Skip to content

Commit 3e640ee

Browse files
Merge pull request #7 from web3settle/develop
@web3settle/merchant-sdk v0.5.0 — gas estimator, telemetry, headless, permit
2 parents 6611005 + c066eb0 commit 3e640ee

39 files changed

Lines changed: 4798 additions & 392 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to `@web3settle/merchant-sdk` will be documented in this fil
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.0] - 2026-05-09
9+
10+
### Added
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
- **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.
16+
17+
### Changed
18+
19+
- `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.
20+
- New multi-entry build outputs: `dist/headless.{js,cjs}`, `dist/wc.{js,cjs}` alongside the existing entries.
21+
822
## [0.4.0] - 2026-04-17
923

1024
### Added

package-lock.json

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@web3settle/merchant-sdk",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "React SDK for accepting crypto payments via Web3Settle (EVM + Solana + TRON)",
55
"type": "module",
66
"main": "./dist/index.cjs",
@@ -25,6 +25,16 @@
2525
"import": "./dist/tron.js",
2626
"require": "./dist/tron.cjs"
2727
},
28+
"./headless": {
29+
"types": "./dist/headless.d.ts",
30+
"import": "./dist/headless.js",
31+
"require": "./dist/headless.cjs"
32+
},
33+
"./wc": {
34+
"types": "./dist/wc.d.ts",
35+
"import": "./dist/wc.js",
36+
"require": "./dist/wc.cjs"
37+
},
2838
"./styles.css": "./dist/styles.css"
2939
},
3040
"files": [
@@ -110,8 +120,12 @@
110120
"wagmi": "^2.14.0"
111121
},
112122
"overrides": {
113-
"axios": "^1.15.0",
114-
"hono": "^4.12.14",
123+
"axios": "^1.16.0",
124+
"hono": "^4.12.18",
125+
"fast-uri": "^3.1.2",
126+
"rpc-websockets": {
127+
"uuid": "^11.1.1"
128+
},
115129
"lodash": "^4.18.1",
116130
"follow-redirects": "^1.16.0",
117131
"esbuild": "^0.25.0"
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
DefaultConfirmationPolicy,
4+
defaultConfirmationPolicy,
5+
createHighValueConfirmationPolicy,
6+
DEFAULT_CONFIRMATION_THRESHOLDS,
7+
CHAIN_FAMILY_REGISTRY,
8+
} from '../core/ConfirmationPolicy';
9+
import type { ChainConfig } from '../core/types';
10+
11+
/**
12+
* Segment 2.2 — ConfirmationPolicy unit tests.
13+
*
14+
* The policy is a pure-data abstraction (no network I/O), so the tests are
15+
* straightforward: verify that each chainId resolves to the SPD-canonical
16+
* value, that ChainConfig overrides win, and that family inference picks
17+
* the right vocabulary.
18+
*
19+
* The SPD-canonical thresholds are defined in `enhancementplan.md` line 94:
20+
* ETH 12, Polygon 30, Base 12, TRON 19, Solana 31.
21+
*/
22+
23+
describe('DefaultConfirmationPolicy — required confirmations (SPD §3.2)', () => {
24+
const policy = new DefaultConfirmationPolicy();
25+
26+
it('returns 12 for Ethereum mainnet (chainId 1)', () => {
27+
expect(policy.requiredConfirmations(1)).toBe(12);
28+
});
29+
30+
it('returns 30 for Polygon mainnet (chainId 137)', () => {
31+
expect(policy.requiredConfirmations(137)).toBe(30);
32+
});
33+
34+
it('returns 12 for Base mainnet (chainId 8453)', () => {
35+
expect(policy.requiredConfirmations(8453)).toBe(12);
36+
});
37+
38+
it('returns 19 for TRON mainnet (TronGrid chainId 728126428)', () => {
39+
expect(policy.requiredConfirmations(728126428)).toBe(19);
40+
});
41+
42+
it('returns 19 for the SDK-internal TRON sentinel (1001)', () => {
43+
expect(policy.requiredConfirmations(1001)).toBe(19);
44+
});
45+
46+
it('returns 31 for Solana mainnet (gateway-internal 901)', () => {
47+
expect(policy.requiredConfirmations(901)).toBe(31);
48+
});
49+
50+
it('falls back to a conservative 12 for unknown chainIds', () => {
51+
expect(policy.requiredConfirmations(999_999)).toBe(12);
52+
});
53+
});
54+
55+
describe('DefaultConfirmationPolicy — family inference', () => {
56+
const policy = new DefaultConfirmationPolicy();
57+
58+
it('classifies the EVM mainnet chainIds as `evm`', () => {
59+
expect(policy.family(1)).toBe('evm');
60+
expect(policy.family(137)).toBe('evm');
61+
expect(policy.family(8453)).toBe('evm');
62+
});
63+
64+
it('classifies TRON chainIds as `tron`', () => {
65+
expect(policy.family(728126428)).toBe('tron');
66+
expect(policy.family(1001)).toBe('tron');
67+
});
68+
69+
it('classifies Solana chainIds as `solana`', () => {
70+
expect(policy.family(900)).toBe('solana');
71+
expect(policy.family(901)).toBe('solana');
72+
expect(policy.family(902)).toBe('solana');
73+
});
74+
75+
it('defaults unknown chainIds to `evm`', () => {
76+
expect(policy.family(424242)).toBe('evm');
77+
});
78+
});
79+
80+
describe('DefaultConfirmationPolicy — Solana commitment level', () => {
81+
it('defaults to `confirmed` for Solana chainIds', () => {
82+
const policy = new DefaultConfirmationPolicy();
83+
expect(policy.commitmentLevel(901)).toBe('confirmed');
84+
});
85+
86+
it('honours an explicit `finalized` override', () => {
87+
const policy = new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' });
88+
expect(policy.commitmentLevel(901)).toBe('finalized');
89+
});
90+
91+
it('returns null for non-Solana chainIds', () => {
92+
const policy = new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' });
93+
expect(policy.commitmentLevel(1)).toBeNull();
94+
expect(policy.commitmentLevel(728126428)).toBeNull();
95+
});
96+
97+
it('createHighValueConfirmationPolicy returns finalized', () => {
98+
expect(createHighValueConfirmationPolicy().commitmentLevel(901)).toBe('finalized');
99+
});
100+
});
101+
102+
describe('DefaultConfirmationPolicy — ChainConfig overrides', () => {
103+
const policy = new DefaultConfirmationPolicy();
104+
105+
function makeConfig(chainId: number, confirmations?: number): ChainConfig {
106+
return {
107+
chainId,
108+
name: 'test',
109+
contractAddress: '0x0000000000000000000000000000000000000001',
110+
tokens: [],
111+
explorerUrl: 'https://example.com',
112+
confirmations,
113+
};
114+
}
115+
116+
it('uses the per-chain override when it is set', () => {
117+
expect(policy.resolve(makeConfig(1, 6))).toBe(6);
118+
});
119+
120+
it('falls back to the canonical default when no override is set', () => {
121+
expect(policy.resolve(makeConfig(1))).toBe(12);
122+
});
123+
124+
it('treats a zero override as "use the default" (defensive — zero is not a valid depth)', () => {
125+
expect(policy.resolve(makeConfig(1, 0))).toBe(12);
126+
});
127+
128+
it('honours overrides on chains that lack a registry entry', () => {
129+
expect(policy.resolve(makeConfig(424242, 5))).toBe(5);
130+
});
131+
});
132+
133+
describe('DefaultConfirmationPolicy — progress descriptor', () => {
134+
const policy = new DefaultConfirmationPolicy();
135+
136+
it('renders "X of N confirmations" for EVM', () => {
137+
const p = policy.progress(1, 8);
138+
expect(p.family).toBe('evm');
139+
expect(p.required).toBe(12);
140+
expect(p.current).toBe(8);
141+
expect(p.label).toBe('8 of 12 confirmations');
142+
});
143+
144+
it('clamps negative current to 0', () => {
145+
const p = policy.progress(1, -3);
146+
expect(p.current).toBe(0);
147+
expect(p.label).toBe('0 of 12 confirmations');
148+
});
149+
150+
it('clamps current to required (cannot exceed)', () => {
151+
const p = policy.progress(1, 99);
152+
expect(p.current).toBe(12);
153+
expect(p.label).toBe('12 of 12 confirmations');
154+
});
155+
156+
it('renders commitment-level state for Solana — Pending → Confirmed → Finalized', () => {
157+
expect(policy.progress(901, 0).label).toContain('Pending');
158+
expect(policy.progress(901, 1).label).toContain('Confirmed');
159+
expect(policy.progress(901, 2).label).toContain('Finalized');
160+
expect(policy.progress(901, 0).label).toContain('confirmed'); // target
161+
});
162+
163+
it('renders confirmations for TRON', () => {
164+
const p = policy.progress(728126428, 10);
165+
expect(p.family).toBe('tron');
166+
expect(p.required).toBe(19);
167+
expect(p.label).toBe('10 of 19 confirmations');
168+
});
169+
});
170+
171+
describe('DefaultConfirmationPolicy — estimated finality time', () => {
172+
const policy = new DefaultConfirmationPolicy();
173+
174+
it('produces a positive estimate for known chains', () => {
175+
expect(policy.estimatedSecondsToFinality(1)).toBeGreaterThan(0);
176+
expect(policy.estimatedSecondsToFinality(137)).toBeGreaterThan(0);
177+
expect(policy.estimatedSecondsToFinality(901)).toBeGreaterThan(0);
178+
});
179+
180+
it('returns 0 for unknown chains (no fabricated estimate)', () => {
181+
expect(policy.estimatedSecondsToFinality(424242)).toBe(0);
182+
});
183+
});
184+
185+
describe('Module-level singletons', () => {
186+
it('defaultConfirmationPolicy is reusable across calls', () => {
187+
expect(defaultConfirmationPolicy.requiredConfirmations(1)).toBe(12);
188+
expect(defaultConfirmationPolicy.commitmentLevel(901)).toBe('confirmed');
189+
});
190+
191+
it('thresholds and family registry are frozen', () => {
192+
expect(Object.isFrozen(DEFAULT_CONFIRMATION_THRESHOLDS)).toBe(true);
193+
expect(Object.isFrozen(CHAIN_FAMILY_REGISTRY)).toBe(true);
194+
});
195+
196+
it('the threshold table covers every family-registered chain', () => {
197+
// Defensive — if you add a chain to one table you must add it to the other.
198+
for (const chainIdStr of Object.keys(CHAIN_FAMILY_REGISTRY)) {
199+
const chainId = Number(chainIdStr);
200+
expect(
201+
DEFAULT_CONFIRMATION_THRESHOLDS[chainId],
202+
`chainId ${chainId} is in CHAIN_FAMILY_REGISTRY but missing from DEFAULT_CONFIRMATION_THRESHOLDS`,
203+
).toBeDefined();
204+
}
205+
});
206+
});
207+
208+
describe('Per-chain locked policies', () => {
209+
it('evmConfirmationPolicy returns null commitment for any chainId', async () => {
210+
const { evmConfirmationPolicy } = await import('../evm/confirmationPolicy');
211+
expect(evmConfirmationPolicy.commitmentLevel(1)).toBeNull();
212+
expect(evmConfirmationPolicy.commitmentLevel(901)).toBeNull(); // even when chainId is Solana
213+
expect(evmConfirmationPolicy.requiredConfirmations(1)).toBe(12);
214+
});
215+
216+
it('solanaConfirmationPolicy defaults to confirmed', async () => {
217+
const { solanaConfirmationPolicy, createSolanaConfirmationPolicy } = await import(
218+
'../solana/confirmationPolicy'
219+
);
220+
expect(solanaConfirmationPolicy.commitmentLevel(901)).toBe('confirmed');
221+
const finalized = createSolanaConfirmationPolicy('finalized');
222+
expect(finalized.commitmentLevel(901)).toBe('finalized');
223+
});
224+
225+
it('tronConfirmationPolicy returns 19 for the TRON mainnet sentinel', async () => {
226+
const { tronConfirmationPolicy } = await import('../tron/confirmationPolicy');
227+
expect(tronConfirmationPolicy.requiredConfirmations(728126428)).toBe(19);
228+
expect(tronConfirmationPolicy.commitmentLevel(728126428)).toBeNull();
229+
});
230+
});

0 commit comments

Comments
 (0)