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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ React component library for accepting crypto payments via Web3Settle. Drop in a
| `@web3settle/merchant-sdk` | EVM (Ethereum / Polygon / Base) provider + button + modal + hooks | `wagmi`, `viem`, `@wagmi/core`, `@tanstack/react-query` |
| `@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, …) |
| `@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` |
| `@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 |
| `@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 |

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.

Expand All @@ -17,11 +19,14 @@ EVM-only consumers never pay the bundle cost of the Solana / TRON stacks; Solana
- Five chains across three stacks: **Ethereum, Polygon, Base** (wagmi + viem), **Solana** (wallet-adapter + web3.js), **TRON** (TronLink)
- Unified `PaymentPipeline` interface so all three stacks present the same `quoteAmount → needsApproval → approve → execute → waitForReceipt` surface
- Native currency and fungible-token payments on every chain
- **EVM:** ERC-20 approval flow with exact-amount allowance (never unlimited)
- **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.
- **Solana:** no-approval direct transfer; PDA derivation + hand-rolled Anchor instruction builders bundled (no `@coral-xyz/anchor` dependency)
- **TRON:** TRC-20 approve + pay, `SafeTRC20`-aware for non-return-value tokens like USDT-TRON
- Built-in wallet connection per chain (injected + WalletConnect on EVM; Phantom / Solflare / Backpack on Solana; TronLink)
- Real-time transaction status tracking with reorg-aware confirmation counts
- **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
- **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)
- **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
- CoinGecko price feeds with in-memory caching + stale-while-revalidate fallback
- Dark theme glassmorphism UI with CSS-variable hooks for theming
- Zod-validated API responses at every boundary
Expand Down Expand Up @@ -581,7 +586,7 @@ Modern evergreen browsers with ES2020 support (Chrome 94+, Firefox 93+, Safari 1
npm install
npm run dev # Build with watch
npm run build # Production build (tsc + Vite lib)
npm run test # Run Vitest (61 tests)
npm run test # Run Vitest (12 test files, 150 tests)
npm run typecheck # tsc --noEmit
npm run lint # ESLint (flat config, strict type-checked)
npm run audit:ci # Fail on high/critical vulns
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"**/*.css"
"**/*.css",
"./dist/wc.js",
"./dist/wc.cjs",
"./src/wc/index.ts",
"./src/wc/pay-button.ts"
],
"exports": {
".": {
Expand Down
240 changes: 240 additions & 0 deletions src/__tests__/permit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { describe, it, expect, vi } from 'vitest';
import type { WalletClient } from 'viem';
import {
assertDeadlineFresh,
buildPermitTypedData,
signPermit,
validatePermitSignature,
MAX_PERMIT_DEADLINE_WINDOW_SECONDS,
} from '../evm/permit';

const OWNER = '0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48' as const;
const SPENDER = '0x1111111111111111111111111111111111111111' as const;
const TOKEN = '0x2222222222222222222222222222222222222222' as const;

// 65-byte signature (132 hex chars) shaped like a real EIP-712 reply. r and s
// are non-zero, s is in the low half, v is 27. Used as the wallet's mock reply
// in the signPermit happy-path tests.
const VALID_SIG = ('0x' +
'11'.repeat(32) + // r
'22'.repeat(32) + // s
'1b' // v = 27
) as `0x${string}`;

function fakeWallet(opts: {
account?: `0x${string}`;
chainId?: number;
signature?: `0x${string}`;
signError?: string;
} = {}) {
const account = opts.account ?? OWNER;
const chainId = opts.chainId ?? 1;
const signature = opts.signature ?? VALID_SIG;
return {
getAddresses: vi.fn().mockResolvedValue([account]),
getChainId: vi.fn().mockResolvedValue(chainId),
signTypedData: vi.fn().mockImplementation(() => {
if (opts.signError) return Promise.reject(new Error(opts.signError));
return Promise.resolve(signature);
}),
} as unknown as WalletClient;
}

describe('assertDeadlineFresh', () => {
it('accepts a deadline within the SDK cap window', () => {
const now = Math.floor(Date.now() / 1000);
const deadline = BigInt(now + 30 * 60); // 30 minutes
expect(() => assertDeadlineFresh(deadline)).not.toThrow();
});

it('rejects a deadline already in the past', () => {
const now = Math.floor(Date.now() / 1000);
expect(() => assertDeadlineFresh(BigInt(now - 1))).toThrow(/not in the future/);
});

it('rejects an unbounded deadline (e.g. MAX_SAFE_INTEGER)', () => {
// Acts as a no-expiry bearer permit — defeats the EIP-2612 deadline
// mechanism. Must be rejected so a bug in the caller can't sign one.
expect(() => assertDeadlineFresh(BigInt(Number.MAX_SAFE_INTEGER))).toThrow(/exceeds the SDK cap/);
});

it('rejects a deadline more than the cap into the future', () => {
const now = Math.floor(Date.now() / 1000);
const tooFar = BigInt(now + MAX_PERMIT_DEADLINE_WINDOW_SECONDS + 60);
expect(() => assertDeadlineFresh(tooFar)).toThrow(/exceeds the SDK cap/);
});

it('honors a caller-provided larger window when explicitly set', () => {
// The cap is a default; advanced callers can opt out by supplying a wider
// bound. This keeps the function flexible while making the safe path
// automatic.
const now = Math.floor(Date.now() / 1000);
const twoHours = 60 * 60 * 2;
expect(() => assertDeadlineFresh(BigInt(now + twoHours), now, twoHours + 1)).not.toThrow();
});
});

describe('buildPermitTypedData', () => {
it('packs the EIP-2612 domain with chainId, name, version, verifyingContract', () => {
const td = buildPermitTypedData({
chainId: 137,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
tokenVersion: '2',
owner: OWNER,
spender: SPENDER,
value: 1_000_000n,
nonce: 5n,
deadline: 9_999_999_999n,
});
expect(td.domain).toEqual({
name: 'USD Coin',
version: '2',
chainId: 137,
verifyingContract: TOKEN,
});
expect(td.primaryType).toBe('Permit');
// Permit struct must match EIP-2612 exactly (owner, spender, value, nonce, deadline).
expect(td.types.Permit).toEqual([
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
]);
});

it('defaults version to "1" when omitted', () => {
const td = buildPermitTypedData({
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'DAI',
owner: OWNER,
spender: SPENDER,
value: 1n,
nonce: 0n,
deadline: 9_999_999_999n,
});
expect(td.domain.version).toBe('1');
});
});

describe('validatePermitSignature', () => {
it('accepts a well-formed signature with low-s and v=27', () => {
expect(validatePermitSignature(VALID_SIG)).toEqual({ valid: true });
});

it('rejects a wrong-length string', () => {
const short = '0xdeadbeef' as `0x${string}`;
const out = validatePermitSignature(short);
expect(out.valid).toBe(false);
expect(out.reason).toMatch(/Expected 132 hex chars/);
});

it('rejects an r=0 signature', () => {
const sig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`;
expect(validatePermitSignature(sig).valid).toBe(false);
});

it('rejects a high-s signature (EIP-2 malleability)', () => {
// s = secp256k1 N - 1 → high half of the curve order.
const highS = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140';
const sig = ('0x' + '11'.repeat(32) + highS + '1b') as `0x${string}`;
expect(validatePermitSignature(sig).valid).toBe(false);
});

it('rejects an out-of-range v', () => {
const sig = ('0x' + '11'.repeat(32) + '22'.repeat(32) + 'ff') as `0x${string}`;
expect(validatePermitSignature(sig).valid).toBe(false);
});
});

describe('signPermit', () => {
function freshDeadline(): bigint {
return BigInt(Math.floor(Date.now() / 1000) + 5 * 60);
}

it('signs and returns split v/r/s for a valid input', async () => {
const wallet = fakeWallet();
const out = await signPermit({
walletClient: wallet,
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
tokenVersion: '2',
owner: OWNER,
spender: SPENDER,
value: 1_000_000n,
nonce: 0n,
deadline: freshDeadline(),
});
expect(out.signature).toBe(VALID_SIG);
expect([27, 28]).toContain(out.v);
expect(out.r).toMatch(/^0x[0-9a-f]{64}$/i);
expect(out.s).toMatch(/^0x[0-9a-f]{64}$/i);
});

it('refuses to sign when the wallet account differs from the permit owner', async () => {
const wallet = fakeWallet({ account: '0x9999999999999999999999999999999999999999' });
await expect(signPermit({
walletClient: wallet,
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
owner: OWNER,
spender: SPENDER,
value: 1n,
nonce: 0n,
deadline: freshDeadline(),
})).rejects.toThrow(/does not match permit owner/);
});

it('refuses to sign when the wallet chainId disagrees with the permit chainId', async () => {
// The wallet is on chain 137 but the caller is asking us to sign for chain 1.
// The signed message would be replayable on the wallet's actual chain.
const wallet = fakeWallet({ chainId: 137 });
await expect(signPermit({
walletClient: wallet,
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
owner: OWNER,
spender: SPENDER,
value: 1n,
nonce: 0n,
deadline: freshDeadline(),
})).rejects.toThrow(/Wallet chainId 137 does not match permit chainId 1/);
});

it('refuses to sign with a MAX_SAFE_INTEGER deadline (no expiry)', async () => {
const wallet = fakeWallet();
await expect(signPermit({
walletClient: wallet,
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
owner: OWNER,
spender: SPENDER,
value: 1n,
nonce: 0n,
deadline: BigInt(Number.MAX_SAFE_INTEGER),
})).rejects.toThrow(/exceeds the SDK cap/);
});

it('throws when the wallet returns a malformed signature', async () => {
// r=0 → fails validatePermitSignature inside signPermit before returning.
const badSig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`;
const wallet = fakeWallet({ signature: badSig });
await expect(signPermit({
walletClient: wallet,
chainId: 1,
tokenAddress: TOKEN,
tokenName: 'USD Coin',
owner: OWNER,
spender: SPENDER,
value: 1n,
nonce: 0n,
deadline: freshDeadline(),
})).rejects.toThrow(/invalid permit signature/);
});
});
16 changes: 16 additions & 0 deletions src/__tests__/solana-estimateGas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,22 @@ describe('estimateSolanaGas (native)', () => {
expect(out.native).toBe(LAMPORTS_PER_SIGNATURE);
});

it('throws when BOTH simulate and getRecentPrioritizationFees fail', async () => {
// When both dynamic sources are unavailable the only thing left is the
// 5000-lamport signature fee, which would render "≈ $0.001" and be
// misleading on a congested cluster. The estimator should refuse rather
// than silently produce a fake-looking number.
const conn = mockConnection({ simulateError: true, feesError: true });
await expect(estimateSolanaGas({
connection: conn,
sender: SENDER,
programId: PROGRAM_ID,
merchantId: MERCHANT_ID,
token: NATIVE_TOKEN_SENTINEL,
amount: 1n,
})).rejects.toThrow(/Solana fee estimate unavailable/);
});

it('converts lamports to USD when a priceUsd oracle is supplied', async () => {
const conn = mockConnection({ unitsConsumed: 50_000, prioritization: [] });
const out = await estimateSolanaGas(
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ describe('redactErrorMessage', () => {
expect(out.length).toBeLessThanOrEqual(240);
expect(out.endsWith('...')).toBe(true);
});

it('redacts POSIX absolute paths from stack-trace fragments', () => {
const msg = 'TypeError at /Users/alice/src/wallet/index.ts:42';
const out = redactErrorMessage(msg) ?? '';
expect(out).not.toContain('/Users/alice');
expect(out).toContain('<path>');
});

it('redacts Windows absolute paths', () => {
const msg = 'Failed loading C:\\Users\\bob\\AppData\\Local\\app\\index.js';
const out = redactErrorMessage(msg) ?? '';
expect(out).not.toMatch(/C:\\Users\\bob/);
expect(out).toContain('<path>');
});

it('redacts file:// URLs', () => {
const msg = 'thrown at file:///home/vscode/workspace/x/y.ts:10:5';
const out = redactErrorMessage(msg) ?? '';
expect(out).not.toContain('file:///home');
expect(out).toContain('<path>');
});

it('redacts long unbroken hex blobs (private-key shaped)', () => {
// A 64+ char hex run is consistent with a raw private key, raw signature,
// or session token. Strip rather than risk leaking via 3rd-party
// analytics.
const msg = 'leaked secret 0x' + 'ab'.repeat(40); // 80 hex chars, way past the floor
const out = redactErrorMessage(msg) ?? '';
expect(out).not.toMatch(/ab{20,}/i);
expect(out).toContain('<');
});
});

describe('hashWalletAddress', () => {
Expand Down
29 changes: 27 additions & 2 deletions src/core/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,21 @@ export async function hashWalletAddress(address: string | null | undefined): Pro
}

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