Skip to content

Commit dd9c97c

Browse files
Merge pull request #9 from web3settle/develop
Doc audit: merchant-sdk
2 parents 3e640ee + 81450ba commit dd9c97c

9 files changed

Lines changed: 410 additions & 8 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ React component library for accepting crypto payments via Web3Settle. Drop in a
99
| `@web3settle/merchant-sdk` | EVM (Ethereum / Polygon / Base) provider + button + modal + hooks | `wagmi`, `viem`, `@wagmi/core`, `@tanstack/react-query` |
1010
| `@web3settle/merchant-sdk/solana` | Solana provider + button + modal + hooks + PDA helpers + raw instruction builders | `@solana/web3.js`, `@solana/wallet-adapter-base`, `@solana/wallet-adapter-react`, plus the wallet-specific adapter packages you want (Phantom, Solflare, …) |
1111
| `@web3settle/merchant-sdk/tron` | TRON provider + button + modal + hooks (TronLink-backed) | TronLink browser extension at runtime. The `tronweb` package is a peer for TypeScript types only — the SDK uses the extension's injected `window.tronWeb` |
12+
| `@web3settle/merchant-sdk/headless` | Framework-agnostic controllers (`createPayButtonController`, `createWalletConnectController`, `createGasEstimateController`) — V0.5.0 — for Vue / Svelte / vanilla JS callers | None beyond your chain-stack peers above |
13+
| `@web3settle/merchant-sdk/wc` | `<web3settle-pay-button>` native HTMLElement built on top of the headless layer — V0.5.0 | None beyond your chain-stack peers above |
1214

1315
EVM-only consumers never pay the bundle cost of the Solana / TRON stacks; Solana-only consumers never pull wagmi. Import only the subpaths you need.
1416

@@ -17,11 +19,14 @@ EVM-only consumers never pay the bundle cost of the Solana / TRON stacks; Solana
1719
- Five chains across three stacks: **Ethereum, Polygon, Base** (wagmi + viem), **Solana** (wallet-adapter + web3.js), **TRON** (TronLink)
1820
- Unified `PaymentPipeline` interface so all three stacks present the same `quoteAmount → needsApproval → approve → execute → waitForReceipt` surface
1921
- Native currency and fungible-token payments on every chain
20-
- **EVM:** ERC-20 approval flow with exact-amount allowance (never unlimited)
22+
- **EVM:** ERC-20 approval flow with exact-amount allowance (never unlimited). **EIP-712 permit** (V0.5.0 / segment 14.6) — when the token implements EIP-2612 the SDK signs the typed-data permit and submits `permit(...)` directly, saving the user one popup and ~$0.50 of gas.
2123
- **Solana:** no-approval direct transfer; PDA derivation + hand-rolled Anchor instruction builders bundled (no `@coral-xyz/anchor` dependency)
2224
- **TRON:** TRC-20 approve + pay, `SafeTRC20`-aware for non-return-value tokens like USDT-TRON
2325
- Built-in wallet connection per chain (injected + WalletConnect on EVM; Phantom / Solflare / Backpack on Solana; TronLink)
2426
- Real-time transaction status tracking with reorg-aware confirmation counts
27+
- **Gas estimator** (V0.5.0 / segment 14.1) — `estimateEvmGas`, `estimateSolanaGas`, `estimateTronGas` — single `GasEstimate` shape across all three chains; the modal renders a `≈ $X` network-fee badge
28+
- **Telemetry breadcrumbs** (V0.5.0 / segment 14.2) — opt-in `onTelemetry` callback emits a privacy-redacted event per failed pay-in (no plain addresses, no amounts; PII-redacted message ≤240 chars)
29+
- **Headless layer + Web Components** (V0.5.0 / segment 14.5) — `@web3settle/merchant-sdk/headless` and `@web3settle/merchant-sdk/wc` (`<web3settle-pay-button>` native HTMLElement) for Vue / Svelte / vanilla JS callers
2530
- CoinGecko price feeds with in-memory caching + stale-while-revalidate fallback
2631
- Dark theme glassmorphism UI with CSS-variable hooks for theming
2732
- Zod-validated API responses at every boundary
@@ -581,7 +586,7 @@ Modern evergreen browsers with ES2020 support (Chrome 94+, Firefox 93+, Safari 1
581586
npm install
582587
npm run dev # Build with watch
583588
npm run build # Production build (tsc + Vite lib)
584-
npm run test # Run Vitest (61 tests)
589+
npm run test # Run Vitest (12 test files, 150 tests)
585590
npm run typecheck # tsc --noEmit
586591
npm run lint # ESLint (flat config, strict type-checked)
587592
npm run audit:ci # Fail on high/critical vulns

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
"module": "./dist/index.js",
88
"types": "./dist/index.d.ts",
99
"sideEffects": [
10-
"**/*.css"
10+
"**/*.css",
11+
"./dist/wc.js",
12+
"./dist/wc.cjs",
13+
"./src/wc/index.ts",
14+
"./src/wc/pay-button.ts"
1115
],
1216
"exports": {
1317
".": {

src/__tests__/permit.test.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { WalletClient } from 'viem';
3+
import {
4+
assertDeadlineFresh,
5+
buildPermitTypedData,
6+
signPermit,
7+
validatePermitSignature,
8+
MAX_PERMIT_DEADLINE_WINDOW_SECONDS,
9+
} from '../evm/permit';
10+
11+
const OWNER = '0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48' as const;
12+
const SPENDER = '0x1111111111111111111111111111111111111111' as const;
13+
const TOKEN = '0x2222222222222222222222222222222222222222' as const;
14+
15+
// 65-byte signature (132 hex chars) shaped like a real EIP-712 reply. r and s
16+
// are non-zero, s is in the low half, v is 27. Used as the wallet's mock reply
17+
// in the signPermit happy-path tests.
18+
const VALID_SIG = ('0x' +
19+
'11'.repeat(32) + // r
20+
'22'.repeat(32) + // s
21+
'1b' // v = 27
22+
) as `0x${string}`;
23+
24+
function fakeWallet(opts: {
25+
account?: `0x${string}`;
26+
chainId?: number;
27+
signature?: `0x${string}`;
28+
signError?: string;
29+
} = {}) {
30+
const account = opts.account ?? OWNER;
31+
const chainId = opts.chainId ?? 1;
32+
const signature = opts.signature ?? VALID_SIG;
33+
return {
34+
getAddresses: vi.fn().mockResolvedValue([account]),
35+
getChainId: vi.fn().mockResolvedValue(chainId),
36+
signTypedData: vi.fn().mockImplementation(() => {
37+
if (opts.signError) return Promise.reject(new Error(opts.signError));
38+
return Promise.resolve(signature);
39+
}),
40+
} as unknown as WalletClient;
41+
}
42+
43+
describe('assertDeadlineFresh', () => {
44+
it('accepts a deadline within the SDK cap window', () => {
45+
const now = Math.floor(Date.now() / 1000);
46+
const deadline = BigInt(now + 30 * 60); // 30 minutes
47+
expect(() => assertDeadlineFresh(deadline)).not.toThrow();
48+
});
49+
50+
it('rejects a deadline already in the past', () => {
51+
const now = Math.floor(Date.now() / 1000);
52+
expect(() => assertDeadlineFresh(BigInt(now - 1))).toThrow(/not in the future/);
53+
});
54+
55+
it('rejects an unbounded deadline (e.g. MAX_SAFE_INTEGER)', () => {
56+
// Acts as a no-expiry bearer permit — defeats the EIP-2612 deadline
57+
// mechanism. Must be rejected so a bug in the caller can't sign one.
58+
expect(() => assertDeadlineFresh(BigInt(Number.MAX_SAFE_INTEGER))).toThrow(/exceeds the SDK cap/);
59+
});
60+
61+
it('rejects a deadline more than the cap into the future', () => {
62+
const now = Math.floor(Date.now() / 1000);
63+
const tooFar = BigInt(now + MAX_PERMIT_DEADLINE_WINDOW_SECONDS + 60);
64+
expect(() => assertDeadlineFresh(tooFar)).toThrow(/exceeds the SDK cap/);
65+
});
66+
67+
it('honors a caller-provided larger window when explicitly set', () => {
68+
// The cap is a default; advanced callers can opt out by supplying a wider
69+
// bound. This keeps the function flexible while making the safe path
70+
// automatic.
71+
const now = Math.floor(Date.now() / 1000);
72+
const twoHours = 60 * 60 * 2;
73+
expect(() => assertDeadlineFresh(BigInt(now + twoHours), now, twoHours + 1)).not.toThrow();
74+
});
75+
});
76+
77+
describe('buildPermitTypedData', () => {
78+
it('packs the EIP-2612 domain with chainId, name, version, verifyingContract', () => {
79+
const td = buildPermitTypedData({
80+
chainId: 137,
81+
tokenAddress: TOKEN,
82+
tokenName: 'USD Coin',
83+
tokenVersion: '2',
84+
owner: OWNER,
85+
spender: SPENDER,
86+
value: 1_000_000n,
87+
nonce: 5n,
88+
deadline: 9_999_999_999n,
89+
});
90+
expect(td.domain).toEqual({
91+
name: 'USD Coin',
92+
version: '2',
93+
chainId: 137,
94+
verifyingContract: TOKEN,
95+
});
96+
expect(td.primaryType).toBe('Permit');
97+
// Permit struct must match EIP-2612 exactly (owner, spender, value, nonce, deadline).
98+
expect(td.types.Permit).toEqual([
99+
{ name: 'owner', type: 'address' },
100+
{ name: 'spender', type: 'address' },
101+
{ name: 'value', type: 'uint256' },
102+
{ name: 'nonce', type: 'uint256' },
103+
{ name: 'deadline', type: 'uint256' },
104+
]);
105+
});
106+
107+
it('defaults version to "1" when omitted', () => {
108+
const td = buildPermitTypedData({
109+
chainId: 1,
110+
tokenAddress: TOKEN,
111+
tokenName: 'DAI',
112+
owner: OWNER,
113+
spender: SPENDER,
114+
value: 1n,
115+
nonce: 0n,
116+
deadline: 9_999_999_999n,
117+
});
118+
expect(td.domain.version).toBe('1');
119+
});
120+
});
121+
122+
describe('validatePermitSignature', () => {
123+
it('accepts a well-formed signature with low-s and v=27', () => {
124+
expect(validatePermitSignature(VALID_SIG)).toEqual({ valid: true });
125+
});
126+
127+
it('rejects a wrong-length string', () => {
128+
const short = '0xdeadbeef' as `0x${string}`;
129+
const out = validatePermitSignature(short);
130+
expect(out.valid).toBe(false);
131+
expect(out.reason).toMatch(/Expected 132 hex chars/);
132+
});
133+
134+
it('rejects an r=0 signature', () => {
135+
const sig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`;
136+
expect(validatePermitSignature(sig).valid).toBe(false);
137+
});
138+
139+
it('rejects a high-s signature (EIP-2 malleability)', () => {
140+
// s = secp256k1 N - 1 → high half of the curve order.
141+
const highS = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140';
142+
const sig = ('0x' + '11'.repeat(32) + highS + '1b') as `0x${string}`;
143+
expect(validatePermitSignature(sig).valid).toBe(false);
144+
});
145+
146+
it('rejects an out-of-range v', () => {
147+
const sig = ('0x' + '11'.repeat(32) + '22'.repeat(32) + 'ff') as `0x${string}`;
148+
expect(validatePermitSignature(sig).valid).toBe(false);
149+
});
150+
});
151+
152+
describe('signPermit', () => {
153+
function freshDeadline(): bigint {
154+
return BigInt(Math.floor(Date.now() / 1000) + 5 * 60);
155+
}
156+
157+
it('signs and returns split v/r/s for a valid input', async () => {
158+
const wallet = fakeWallet();
159+
const out = await signPermit({
160+
walletClient: wallet,
161+
chainId: 1,
162+
tokenAddress: TOKEN,
163+
tokenName: 'USD Coin',
164+
tokenVersion: '2',
165+
owner: OWNER,
166+
spender: SPENDER,
167+
value: 1_000_000n,
168+
nonce: 0n,
169+
deadline: freshDeadline(),
170+
});
171+
expect(out.signature).toBe(VALID_SIG);
172+
expect([27, 28]).toContain(out.v);
173+
expect(out.r).toMatch(/^0x[0-9a-f]{64}$/i);
174+
expect(out.s).toMatch(/^0x[0-9a-f]{64}$/i);
175+
});
176+
177+
it('refuses to sign when the wallet account differs from the permit owner', async () => {
178+
const wallet = fakeWallet({ account: '0x9999999999999999999999999999999999999999' });
179+
await expect(signPermit({
180+
walletClient: wallet,
181+
chainId: 1,
182+
tokenAddress: TOKEN,
183+
tokenName: 'USD Coin',
184+
owner: OWNER,
185+
spender: SPENDER,
186+
value: 1n,
187+
nonce: 0n,
188+
deadline: freshDeadline(),
189+
})).rejects.toThrow(/does not match permit owner/);
190+
});
191+
192+
it('refuses to sign when the wallet chainId disagrees with the permit chainId', async () => {
193+
// The wallet is on chain 137 but the caller is asking us to sign for chain 1.
194+
// The signed message would be replayable on the wallet's actual chain.
195+
const wallet = fakeWallet({ chainId: 137 });
196+
await expect(signPermit({
197+
walletClient: wallet,
198+
chainId: 1,
199+
tokenAddress: TOKEN,
200+
tokenName: 'USD Coin',
201+
owner: OWNER,
202+
spender: SPENDER,
203+
value: 1n,
204+
nonce: 0n,
205+
deadline: freshDeadline(),
206+
})).rejects.toThrow(/Wallet chainId 137 does not match permit chainId 1/);
207+
});
208+
209+
it('refuses to sign with a MAX_SAFE_INTEGER deadline (no expiry)', async () => {
210+
const wallet = fakeWallet();
211+
await expect(signPermit({
212+
walletClient: wallet,
213+
chainId: 1,
214+
tokenAddress: TOKEN,
215+
tokenName: 'USD Coin',
216+
owner: OWNER,
217+
spender: SPENDER,
218+
value: 1n,
219+
nonce: 0n,
220+
deadline: BigInt(Number.MAX_SAFE_INTEGER),
221+
})).rejects.toThrow(/exceeds the SDK cap/);
222+
});
223+
224+
it('throws when the wallet returns a malformed signature', async () => {
225+
// r=0 → fails validatePermitSignature inside signPermit before returning.
226+
const badSig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`;
227+
const wallet = fakeWallet({ signature: badSig });
228+
await expect(signPermit({
229+
walletClient: wallet,
230+
chainId: 1,
231+
tokenAddress: TOKEN,
232+
tokenName: 'USD Coin',
233+
owner: OWNER,
234+
spender: SPENDER,
235+
value: 1n,
236+
nonce: 0n,
237+
deadline: freshDeadline(),
238+
})).rejects.toThrow(/invalid permit signature/);
239+
});
240+
});

src/__tests__/solana-estimateGas.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ describe('estimateSolanaGas (native)', () => {
128128
expect(out.native).toBe(LAMPORTS_PER_SIGNATURE);
129129
});
130130

131+
it('throws when BOTH simulate and getRecentPrioritizationFees fail', async () => {
132+
// When both dynamic sources are unavailable the only thing left is the
133+
// 5000-lamport signature fee, which would render "≈ $0.001" and be
134+
// misleading on a congested cluster. The estimator should refuse rather
135+
// than silently produce a fake-looking number.
136+
const conn = mockConnection({ simulateError: true, feesError: true });
137+
await expect(estimateSolanaGas({
138+
connection: conn,
139+
sender: SENDER,
140+
programId: PROGRAM_ID,
141+
merchantId: MERCHANT_ID,
142+
token: NATIVE_TOKEN_SENTINEL,
143+
amount: 1n,
144+
})).rejects.toThrow(/Solana fee estimate unavailable/);
145+
});
146+
131147
it('converts lamports to USD when a priceUsd oracle is supplied', async () => {
132148
const conn = mockConnection({ unitsConsumed: 50_000, prioritization: [] });
133149
const out = await estimateSolanaGas(

src/__tests__/telemetry.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,37 @@ describe('redactErrorMessage', () => {
6767
expect(out.length).toBeLessThanOrEqual(240);
6868
expect(out.endsWith('...')).toBe(true);
6969
});
70+
71+
it('redacts POSIX absolute paths from stack-trace fragments', () => {
72+
const msg = 'TypeError at /Users/alice/src/wallet/index.ts:42';
73+
const out = redactErrorMessage(msg) ?? '';
74+
expect(out).not.toContain('/Users/alice');
75+
expect(out).toContain('<path>');
76+
});
77+
78+
it('redacts Windows absolute paths', () => {
79+
const msg = 'Failed loading C:\\Users\\bob\\AppData\\Local\\app\\index.js';
80+
const out = redactErrorMessage(msg) ?? '';
81+
expect(out).not.toMatch(/C:\\Users\\bob/);
82+
expect(out).toContain('<path>');
83+
});
84+
85+
it('redacts file:// URLs', () => {
86+
const msg = 'thrown at file:///home/vscode/workspace/x/y.ts:10:5';
87+
const out = redactErrorMessage(msg) ?? '';
88+
expect(out).not.toContain('file:///home');
89+
expect(out).toContain('<path>');
90+
});
91+
92+
it('redacts long unbroken hex blobs (private-key shaped)', () => {
93+
// A 64+ char hex run is consistent with a raw private key, raw signature,
94+
// or session token. Strip rather than risk leaking via 3rd-party
95+
// analytics.
96+
const msg = 'leaked secret 0x' + 'ab'.repeat(40); // 80 hex chars, way past the floor
97+
const out = redactErrorMessage(msg) ?? '';
98+
expect(out).not.toMatch(/ab{20,}/i);
99+
expect(out).toContain('<');
100+
});
70101
});
71102

72103
describe('hashWalletAddress', () => {

src/core/telemetry.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,21 @@ export async function hashWalletAddress(address: string | null | undefined): Pro
139139
}
140140

141141
/**
142-
* Strip values that look like wallet addresses, pubkeys, or UUIDs from a
143-
* developer error message. Keeps the message under 240 chars.
142+
* Strip values that look like wallet addresses, pubkeys, UUIDs, filesystem
143+
* paths, or long secrets from a developer error message. Keeps the message
144+
* under 240 chars.
145+
*
146+
* The redaction list reflects what we've seen leak through `Error.message` /
147+
* `Error.stack` in browser & Node SDKs:
148+
* - EVM addresses + tx hashes (caller's wallet, our contract).
149+
* - UUIDs (session ids).
150+
* - Solana / TRON base58 (caller's wallet).
151+
* - Absolute filesystem paths from stack traces (`/Users/`, `/home/`,
152+
* `C:\`, `file://`) — these reveal username and source-file layout when
153+
* the integrator pipes the message into a 3rd-party analytics pipeline.
154+
* - Long unbroken hex blobs (≥64 chars) — catches private keys, raw
155+
* signatures, or session tokens that occasionally surface in nested
156+
* wallet errors.
144157
*/
145158
export function redactErrorMessage(message: string | undefined): string | undefined {
146159
if (!message) return undefined;
@@ -151,6 +164,18 @@ export function redactErrorMessage(message: string | undefined): string | undefi
151164
.replace(/0x[a-fA-F0-9]{64}/g, '0x<redacted>')
152165
// UUID-shaped strings
153166
.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '<uuid>')
167+
// POSIX absolute paths (`/Users/alice/...`, `/home/bob/...`, `/private/tmp/...`).
168+
// Catches stack-trace fragments that leak the integrator's username.
169+
.replace(/\/(?:Users|home|root|private|var|opt|srv|tmp)\/[^\s)"']+/gi, '<path>')
170+
// Windows-style absolute paths (`C:\Users\...`).
171+
.replace(/[A-Za-z]:\\[^\s)"']+/g, '<path>')
172+
// file:// URLs from JS stack traces.
173+
.replace(/file:\/\/[^\s)"']+/g, '<path>')
174+
// Long bare hex blobs (≥64 chars). Catches private keys, raw 65-byte
175+
// signatures, and session-token-shaped strings nested inside wallet
176+
// error messages. The 64-char floor avoids false positives on shorter
177+
// identifiers.
178+
.replace(/(?:0x)?[a-fA-F0-9]{64,}/g, '<hex>')
154179
// Solana / TRON base58 (32–44 chars, no 0/O/I/l) — be conservative, only
155180
// redact when the substring is a standalone token (whitespace bounded).
156181
.replace(/(^|\s)[1-9A-HJ-NP-Za-km-z]{32,44}(?=\s|[,.;:]|$)/g, '$1<addr>');

0 commit comments

Comments
 (0)