Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to `@web3settle/merchant-sdk` will be documented in this fil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-05-09

### Added

- **Gas estimator (item 14.1)** — `estimateEvmGas`, `estimateEvmApproveGas`, `estimateSolanaGas` (with `buildSolanaEstimateInstruction`, `LAMPORTS_PER_SIGNATURE`), `estimateTronGas` / `computeTronCost` (with `DEFAULT_SUN_PER_ENERGY`). Single `GasEstimate` shape across all three chains: `{ native, usd, breakdown }`. The TopUpModal now renders a `≈ $X` network-fee badge under the quote when an estimate is available; failure to estimate hides the badge silently and never blocks pay.
- **Telemetry breadcrumbs (item 14.2)** — opt-in `onTelemetry` callback on `Web3SettleConfig`, plus `core/telemetry`: `buildTelemetryEvent`, `redactErrorMessage`, `hashWalletAddress`, `safeEmit`. EVM, Solana, and TRON payment hooks emit a single `TelemetryEvent` per failed pay-in with `{ chain, phase, errorCode, walletId, contractVersion, walletDigest, message }`. Privacy contract: no plain addresses (only an opaque SHA-256 prefix), no amounts, message is PII-redacted to ≤240 chars. The callback is wrapped in `safeEmit` so a buggy analytics handler can never break the payment flow.
- **Headless layer + Web Components (item 14.5)** — new subpath exports `@web3settle/merchant-sdk/headless` (`createPayButtonController`, `createWalletConnectController`, `createGasEstimateController`) and `@web3settle/merchant-sdk/wc` (`<web3settle-pay-button>` native HTMLElement). The headless controllers expose a `subscribe()` API with no React imports, so Vue/Svelte/vanilla JS callers can drive the same flow. The Web Component reuses the headless controller end-to-end.
- **EIP-712 permit signing (item 14.6)** — `evm/permit`: `detectPermitSupport`, `signPermit`, `buildPermitTypedData`, `validatePermitSignature`, `assertDeadlineFresh`. The pay-token EVM flow now accepts a `permit?: 'auto' | 'never' | 'require'` option (default `'auto'`): when the token implements EIP-2612, the SDK signs the typed-data permit and submits `permit(...)` directly instead of running a separate `approve()` tx. Saves the user one popup and ~$0.50 of gas.

### Changed

- `Web3SettleConfig` now carries optional `onTelemetry` and `contractVersion` fields. Both are threaded through `usePayment.startPayment` (and the Solana / TRON equivalents) so the modal does not need to wire them manually.
- New multi-entry build outputs: `dist/headless.{js,cjs}`, `dist/wc.{js,cjs}` alongside the existing entries.

## [0.4.0] - 2026-04-17

### Added
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 17 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@web3settle/merchant-sdk",
"version": "0.4.0",
"version": "0.5.0",
"description": "React SDK for accepting crypto payments via Web3Settle (EVM + Solana + TRON)",
"type": "module",
"main": "./dist/index.cjs",
Expand All @@ -25,6 +25,16 @@
"import": "./dist/tron.js",
"require": "./dist/tron.cjs"
},
"./headless": {
"types": "./dist/headless.d.ts",
"import": "./dist/headless.js",
"require": "./dist/headless.cjs"
},
"./wc": {
"types": "./dist/wc.d.ts",
"import": "./dist/wc.js",
"require": "./dist/wc.cjs"
},
"./styles.css": "./dist/styles.css"
},
"files": [
Expand Down Expand Up @@ -110,8 +120,12 @@
"wagmi": "^2.14.0"
},
"overrides": {
"axios": "^1.15.0",
"hono": "^4.12.14",
"axios": "^1.16.0",
"hono": "^4.12.18",
"fast-uri": "^3.1.2",
"rpc-websockets": {
"uuid": "^11.1.1"
},
"lodash": "^4.18.1",
"follow-redirects": "^1.16.0",
"esbuild": "^0.25.0"
Expand Down
230 changes: 230 additions & 0 deletions src/__tests__/confirmationPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, it, expect } from 'vitest';
import {
DefaultConfirmationPolicy,
defaultConfirmationPolicy,
createHighValueConfirmationPolicy,
DEFAULT_CONFIRMATION_THRESHOLDS,
CHAIN_FAMILY_REGISTRY,
} from '../core/ConfirmationPolicy';
import type { ChainConfig } from '../core/types';

/**
* Segment 2.2 — ConfirmationPolicy unit tests.
*
* The policy is a pure-data abstraction (no network I/O), so the tests are
* straightforward: verify that each chainId resolves to the SPD-canonical
* value, that ChainConfig overrides win, and that family inference picks
* the right vocabulary.
*
* The SPD-canonical thresholds are defined in `enhancementplan.md` line 94:
* ETH 12, Polygon 30, Base 12, TRON 19, Solana 31.
*/

describe('DefaultConfirmationPolicy — required confirmations (SPD §3.2)', () => {
const policy = new DefaultConfirmationPolicy();

it('returns 12 for Ethereum mainnet (chainId 1)', () => {
expect(policy.requiredConfirmations(1)).toBe(12);
});

it('returns 30 for Polygon mainnet (chainId 137)', () => {
expect(policy.requiredConfirmations(137)).toBe(30);
});

it('returns 12 for Base mainnet (chainId 8453)', () => {
expect(policy.requiredConfirmations(8453)).toBe(12);
});

it('returns 19 for TRON mainnet (TronGrid chainId 728126428)', () => {
expect(policy.requiredConfirmations(728126428)).toBe(19);
});

it('returns 19 for the SDK-internal TRON sentinel (1001)', () => {
expect(policy.requiredConfirmations(1001)).toBe(19);
});

it('returns 31 for Solana mainnet (gateway-internal 901)', () => {
expect(policy.requiredConfirmations(901)).toBe(31);
});

it('falls back to a conservative 12 for unknown chainIds', () => {
expect(policy.requiredConfirmations(999_999)).toBe(12);
});
});

describe('DefaultConfirmationPolicy — family inference', () => {
const policy = new DefaultConfirmationPolicy();

it('classifies the EVM mainnet chainIds as `evm`', () => {
expect(policy.family(1)).toBe('evm');
expect(policy.family(137)).toBe('evm');
expect(policy.family(8453)).toBe('evm');
});

it('classifies TRON chainIds as `tron`', () => {
expect(policy.family(728126428)).toBe('tron');
expect(policy.family(1001)).toBe('tron');
});

it('classifies Solana chainIds as `solana`', () => {
expect(policy.family(900)).toBe('solana');
expect(policy.family(901)).toBe('solana');
expect(policy.family(902)).toBe('solana');
});

it('defaults unknown chainIds to `evm`', () => {
expect(policy.family(424242)).toBe('evm');
});
});

describe('DefaultConfirmationPolicy — Solana commitment level', () => {
it('defaults to `confirmed` for Solana chainIds', () => {
const policy = new DefaultConfirmationPolicy();
expect(policy.commitmentLevel(901)).toBe('confirmed');
});

it('honours an explicit `finalized` override', () => {
const policy = new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' });
expect(policy.commitmentLevel(901)).toBe('finalized');
});

it('returns null for non-Solana chainIds', () => {
const policy = new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' });
expect(policy.commitmentLevel(1)).toBeNull();
expect(policy.commitmentLevel(728126428)).toBeNull();
});

it('createHighValueConfirmationPolicy returns finalized', () => {
expect(createHighValueConfirmationPolicy().commitmentLevel(901)).toBe('finalized');
});
});

describe('DefaultConfirmationPolicy — ChainConfig overrides', () => {
const policy = new DefaultConfirmationPolicy();

function makeConfig(chainId: number, confirmations?: number): ChainConfig {
return {
chainId,
name: 'test',
contractAddress: '0x0000000000000000000000000000000000000001',
tokens: [],
explorerUrl: 'https://example.com',
confirmations,
};
}

it('uses the per-chain override when it is set', () => {
expect(policy.resolve(makeConfig(1, 6))).toBe(6);
});

it('falls back to the canonical default when no override is set', () => {
expect(policy.resolve(makeConfig(1))).toBe(12);
});

it('treats a zero override as "use the default" (defensive — zero is not a valid depth)', () => {
expect(policy.resolve(makeConfig(1, 0))).toBe(12);
});

it('honours overrides on chains that lack a registry entry', () => {
expect(policy.resolve(makeConfig(424242, 5))).toBe(5);
});
});

describe('DefaultConfirmationPolicy — progress descriptor', () => {
const policy = new DefaultConfirmationPolicy();

it('renders "X of N confirmations" for EVM', () => {
const p = policy.progress(1, 8);
expect(p.family).toBe('evm');
expect(p.required).toBe(12);
expect(p.current).toBe(8);
expect(p.label).toBe('8 of 12 confirmations');
});

it('clamps negative current to 0', () => {
const p = policy.progress(1, -3);
expect(p.current).toBe(0);
expect(p.label).toBe('0 of 12 confirmations');
});

it('clamps current to required (cannot exceed)', () => {
const p = policy.progress(1, 99);
expect(p.current).toBe(12);
expect(p.label).toBe('12 of 12 confirmations');
});

it('renders commitment-level state for Solana — Pending → Confirmed → Finalized', () => {
expect(policy.progress(901, 0).label).toContain('Pending');
expect(policy.progress(901, 1).label).toContain('Confirmed');
expect(policy.progress(901, 2).label).toContain('Finalized');
expect(policy.progress(901, 0).label).toContain('confirmed'); // target
});

it('renders confirmations for TRON', () => {
const p = policy.progress(728126428, 10);
expect(p.family).toBe('tron');
expect(p.required).toBe(19);
expect(p.label).toBe('10 of 19 confirmations');
});
});

describe('DefaultConfirmationPolicy — estimated finality time', () => {
const policy = new DefaultConfirmationPolicy();

it('produces a positive estimate for known chains', () => {
expect(policy.estimatedSecondsToFinality(1)).toBeGreaterThan(0);
expect(policy.estimatedSecondsToFinality(137)).toBeGreaterThan(0);
expect(policy.estimatedSecondsToFinality(901)).toBeGreaterThan(0);
});

it('returns 0 for unknown chains (no fabricated estimate)', () => {
expect(policy.estimatedSecondsToFinality(424242)).toBe(0);
});
});

describe('Module-level singletons', () => {
it('defaultConfirmationPolicy is reusable across calls', () => {
expect(defaultConfirmationPolicy.requiredConfirmations(1)).toBe(12);
expect(defaultConfirmationPolicy.commitmentLevel(901)).toBe('confirmed');
});

it('thresholds and family registry are frozen', () => {
expect(Object.isFrozen(DEFAULT_CONFIRMATION_THRESHOLDS)).toBe(true);
expect(Object.isFrozen(CHAIN_FAMILY_REGISTRY)).toBe(true);
});

it('the threshold table covers every family-registered chain', () => {
// Defensive — if you add a chain to one table you must add it to the other.
for (const chainIdStr of Object.keys(CHAIN_FAMILY_REGISTRY)) {
const chainId = Number(chainIdStr);
expect(
DEFAULT_CONFIRMATION_THRESHOLDS[chainId],
`chainId ${chainId} is in CHAIN_FAMILY_REGISTRY but missing from DEFAULT_CONFIRMATION_THRESHOLDS`,
).toBeDefined();
}
});
});

describe('Per-chain locked policies', () => {
it('evmConfirmationPolicy returns null commitment for any chainId', async () => {
const { evmConfirmationPolicy } = await import('../evm/confirmationPolicy');
expect(evmConfirmationPolicy.commitmentLevel(1)).toBeNull();
expect(evmConfirmationPolicy.commitmentLevel(901)).toBeNull(); // even when chainId is Solana
expect(evmConfirmationPolicy.requiredConfirmations(1)).toBe(12);
});

it('solanaConfirmationPolicy defaults to confirmed', async () => {
const { solanaConfirmationPolicy, createSolanaConfirmationPolicy } = await import(
'../solana/confirmationPolicy'
);
expect(solanaConfirmationPolicy.commitmentLevel(901)).toBe('confirmed');
const finalized = createSolanaConfirmationPolicy('finalized');
expect(finalized.commitmentLevel(901)).toBe('finalized');
});

it('tronConfirmationPolicy returns 19 for the TRON mainnet sentinel', async () => {
const { tronConfirmationPolicy } = await import('../tron/confirmationPolicy');
expect(tronConfirmationPolicy.requiredConfirmations(728126428)).toBe(19);
expect(tronConfirmationPolicy.commitmentLevel(728126428)).toBeNull();
});
});
Loading
Loading