diff --git a/CHANGELOG.md b/CHANGELOG.md index dccf11d..780f2f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (`` 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 diff --git a/package-lock.json b/package-lock.json index 745dfc2..61c44b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@web3settle/merchant-sdk", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@web3settle/merchant-sdk", - "version": "0.4.0", + "version": "0.5.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "i18next": "^25.10.10", @@ -11083,13 +11083,13 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -13611,9 +13611,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -14312,9 +14312,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", "engines": { @@ -18418,9 +18418,9 @@ } }, "node_modules/rpc-websockets/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", diff --git a/package.json b/package.json index d61340b..e9bdefe 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": [ @@ -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" diff --git a/src/__tests__/confirmationPolicy.test.ts b/src/__tests__/confirmationPolicy.test.ts new file mode 100644 index 0000000..a5f635e --- /dev/null +++ b/src/__tests__/confirmationPolicy.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/evm-estimateGas.test.ts b/src/__tests__/evm-estimateGas.test.ts new file mode 100644 index 0000000..f49e701 --- /dev/null +++ b/src/__tests__/evm-estimateGas.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { PublicClient } from 'viem'; +import { + estimateEvmGas, + estimateEvmApproveGas, +} from '../evm/estimateGas'; +import { NATIVE_TOKEN_SENTINEL } from '../core/types'; + +/** + * The estimator only needs `estimateGas` and `getGasPrice` from the public + * client. We hand-roll a minimal mock to keep the test fast and isolated from + * any RPC. + */ +function mockPublicClient(opts: { gas: bigint; gasPrice: bigint }) { + const estimateGas = vi.fn().mockResolvedValue(opts.gas); + const getGasPrice = vi.fn().mockResolvedValue(opts.gasPrice); + return { + client: { estimateGas, getGasPrice } as unknown as PublicClient, + estimateGas, + getGasPrice, + }; +} + +const ACCOUNT = '0x1111111111111111111111111111111111111111' as const; +const CONTRACT = '0x2222222222222222222222222222222222222222' as const; +const TOKEN = '0x3333333333333333333333333333333333333333' as const; + +describe('estimateEvmGas', () => { + it('multiplies gas units by gas price for a native pay-in', async () => { + const { client } = mockPublicClient({ gas: 50_000n, gasPrice: 25_000_000_000n }); // 25 gwei + const out = await estimateEvmGas({ + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000_000_000_000_000n, + }); + // 50k gas * 25 gwei = 0.00125 ETH = 1.25e15 wei. Safety multiplier 1.2 → 1.5e15 wei. + // gas units: ceil(50000 * 1.2) = 60000. 60000 * 25e9 = 1.5e15. + expect((out.breakdown as { gasUnits: bigint }).gasUnits).toBe(60_000n); + expect(out.native).toBe(60_000n * 25_000_000_000n); + expect(out.usd).toBeNull(); + }); + + it('estimates a token pay-in with `payInToken(token, amount)` calldata', async () => { + const { client, estimateGas } = mockPublicClient({ gas: 80_000n, gasPrice: 10_000_000_000n }); + const out = await estimateEvmGas({ + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: TOKEN, + amount: 100_000_000n, + }); + expect(out.breakdown).toMatchObject({ family: 'evm', flow: 'token' }); + expect(estimateGas).toHaveBeenCalledOnce(); + const args = estimateGas.mock.calls[0][0] as { value?: bigint; data: string }; + // Native value should be omitted/zero for an ERC-20 pay-in. + expect(args.value === undefined || args.value === 0n).toBe(true); + // payInToken selector = 0x0dff7042 (first 4 bytes of keccak256("payInToken(address,uint256)")) + expect(args.data.slice(0, 10)).toBe('0x0dff7042'); + }); + + it('converts to USD when priceUsd is supplied', async () => { + const { client } = mockPublicClient({ gas: 21_000n, gasPrice: 20_000_000_000n }); // 21k * 20 gwei = 4.2e14 wei = 0.00042 ETH + const out = await estimateEvmGas( + { + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 0n, + }, + { priceUsd: 4000, safetyMultiplier: 1.0 }, + ); + expect(out.native).toBe(21_000n * 20_000_000_000n); + // 0.00042 ETH * $4000 = $1.68 + expect(out.usd).toBeCloseTo(1.68, 2); + }); + + it('uses fetchPriceUsd when priceUsd is omitted', async () => { + const { client } = mockPublicClient({ gas: 21_000n, gasPrice: 20_000_000_000n }); + const fetchPriceUsd = vi.fn().mockResolvedValue(4000); + const out = await estimateEvmGas( + { + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 0n, + }, + { fetchPriceUsd, safetyMultiplier: 1.0 }, + ); + expect(fetchPriceUsd).toHaveBeenCalledOnce(); + expect(out.usd).toBeCloseTo(1.68, 2); + }); + + it('returns usd=null when fetchPriceUsd throws', async () => { + const { client } = mockPublicClient({ gas: 21_000n, gasPrice: 20_000_000_000n }); + const out = await estimateEvmGas( + { + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 0n, + }, + { fetchPriceUsd: () => Promise.reject(new Error('price feed down')) }, + ); + expect(out.usd).toBeNull(); + expect(out.native).toBeGreaterThan(0n); + }); + + it('returns usd=null for a non-positive priceUsd', async () => { + const { client } = mockPublicClient({ gas: 21_000n, gasPrice: 20_000_000_000n }); + const out = await estimateEvmGas( + { + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 0n, + }, + { priceUsd: 0 }, + ); + expect(out.usd).toBeNull(); + }); + + it('respects safetyMultiplier=1.0 (no padding)', async () => { + const { client } = mockPublicClient({ gas: 50_000n, gasPrice: 1_000_000_000n }); + const out = await estimateEvmGas( + { + publicClient: client, + account: ACCOUNT, + contractAddress: CONTRACT, + token: NATIVE_TOKEN_SENTINEL, + amount: 0n, + }, + { safetyMultiplier: 1.0 }, + ); + expect((out.breakdown as { gasUnits: bigint }).gasUnits).toBe(50_000n); + }); +}); + +describe('estimateEvmApproveGas', () => { + it('encodes calldata for ERC-20 approve and returns native + usd', async () => { + const { client, estimateGas } = mockPublicClient({ gas: 46_000n, gasPrice: 30_000_000_000n }); + const out = await estimateEvmApproveGas( + { + publicClient: client, + account: ACCOUNT, + tokenAddress: TOKEN, + spenderAddress: CONTRACT, + amount: 1_000_000n, + }, + { priceUsd: 3500, safetyMultiplier: 1.0 }, + ); + const callArgs = estimateGas.mock.calls[0][0] as { data: string; to: string }; + // approve selector = 0x095ea7b3 + expect(callArgs.data.slice(0, 10)).toBe('0x095ea7b3'); + expect(callArgs.to).toBe(TOKEN); + expect(out.native).toBe(46_000n * 30_000_000_000n); + expect(typeof out.usd).toBe('number'); + }); +}); diff --git a/src/__tests__/solana-estimateGas.test.ts b/src/__tests__/solana-estimateGas.test.ts new file mode 100644 index 0000000..17a438e --- /dev/null +++ b/src/__tests__/solana-estimateGas.test.ts @@ -0,0 +1,180 @@ +// @vitest-environment node +// PDA derivation via @solana/web3.js fails under jsdom because of a +// TextEncoder/Uint8Array identity quirk in seed concatenation — same workaround +// as `solana-pda.test.ts`. The estimator is pure JS plus mocked Connection +// methods, so node is the right runtime. +import { describe, it, expect, vi } from 'vitest'; +import { PublicKey, type Connection } from '@solana/web3.js'; +import { + estimateSolanaGas, + buildSolanaEstimateInstruction, + LAMPORTS_PER_SIGNATURE, +} from '../solana/estimateGas'; +import { NATIVE_TOKEN_SENTINEL } from '../core/types'; + +// Use a real curve-point program id. SystemProgram (`...11112`) has no viable +// PDA nonce for many merchant-id seeds, which is what triggered the original +// "Unable to find a viable program address nonce" failures. We reuse the same +// fixture as `solana-pda.test.ts` because that test already proves these seeds +// derive cleanly. +const PROGRAM_ID = new PublicKey('Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS'); +const SENDER = new PublicKey('Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnT'); +const MINT = new PublicKey('So11111111111111111111111111111111111111112'); +// Non-trivial merchant id: bytes [1,2,3,…,32]. A zeroed (or all-`aa`) id can +// collide with an on-curve point and break PDA derivation. +const MERCHANT_ID = + '0x' + + Array.from({ length: 32 }, (_, i) => (i + 1).toString(16).padStart(2, '0')).join(''); + +interface MockConnectionInit { + unitsConsumed?: number; + prioritization?: { prioritizationFee: number }[]; + simulateError?: boolean; + feesError?: boolean; +} + +function mockConnection(opts: MockConnectionInit) { + const simulateTransaction = vi.fn().mockImplementation(() => { + if (opts.simulateError) return Promise.reject(new Error('simulate failed')); + return Promise.resolve({ + value: { + unitsConsumed: opts.unitsConsumed, + err: null, + logs: [], + }, + }); + }); + const getRecentPrioritizationFees = vi.fn().mockImplementation(() => { + if (opts.feesError) return Promise.reject(new Error('rpc down')); + return Promise.resolve(opts.prioritization ?? []); + }); + const getLatestBlockhash = vi.fn().mockResolvedValue({ + blockhash: '11111111111111111111111111111111', + lastValidBlockHeight: 1, + }); + return { + simulateTransaction, + getRecentPrioritizationFees, + getLatestBlockhash, + } as unknown as Connection; +} + +describe('estimateSolanaGas (native)', () => { + it('returns the static signature fee when no priority fee is recommended', async () => { + const conn = mockConnection({ unitsConsumed: 50_000, prioritization: [] }); + const out = await estimateSolanaGas({ + connection: conn, + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000n, + }); + expect(out.native).toBe(LAMPORTS_PER_SIGNATURE); + expect(out.usd).toBeNull(); + expect(out.breakdown).toMatchObject({ + family: 'solana', + computeUnits: 50_000, + microLamportsPerCu: 0, + }); + }); + + it('adds priority fee = median(microLam/CU) × CU / 1e6', async () => { + const conn = mockConnection({ + unitsConsumed: 200_000, + prioritization: [ + { prioritizationFee: 50 }, + { prioritizationFee: 100 }, + { prioritizationFee: 150 }, + ], + }); + const out = await estimateSolanaGas({ + connection: conn, + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000n, + }); + // Median = 100 µLAM/CU, CU = 200k → priority lamports = ceil(100*200000/1e6) = 20. + // Total = 5000 base + 20 priority = 5020. + expect(out.native).toBe(5020); + expect((out.breakdown as { microLamportsPerCu: number }).microLamportsPerCu).toBe(100); + }); + + it('falls back to a default 200k CU when simulate fails', async () => { + const conn = mockConnection({ simulateError: true }); + const out = await estimateSolanaGas({ + connection: conn, + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + }); + expect((out.breakdown as { computeUnits: number }).computeUnits).toBe(200_000); + }); + + it('still returns the base fee when getRecentPrioritizationFees errors', async () => { + const conn = mockConnection({ unitsConsumed: 30_000, feesError: true }); + const out = await estimateSolanaGas({ + connection: conn, + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + }); + expect(out.native).toBe(LAMPORTS_PER_SIGNATURE); + }); + + it('converts lamports to USD when a priceUsd oracle is supplied', async () => { + const conn = mockConnection({ unitsConsumed: 50_000, prioritization: [] }); + const out = await estimateSolanaGas( + { + connection: conn, + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000n, + }, + { priceUsd: 200 }, + ); + // 5000 lamports = 5e-6 SOL = $0.001 at $200/SOL. + expect(out.usd).toBeCloseTo(0.001, 5); + }); +}); + +describe('buildSolanaEstimateInstruction', () => { + it('builds the native pay-in instruction with the right discriminator', () => { + const ix = buildSolanaEstimateInstruction({ + connection: mockConnection({ unitsConsumed: 1 }), + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: NATIVE_TOKEN_SENTINEL, + amount: 12345n, + }); + expect(ix.programId.toBase58()).toBe(PROGRAM_ID.toBase58()); + // Anchor discriminator for `pay_in_native`. Verified against + // src/solana/instructions.ts. + expect(ix.data[0]).toBe(0xe4); + expect(ix.data[1]).toBe(0xa7); + }); + + it('builds the SPL pay-in instruction with the token discriminator', () => { + const ix = buildSolanaEstimateInstruction({ + connection: mockConnection({ unitsConsumed: 1 }), + sender: SENDER, + programId: PROGRAM_ID, + merchantId: MERCHANT_ID, + token: MINT.toBase58(), + amount: 1n, + tokenMint: MINT, + }); + // pay_in_token discriminator + expect(ix.data[0]).toBe(0xba); + expect(ix.data[1]).toBe(0x77); + }); +}); diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts new file mode 100644 index 0000000..987aaa5 --- /dev/null +++ b/src/__tests__/telemetry.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildTelemetryEvent, + hashWalletAddress, + redactErrorMessage, + safeEmit, + type TelemetryEvent, +} from '../core/telemetry'; + +describe('buildTelemetryEvent', () => { + it('builds an event with the supplied fields and a fresh timestamp', () => { + const before = Date.now(); + const ev = buildTelemetryEvent({ + chain: 'evm', + phase: 'send', + errorCode: 'user-rejected', + walletId: 'injected', + contractVersion: '3.1.0', + walletDigest: 'abcdef0123456789', + rawMessage: 'something broke', + }); + const after = Date.now(); + expect(ev.chain).toBe('evm'); + expect(ev.phase).toBe('send'); + expect(ev.errorCode).toBe('user-rejected'); + expect(ev.walletId).toBe('injected'); + expect(ev.contractVersion).toBe('3.1.0'); + expect(ev.walletDigest).toBe('abcdef0123456789'); + expect(ev.message).toBe('something broke'); + expect(ev.timestamp).toBeGreaterThanOrEqual(before); + expect(ev.timestamp).toBeLessThanOrEqual(after); + }); + + it('omits PII even when rawMessage embeds an EVM address and a tx hash', () => { + const ev = buildTelemetryEvent({ + chain: 'evm', + phase: 'confirm', + errorCode: 'reverted', + rawMessage: + 'tx 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000abcdef00000000 reverted on 0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + expect(ev.message).not.toMatch(/0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48/); + expect(ev.message).toContain(''); + }); +}); + +describe('redactErrorMessage', () => { + it('redacts EVM addresses, tx hashes, and UUIDs', () => { + const msg = 'failure on 0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48 (session 550e8400-e29b-41d4-a716-446655440000)'; + const out = redactErrorMessage(msg); + expect(out).toBe('failure on 0x (session )'); + }); + + it('redacts a Solana base58 pubkey when whitespace-bounded', () => { + const msg = 'rejected by 4Nd1mYbHGd5gKPVtSuPxCMC8gXSyfuwBkXk1JLPv2VEC'; + const out = redactErrorMessage(msg); + expect(out).toMatch(//); + }); + + it('returns undefined for undefined input', () => { + expect(redactErrorMessage(undefined)).toBeUndefined(); + }); + + it('truncates messages over 240 chars to keep payload bounded', () => { + const msg = 'X'.repeat(500); + const out = redactErrorMessage(msg) ?? ''; + expect(out.length).toBeLessThanOrEqual(240); + expect(out.endsWith('...')).toBe(true); + }); +}); + +describe('hashWalletAddress', () => { + it('returns undefined for null/undefined input', async () => { + await expect(hashWalletAddress(null)).resolves.toBeUndefined(); + await expect(hashWalletAddress(undefined)).resolves.toBeUndefined(); + await expect(hashWalletAddress('')).resolves.toBeUndefined(); + }); + + it('returns a deterministic, non-reversible 16-char digest', async () => { + const a = await hashWalletAddress('0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48'); + const b = await hashWalletAddress('0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48'); + expect(a).toBe(b); + expect(a).toMatch(/^[a-f0-9]+$/); + expect(a).not.toContain('0xA0b86991'); + }); + + it('returns different digests for different addresses', async () => { + const a = await hashWalletAddress('0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48'); + const b = await hashWalletAddress('0xB0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48'); + expect(a).not.toBe(b); + }); + + it('hashes case-insensitively (mixed-case addresses agree)', async () => { + const lower = await hashWalletAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + const upper = await hashWalletAddress('0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48'); + expect(lower).toBe(upper); + }); +}); + +describe('safeEmit', () => { + it('invokes the callback with the supplied event', () => { + const cb = vi.fn(); + const ev: TelemetryEvent = buildTelemetryEvent({ + chain: 'tron', + phase: 'approve', + errorCode: 'unknown', + }); + safeEmit(cb, ev); + expect(cb).toHaveBeenCalledWith(ev); + }); + + it('swallows callback throws so they cannot break payment flow', () => { + const cb = vi.fn(() => { + throw new Error('analytics broken'); + }); + const ev = buildTelemetryEvent({ chain: 'evm', phase: 'send', errorCode: 'unknown' }); + expect(() => safeEmit(cb, ev)).not.toThrow(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when the callback is undefined', () => { + const ev = buildTelemetryEvent({ chain: 'solana', phase: 'connect', errorCode: 'unknown' }); + expect(() => safeEmit(undefined, ev)).not.toThrow(); + }); +}); diff --git a/src/__tests__/tron-estimateGas.test.ts b/src/__tests__/tron-estimateGas.test.ts new file mode 100644 index 0000000..0da01c2 --- /dev/null +++ b/src/__tests__/tron-estimateGas.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + estimateTronGas, + computeTronCost, + DEFAULT_SUN_PER_ENERGY, +} from '../tron/estimateGas'; +import { NATIVE_TOKEN_SENTINEL } from '../core/types'; +import type { TronWebLike } from '../tron/tronweb-global'; + +const VALID_T_ADDR = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; +const VALID_USDT_ADDR = 'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf'; + +interface MockTronInit { + energyUsed?: number; + rawDataHex?: string; + triggerError?: string; +} + +function mockTronWeb(opts: MockTronInit): TronWebLike { + const triggerConstantContract = vi.fn().mockImplementation(() => { + if (opts.triggerError) return Promise.reject(new Error(opts.triggerError)); + return Promise.resolve({ + energy_used: opts.energyUsed, + transaction: { raw_data_hex: opts.rawDataHex }, + result: { result: true }, + }); + }); + return { + defaultAddress: { base58: VALID_T_ADDR, hex: '0x41' + '00'.repeat(20) }, + ready: true, + contract: () => ({ at: vi.fn().mockResolvedValue({}) }), + trx: { getConfirmedTransaction: vi.fn() }, + toSun: vi.fn(), + address: { toHex: vi.fn(), fromHex: vi.fn() }, + transactionBuilder: { triggerConstantContract }, + } as unknown as TronWebLike; +} + +afterEach(() => { + delete (globalThis as unknown as { window: unknown }).window; +}); + +describe('estimateTronGas', () => { + it('returns sun cost = energy*sunPerEnergy + bandwidth for native pay-in', async () => { + const tw = mockTronWeb({ + energyUsed: 30_000, + rawDataHex: '00'.repeat(270), // 270 byte placeholder tx + }); + const out = await estimateTronGas( + { + contractAddress: VALID_T_ADDR, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000n, + tronWebOverride: tw, + }, + { sunPerEnergy: 280 } as never, // sunPerEnergy lives on input — passing here is a typecheck noop + ); + // We pass sunPerEnergy via the input, not the fee oracle. Re-run with input: + const out2 = await estimateTronGas({ + contractAddress: VALID_T_ADDR, + token: NATIVE_TOKEN_SENTINEL, + amount: 1_000_000n, + sunPerEnergy: 280, + tronWebOverride: tw, + }); + // 30k energy * 280 sun/energy = 8.4e6 sun, + 270 bandwidth = 8_400_270. + expect(out2.native).toBe(8_400_270); + expect((out2.breakdown as { energy: number; bandwidth: number }).energy).toBe(30_000); + expect((out2.breakdown as { bandwidth: number }).bandwidth).toBe(270); + // First call (without sunPerEnergy override) should use the default rate. + expect(out.native).toBe(30_000 * DEFAULT_SUN_PER_ENERGY + 270); + }); + + it('rejects an invalid (non-T) contract address before hitting tronWeb', async () => { + const tw = mockTronWeb({ energyUsed: 1 }); + await expect(estimateTronGas({ + contractAddress: '0x1234567890abcdef1234567890abcdef12345678', + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + tronWebOverride: tw, + })).rejects.toThrow(/Invalid TRON/); + }); + + it('rejects when tronWeb is not present', async () => { + await expect(estimateTronGas({ + contractAddress: VALID_T_ADDR, + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + })).rejects.toThrow(/not available/); + }); + + it('builds payInToken(address,uint256) for an ERC-20 pay-in', async () => { + const tw = mockTronWeb({ energyUsed: 60_000, rawDataHex: '00'.repeat(280) }); + await estimateTronGas({ + contractAddress: VALID_T_ADDR, + token: VALID_USDT_ADDR, + amount: 1_000_000n, + tronWebOverride: tw, + }); + const builder = (tw as unknown as { transactionBuilder: { triggerConstantContract: ReturnType } }).transactionBuilder; + const args = builder.triggerConstantContract.mock.calls[0]; + expect(args[1]).toBe('payInToken(address,uint256)'); + // Two parameters + expect(args[3]).toEqual([ + { type: 'address', value: VALID_USDT_ADDR }, + { type: 'uint256', value: '1000000' }, + ]); + }); + + it('converts sun to USD when a TRX price is provided', async () => { + const tw = mockTronWeb({ energyUsed: 30_000, rawDataHex: '00'.repeat(270) }); + const out = await estimateTronGas( + { + contractAddress: VALID_T_ADDR, + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + sunPerEnergy: 280, + tronWebOverride: tw, + }, + { priceUsd: 0.13 }, + ); + // 8400270 sun = 8.40027 TRX → 8.40027 * $0.13 = $1.092 + expect(out.usd).toBeCloseTo(1.092, 2); + }); + + it('surfaces a triggerConstantContract error as a thrown Error', async () => { + const tw = mockTronWeb({ triggerError: 'rpc denied' }); + await expect(estimateTronGas({ + contractAddress: VALID_T_ADDR, + token: NATIVE_TOKEN_SENTINEL, + amount: 1n, + tronWebOverride: tw, + })).rejects.toThrow(/rpc denied/); + }); +}); + +describe('computeTronCost', () => { + it('matches the documented formula', () => { + const out = computeTronCost(40_000, 270, 280); + expect(out.energySun).toBe(40_000 * 280); + expect(out.bandwidthSun).toBe(270); + expect(out.sunCost).toBe(40_000 * 280 + 270); + }); + + it('uses DEFAULT_SUN_PER_ENERGY when omitted', () => { + const out = computeTronCost(40_000, 270); + expect(out.energySun).toBe(40_000 * DEFAULT_SUN_PER_ENERGY); + }); +}); diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 23b0c59..5d86ad5 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,25 +1,72 @@ -import { useCallback, useEffect, useId, useRef, useState } from 'react'; -import { useAccount } from 'wagmi'; -import type { TokenConfig, TokenSelection, TopUpModalProps } from '../core/types'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { formatUnits } from 'viem'; +import { usePublicClient } from 'wagmi'; +import type { ChainConfig, TopUpModalProps } from '../core/types'; import { NATIVE_TOKEN_SENTINEL, PaymentStatus } from '../core/types'; import { useWeb3SettleContext } from './Web3SettleProvider'; +import { useWallet } from '../hooks/useWallet'; import { usePayment } from '../hooks/usePayment'; +import { useQuote } from '../hooks/useQuote'; import { useWeb3Settle } from '../hooks/useWeb3Settle'; -import { ChainSelector } from './ChainSelector'; -import { TokenSelector } from './TokenSelector'; -import { TransactionStatus } from './TransactionStatus'; -import { WalletConnect } from './WalletConnect'; - -type ModalStep = 'amount' | 'wallet' | 'token' | 'review' | 'processing' | 'result'; - -const STEP_TITLE: Record string> = { - amount: () => 'Enter Amount', - wallet: () => 'Connect Wallet', - token: () => 'Select Payment', - review: () => 'Review Payment', - processing: () => 'Processing', - result: (status) => (status === PaymentStatus.Success ? 'Complete' : 'Failed'), -}; +import { CHAIN_ICONS } from '../core/config'; +import { getTokenBalance } from '../core/contract'; +import { estimateEvmGas, type GasEstimate } from '../evm/estimateGas'; +import { defaultConfirmationPolicy } from '../core/ConfirmationPolicy'; + +// Wagmi is configured for these EVM chains in Web3SettleProvider. Solana / Tron flow through +// dedicated sub-entrypoints (`@web3settle/merchant-sdk/solana`, `/tron`) — the main modal is +// EVM-only on purpose, so the storefront's non-EVM chains are filtered from the picker. +const SUPPORTED_EVM_CHAIN_IDS = new Set([1, 137, 8453]); + +interface TokenOption { + /** Selection sentinel: token address for ERC20s, the literal "native" for the gas token. */ + value: string; + /** Display symbol (e.g., USDT, ETH). */ + symbol: string; + /** Decimals — used when formatting on-chain balances and quoted amounts. */ + decimals: number; + /** Whether this option is the chain's native asset. */ + isNative: boolean; + /** Optional icon URL. */ + iconUrl?: string; +} + +function buildTokenOptions(chain: ChainConfig): TokenOption[] { + const options: TokenOption[] = []; + if (chain.nativeCurrency) { + options.push({ + value: NATIVE_TOKEN_SENTINEL, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + isNative: true, + }); + } + for (const t of chain.tokens) { + options.push({ + value: t.address, + symbol: t.symbol, + decimals: t.decimals, + isNative: false, + iconUrl: t.iconUrl, + }); + } + return options; +} + +/** + * Picks the most useful default token: stablecoins win over volatile assets so the merchant's + * USD price translates cleanly. Falls back to native if nothing else is configured. + */ +function pickDefaultToken(options: TokenOption[]): TokenOption | null { + if (options.length === 0) return null; + const preferredOrder = ['USDT', 'USDC', 'DAI']; + for (const sym of preferredOrder) { + const hit = options.find((o) => o.symbol.toUpperCase() === sym && !o.isNative); + if (hit) return hit; + } + const firstErc20 = options.find((o) => !o.isNative); + return firstErc20 ?? options[0] ?? null; +} function CloseIcon({ className }: { className?: string }) { return ( @@ -33,14 +80,27 @@ function CloseIcon({ className }: { className?: string }) { ); } -function BackIcon({ className }: { className?: string }) { +function SpinnerIcon({ className }: { className?: string }) { + return ( + + ); +} + +function CheckIcon({ className }: { className?: string }) { return ( + ); +} + +function AlertIcon({ className }: { className?: string }) { + return ( + ); } @@ -48,172 +108,249 @@ function BackIcon({ className }: { className?: string }) { export function Web3SettleTopUpModal({ isOpen, onClose, - amount: initialAmount, + amount: fixedAmount, }: TopUpModalProps) { const { config } = useWeb3SettleContext(); - const { paymentConfig, isLoading: configLoading } = useWeb3Settle(); - const { address, isConnected } = useAccount(); - const { startPayment, status, txHash, error, reset } = usePayment(); + const { paymentConfig, isLoading: configLoading, error: configError, refetch: refetchConfig } = + useWeb3Settle(); + const wallet = useWallet(); + const { startPayment, status, txHash, error: paymentError, reset: resetPayment } = usePayment(); - const backdropRef = useRef(null); const dialogRef = useRef(null); - const amountInputRef = useRef(null); + const backdropRef = useRef(null); const titleId = useId(); const amountInputId = useId(); - const [step, setStep] = useState('amount'); - const [amount, setAmount] = useState(initialAmount ? String(initialAmount) : ''); + // ── State ──────────────────────────────────────────────────────────────── + // `enteredAmount` only matters when the merchant didn't fix `amount` via prop. The effective + // amount used everywhere downstream (quote, payment) is the merge of fixedAmount + entered. + const [enteredAmount, setEnteredAmount] = useState(''); const [selectedChainId, setSelectedChainId] = useState(null); - const [selectedToken, setSelectedToken] = useState(null); - - const selectedChain = - paymentConfig?.chains.find((c) => c.chainId === selectedChainId) ?? null; - const selectedTokenConfig: TokenConfig | null = - selectedToken && selectedToken !== NATIVE_TOKEN_SENTINEL - ? (selectedChain?.tokens.find((t) => t.address === selectedToken) ?? null) - : null; - const isNativePayment = selectedToken === NATIVE_TOKEN_SENTINEL; + const [selectedToken, setSelectedToken] = useState(null); + const [tokenBalance, setTokenBalance] = useState(null); + const [showConnectorList, setShowConnectorList] = useState(false); + // Reset on open so opening the modal twice in a row doesn't keep stale state from the first + // round (esp. payment status — closing on success and re-opening should give a fresh form). useEffect(() => { if (!isOpen) return; - setStep(initialAmount ? 'wallet' : 'amount'); - setAmount(initialAmount ? String(initialAmount) : ''); + setEnteredAmount(''); setSelectedChainId(null); setSelectedToken(null); - reset(); - }, [isOpen, initialAmount, reset]); + setTokenBalance(null); + setShowConnectorList(false); + resetPayment(); + }, [isOpen, resetPayment]); + + // ── Derived ───────────────────────────────────────────────────────────── + const evmChains = useMemo( + () => paymentConfig?.chains.filter((c) => SUPPORTED_EVM_CHAIN_IDS.has(c.chainId)) ?? [], + [paymentConfig], + ); + const selectedChain = evmChains.find((c) => c.chainId === selectedChainId) ?? null; + const tokenOptions = useMemo( + () => (selectedChain ? buildTokenOptions(selectedChain) : []), + [selectedChain], + ); + const selectedTokenOption = tokenOptions.find((t) => t.value === selectedToken) ?? null; + + const effectiveAmount = useMemo(() => { + if (typeof fixedAmount === 'number' && fixedAmount > 0) return fixedAmount; + const parsed = parseFloat(enteredAmount); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }, [fixedAmount, enteredAmount]); + + // Auto-pick the first EVM chain so the user opens the modal already pointed at something. + useEffect(() => { + if (selectedChainId !== null) return; + const first = evmChains[0]; + if (!first) return; + setSelectedChainId(first.chainId); + }, [evmChains, selectedChainId]); + // Auto-pick the most useful token (USDT > USDC > first ERC20 > native) so the user only has + // to override if they want a different one. Re-runs when the chain changes. useEffect(() => { + if (tokenOptions.length === 0) { + setSelectedToken(null); + return; + } + const stillValid = selectedToken && tokenOptions.some((o) => o.value === selectedToken); + if (stillValid) return; + const def = pickDefaultToken(tokenOptions); + setSelectedToken(def?.value ?? null); + }, [tokenOptions, selectedToken]); + + // ── Quote ──────────────────────────────────────────────────────────────── + const quoteToken = selectedToken === NATIVE_TOKEN_SENTINEL ? 'native' : selectedToken; + const { quote, isLoading: quoteLoading, error: quoteError } = useQuote( + selectedChain?.name ?? null, + quoteToken, + effectiveAmount, + { enabled: isOpen && status === PaymentStatus.Idle && Boolean(selectedChain) && Boolean(quoteToken) }, + ); + + // ── Token balance (best-effort; non-blocking) ────────────────────────── + // Reads the wallet's balance for the selected token so the user sees whether they can afford + // the quoted amount before signing. Failures are silent — the on-chain tx will reject if the + // user really is short, and showing "—" is better than blocking the flow on RPC noise. + const publicClient = usePublicClient({ + chainId: selectedChain?.chainId, + }); + + // ── Gas estimate (item 14.1) ──────────────────────────────────────────── + // Best-effort: a failure here just hides the "≈ $X fee" badge — never blocks + // the pay flow. + const [gasEstimate, setGasEstimate] = useState(null); + useEffect(() => { + let cancelled = false; + setGasEstimate(null); + const account = wallet.address; if ( - status === PaymentStatus.Sending || - status === PaymentStatus.Confirming || - status === PaymentStatus.Approving + !publicClient || + !account || + !selectedChain || + !selectedTokenOption || + !quote ) { - setStep('processing'); - } else if (status === PaymentStatus.Success || status === PaymentStatus.Error) { - setStep('result'); + return; } - }, [status]); + const run = async () => { + try { + const amountAtomic = BigInt(quote.amountToken); + const tokenForEstimate = selectedTokenOption.isNative + ? NATIVE_TOKEN_SENTINEL + : (selectedTokenOption.value as `0x${string}`); + const est = await estimateEvmGas( + { + publicClient, + account, + contractAddress: selectedChain.contractAddress as `0x${string}`, + nativeDecimals: selectedChain.nativeCurrency?.decimals ?? 18, + token: tokenForEstimate, + amount: amountAtomic, + }, + // The quote already carries a price for the selected token; if the + // selected token is native we can use quote.priceUsd directly. For + // ERC-20 we don't have native price handy here — leave usd null and + // the modal renders "≈ network fee" without a $ figure. + selectedTokenOption.isNative ? { priceUsd: quote.priceUsd } : {}, + ); + if (!cancelled) setGasEstimate(est); + } catch { + if (!cancelled) setGasEstimate(null); + } + }; + void run(); + return () => { cancelled = true; }; + }, [publicClient, wallet.address, selectedChain, selectedTokenOption, quote]); + useEffect(() => { + let cancelled = false; + setTokenBalance(null); + const account = wallet.address; + if (!account || !publicClient || !selectedChain || !selectedTokenOption) return; + + const loadBalance = async () => { + try { + if (selectedTokenOption.isNative) { + const bal = await publicClient.getBalance({ address: account }); + if (!cancelled) { + setTokenBalance(Number(formatUnits(bal, selectedTokenOption.decimals)).toFixed(4)); + } + } else { + const bal = await getTokenBalance( + publicClient, + selectedTokenOption.value as `0x${string}`, + account, + ); + if (!cancelled) { + setTokenBalance(Number(formatUnits(bal, selectedTokenOption.decimals)).toFixed(4)); + } + } + } catch { + if (!cancelled) setTokenBalance(null); + } + }; + void loadBalance(); + return () => { cancelled = true; }; + }, [wallet.address, publicClient, selectedChain, selectedTokenOption]); + + // ── Payment success/error → consumer callbacks ────────────────────────── const { onSuccess, onError } = config; useEffect(() => { - if (status !== PaymentStatus.Success || !txHash || !onSuccess) return; - onSuccess({ - id: '00000000-0000-0000-0000-000000000000', - amount: Number(amount), - status: 'confirmed', - txHash, - chain: selectedChain?.name, - token: isNativePayment - ? selectedChain?.nativeCurrency?.symbol - : selectedTokenConfig?.symbol, - }); - }, [status, txHash, onSuccess, amount, selectedChain, isNativePayment, selectedTokenConfig]); + if (status === PaymentStatus.Success && txHash && onSuccess) { + onSuccess({ + id: '00000000-0000-0000-0000-000000000000', + amount: effectiveAmount ?? 0, + status: 'confirmed', + txHash, + chain: selectedChain?.name, + token: selectedTokenOption?.symbol, + }); + } + }, [status, txHash, onSuccess, effectiveAmount, selectedChain, selectedTokenOption]); useEffect(() => { - if (status !== PaymentStatus.Error || !error || !onError) return; - onError(new Error(error)); - }, [status, error, onError]); + if (status === PaymentStatus.Error && paymentError && onError) { + onError(new Error(paymentError)); + } + }, [status, paymentError, onError]); + // ── Modal chrome (Esc to close, focus trap-lite) ──────────────────────── useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } + if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKey); - return () => { window.removeEventListener('keydown', handleKey); }; + return () => window.removeEventListener('keydown', handleKey); }, [isOpen, onClose]); useEffect(() => { if (!isOpen) return; - const previouslyFocused = document.activeElement as HTMLElement | null; - if (step === 'amount' && amountInputRef.current) { - amountInputRef.current.focus(); - } else { - dialogRef.current?.focus(); - } - return () => { - previouslyFocused?.focus?.(); - }; - }, [isOpen, step]); + dialogRef.current?.focus(); + }, [isOpen]); const handleBackdropClick = useCallback( (e: React.MouseEvent) => { - if (e.target === backdropRef.current) { - onClose(); - } + if (e.target === backdropRef.current) onClose(); }, [onClose], ); - const handleAmountNext = useCallback(() => { - const parsed = parseFloat(amount); - if (Number.isNaN(parsed) || parsed <= 0) return; - setStep(isConnected ? 'token' : 'wallet'); - }, [amount, isConnected]); + // ── Action: pay ────────────────────────────────────────────────────────── + const isProcessing = + status === PaymentStatus.Connecting || + status === PaymentStatus.Approving || + status === PaymentStatus.Sending || + status === PaymentStatus.Confirming; - const handleWalletConnected = useCallback(() => { - setStep('token'); - }, []); + const canPay = + wallet.isConnected && + Boolean(selectedChain) && + Boolean(selectedToken) && + Boolean(quote) && + !quoteLoading && + !quoteError && + !isProcessing; - const handleChainSelect = useCallback((chainId: number) => { - setSelectedChainId(chainId); - setSelectedToken(null); - }, []); - - const handleTokenSelect = useCallback((tokenAddress: TokenSelection) => { - setSelectedToken(tokenAddress); - }, []); - - const handleReview = useCallback(() => { - if (!selectedChainId || !selectedToken) return; - setStep('review'); - }, [selectedChainId, selectedToken]); - - const handleConfirm = useCallback(() => { - if (!selectedChain || !selectedToken) return; - void startPayment(Number(amount), selectedChain, selectedToken); - }, [amount, selectedChain, selectedToken, startPayment]); - - const handleBack = useCallback(() => { - switch (step) { - case 'wallet': - setStep('amount'); - break; - case 'token': - setStep(isConnected ? 'amount' : 'wallet'); - break; - case 'review': - setStep('token'); - break; - case 'amount': - case 'processing': - case 'result': - break; - } - }, [step, isConnected]); - - const handleResultAction = useCallback(() => { - if (status === PaymentStatus.Error) { - reset(); - setStep('review'); - } else { - onClose(); - } - }, [status, reset, onClose]); + const handlePay = useCallback(() => { + if (!canPay || !selectedChain || !selectedToken || !quote || !effectiveAmount) return; + void startPayment(effectiveAmount, selectedChain, selectedToken, { + atomicAmount: quote.amountToken, + onTelemetry: config.onTelemetry, + contractVersion: config.contractVersion, + }); + }, [canPay, selectedChain, selectedToken, quote, effectiveAmount, startPayment, config.onTelemetry, config.contractVersion]); if (!isOpen) return null; - const parsedAmount = parseFloat(amount); - const isAmountValid = !Number.isNaN(parsedAmount) && parsedAmount > 0; - const showBackButton = step === 'wallet' || step === 'token' || step === 'review'; + // ── Render ─────────────────────────────────────────────────────────────── + const showSuccess = status === PaymentStatus.Success; + const showError = status === PaymentStatus.Error && paymentError; return ( - // Backdrop is a mouse convenience for closing the modal; keyboard users close via the - // close button or the global Escape key handler. role="presentation" signals that the - // backdrop itself is not an interactive widget.
+ {/* ── Header ───────────────────────────────────────────────── */}
-
- {showBackButton && ( - - )} -

- {STEP_TITLE[step](status)} -

-
+

+ {showSuccess ? 'Payment confirmed' : 'Pay with crypto'} +

+ {/* ── Body ─────────────────────────────────────────────────── */}
{configLoading ? ( -
-
-
+ + ) : configError ? ( + + ) : evmChains.length === 0 ? ( + + ) : showSuccess ? ( + + ) : isProcessing ? ( + ) : ( - <> - {step === 'amount' && ( -
-
- -
- - { setAmount(e.target.value); }} - placeholder="0.00" - className=" - w3s-w-full w3s-rounded-xl w3s-border w3s-border-white/10 - w3s-bg-white/5 w3s-py-3 w3s-pl-10 w3s-pr-4 - w3s-text-2xl w3s-font-semibold w3s-text-white - w3s-outline-none - focus:w3s-border-indigo-500 focus:w3s-ring-1 focus:w3s-ring-indigo-500 - w3s-transition-colors - placeholder:w3s-text-slate-600 - " - /> -
-
- -
- )} - - {step === 'wallet' && ( -
- -
- )} + + ${fixedAmount.toFixed(2)} + + USD +
+ ) : ( +
+ + { setEnteredAmount(e.target.value); }} + placeholder="0.00" + className=" + w3s-w-full w3s-rounded-xl w3s-border w3s-border-white/10 + w3s-bg-white/5 w3s-py-3 w3s-pl-9 w3s-pr-14 + w3s-text-2xl w3s-font-semibold w3s-text-white + w3s-outline-none + focus:w3s-border-indigo-500 focus:w3s-ring-1 focus:w3s-ring-indigo-500 + w3s-transition-colors + placeholder:w3s-text-slate-600 + " + /> + +
+ )} +
- {step === 'token' && paymentConfig && ( -
- + + Pay with + +
+ + +
+
- {selectedChain && ( - - )} + {/* Quote panel — the prominent "you'll send X.YYY TOKEN" line so the user knows + exactly what they're about to sign. */} + - -
+ {/* Inline error from a previously failed payment attempt. */} + {showError && ( + )} - {step === 'review' && selectedChain && ( -
-
-
-
- Amount - - ${parsedAmount.toFixed(2)} USD - -
-
- Network - {selectedChain.name} -
-
- Token - - {isNativePayment - ? (selectedChain.nativeCurrency?.symbol ?? 'Native') - : selectedTokenConfig?.symbol} - -
-
- Wallet - - {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '--'} - -
- {paymentConfig && paymentConfig.commissionBps > 0 && ( -
- - Commission ({(paymentConfig.commissionBps / 100).toFixed(2)}%) - - - Included in amount - -
- )} -
-
- + {/* Wallet + CTA. Single decision point: connect, or pay. */} + {!wallet.isConnected ? ( + 1} + onShowList={() => setShowConnectorList(true)} + /> + ) : ( + <> -
- )} - - {step === 'processing' && ( - - )} - - {step === 'result' && ( -
- { wallet.disconnect(); }} /> - -
+ )} - + )} + {/* ── Footer ────────────────────────────────────────────────── */}
- Powered by{' '} - Web3Settle + Powered by Web3Settle
); } + +// ── Sub-components ───────────────────────────────────────────────────────── + +function ChainPicker({ + chains, + selectedChainId, + onSelect, +}: { + chains: ChainConfig[]; + selectedChainId: number | null; + onSelect: (id: number) => void; +}) { + // Native { onSelect(Number(e.target.value)); }} + className=" + w3s-w-full w3s-appearance-none + w3s-rounded-xl w3s-border w3s-border-white/10 w3s-bg-white/5 + w3s-px-4 w3s-py-3 w3s-pr-9 + w3s-text-sm w3s-text-white + focus:w3s-border-indigo-500 focus:w3s-outline-none focus:w3s-ring-1 focus:w3s-ring-indigo-500 + w3s-cursor-pointer + " + > + {chains.map((c) => ( + + ))} + + + {selectedChainId !== null && CHAIN_ICONS[selectedChainId] && ( + // Hidden by default; just retains the icon mapping for a future visual variant. + {`Network icon: ${CHAIN_ICONS[selectedChainId]}`} + )} + + ); +} + +function TokenPicker({ + options, + selectedToken, + onSelect, + balance, +}: { + options: TokenOption[]; + selectedToken: string | null; + onSelect: (value: string) => void; + balance: string | null; +}) { + if (options.length === 0) { + return ( +
+ No tokens +
+ ); + } + return ( +
+ + + {balance !== null && ( +
+ Balance: {balance} +
+ )} +
+ ); +} + +function QuotePanel({ + amountUsd, + tokenOption, + quote, + quoteLoading, + quoteError, + tokenBalance, + gasEstimate, +}: { + amountUsd: number | null; + tokenOption: TokenOption | null; + quote: ReturnType['quote']; + quoteLoading: boolean; + quoteError: string | null; + tokenBalance: string | null; + gasEstimate: GasEstimate | null; +}) { + const insufficientBalance = useMemo(() => { + if (!quote || !tokenBalance) return false; + return Number(tokenBalance) < quote.amountTokenDisplay; + }, [quote, tokenBalance]); + + if (!amountUsd) { + return ( +
+ Enter a USD amount above to see the rate. +
+ ); + } + if (!tokenOption) { + return ( +
+ This network has no payable tokens configured by the merchant. +
+ ); + } + if (quoteError) { + return ( +
+ Rate unavailable: {quoteError} +
+ ); + } + if (quoteLoading && !quote) { + return ( +
+ + Fetching live rate… +
+ ); + } + if (!quote) return null; + + return ( +
+
+ You'll send + + {formatTokenAmount(quote.amountTokenDisplay, quote.tokenDecimals)} {quote.tokenSymbol} + +
+
+ + Rate: 1 {quote.tokenSymbol} = ${formatPriceUsd(quote.priceUsd)} + + {quote.source} +
+ {gasEstimate && ( +
+ Network fee + + {typeof gasEstimate.usd === 'number' + ? `≈ $${gasEstimate.usd < 0.01 ? gasEstimate.usd.toFixed(4) : gasEstimate.usd.toFixed(2)}` + : '—'} + +
+ )} + {insufficientBalance && ( +
+ + + Your wallet has {tokenBalance} {quote.tokenSymbol}; you need {formatTokenAmount(quote.amountTokenDisplay, quote.tokenDecimals)}. + +
+ )} +
+ ); +} + +function ConnectWalletSection({ + connectors, + connect, + isConnecting, + error, + showList, + onShowList, +}: { + connectors: ReturnType['connectors']; + connect: ReturnType['connect']; + isConnecting: boolean; + error: Error | null; + showList: boolean; + onShowList: () => void; +}) { + // One connector → single button. Multiple → list (or click to expand). Either way, the + // visual weight is "one decision: connect", not "step 2 of 5: pick a wallet provider". + const onlyConnector = connectors.length === 1 ? connectors[0] : undefined; + if (!showList && onlyConnector) { + const c = onlyConnector; + return ( +
+ + {error && } +
+ ); + } + + if (!showList) { + return ( + + ); + } + + return ( +
+ + Choose a wallet + + {connectors.map((c) => ( + + ))} + {error && } +
+ ); +} + +function WalletStatusLine({ + address, + onChange, +}: { + address: string | null; + onChange: () => void; +}) { + return ( +
+ + Wallet: {address ?? '—'} + + +
+ ); +} + +function ProcessingState({ + status, + txHash, + explorerUrl, + chainId, +}: { + status: PaymentStatus; + txHash: string | null; + explorerUrl?: string; + chainId?: number; +}) { + const labelByStatus: Record = { + [PaymentStatus.Connecting]: 'Switching network…', + [PaymentStatus.Approving]: 'Approving token spend…', + [PaymentStatus.Sending]: 'Waiting for wallet signature…', + [PaymentStatus.Confirming]: 'Confirming on-chain…', + }; + // Segment 2.2: surface the policy-derived ETA + required-confirmations + // hint so the user knows what they're waiting for. Falls back to the + // generic "10-60 s" copy when no chainId is in scope. + const policy = defaultConfirmationPolicy; + const required = + typeof chainId === 'number' ? policy.requiredConfirmations(chainId) : null; + const etaSec = + typeof chainId === 'number' ? policy.estimatedSecondsToFinality(chainId) : 0; + const family = typeof chainId === 'number' ? policy.family(chainId) : null; + const isSolana = family === 'solana'; + const hint = + required && etaSec > 0 + ? isSolana + ? `Awaiting commitment (~${Math.round(etaSec)} s)` + : `Waiting for ${required} confirmations (~${Math.round(etaSec)} s)` + : 'This usually takes 10–60 seconds.'; + return ( +
+ +
+
{labelByStatus[status] ?? 'Processing…'}
+ {status === PaymentStatus.Confirming && ( +
+ {hint} +
+ )} +
+ {txHash && explorerUrl && ( + + View on explorer ↗ + + )} +
+ ); +} + +function SuccessState({ + txHash, + explorerUrl, + amountTokenDisplay, + tokenSymbol, + chainName, + onClose, +}: { + txHash: string | null; + explorerUrl?: string; + amountTokenDisplay?: number; + tokenSymbol?: string; + chainName?: string; + onClose: () => void; +}) { + return ( +
+
+ +
+
+

Payment confirmed

+ {amountTokenDisplay && tokenSymbol && chainName && ( +

+ {amountTokenDisplay.toFixed(Math.min(8, 6))} {tokenSymbol} sent on {chainName} +

+ )} +
+ {txHash && explorerUrl && ( + + View on explorer ↗ + + )} + +
+ ); +} + +function LoadingState() { + return ( +
+ +
+ ); +} + +function ConfigErrorState({ error, onRetry }: { error: string; onRetry: () => void }) { + return ( +
+
+ +
+
+

Couldn't load payment options

+

{error}

+
+ +
+ ); +} + +function NoChainsState() { + return ( +
+
+ +
+
+

No payment options yet

+

+ The merchant hasn't bound any supported networks to this storefront. Once they deploy + a contract on Ethereum, Polygon, or Base and enable a token, this modal will let you pay. +

+
+
+ ); +} + +function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) { + return ( +
+ +
{message}
+ {onDismiss && ( + + )} +
+ ); +} + +function ChevronIcon({ className }: { className?: string }) { + return ( + + ); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function formatTokenAmount(amount: number, decimals: number): string { + // Show up to 8 fractional digits so very-small-decimals tokens (USDT/USDC at 6 decimals, + // ETH at 18) still read sensibly. Trim trailing zeros for compactness. + const fractionDigits = Math.min(8, Math.max(2, Math.min(decimals, 8))); + return amount + .toFixed(fractionDigits) + .replace(/\.?0+$/, ''); +} + +function formatPriceUsd(price: number): string { + // Sub-cent prices need more digits; supra-dollar prices need fewer. + if (price < 0.01) return price.toFixed(8).replace(/\.?0+$/, ''); + if (price < 1) return price.toFixed(6).replace(/\.?0+$/, ''); + if (price < 100) return price.toFixed(4).replace(/\.?0+$/, ''); + return price.toFixed(2); +} diff --git a/src/components/TransactionStatus.tsx b/src/components/TransactionStatus.tsx index ac7bb5e..8181af8 100644 --- a/src/components/TransactionStatus.tsx +++ b/src/components/TransactionStatus.tsx @@ -1,4 +1,8 @@ import { PaymentStatus, type TransactionStatusProps } from '../core/types'; +import { + defaultConfirmationPolicy, + type ConfirmationPolicy, +} from '../core/ConfirmationPolicy'; const STEP_CONFIG = [ { status: PaymentStatus.Sending, label: 'Sending transaction' }, @@ -72,12 +76,25 @@ function getStepState( return 'pending'; } +/** + * Props extension that lets callers pass a custom {@link ConfirmationPolicy}. + * Kept as an addition (not on `TransactionStatusProps`) so existing + * consumers compile unchanged. Storefronts that want a high-value Solana + * setup can supply `createSolanaConfirmationPolicy('finalized')`. + */ +export interface TransactionStatusExtraProps { + confirmationPolicy?: ConfirmationPolicy; +} + export function TransactionStatus({ status, txHash, explorerUrl, error, -}: TransactionStatusProps) { + chainId, + currentConfirmations, + confirmationPolicy, +}: TransactionStatusProps & TransactionStatusExtraProps) { if (status === PaymentStatus.Error) { return (
{STEP_CONFIG.map((step, index) => { const state = getStepState(status, step.status); + const isConfirmingStep = step.status === PaymentStatus.Confirming; return (
@@ -178,21 +209,31 @@ export function TransactionStatus({ )}
- - {step.label} - {state === 'active' && '...'} - +
+ + {step.label} + {state === 'active' && '...'} + + {isConfirmingStep && state === 'active' && progressLabel && ( + + {progressLabel} + + )} +
); })} diff --git a/src/core/ConfirmationPolicy.ts b/src/core/ConfirmationPolicy.ts new file mode 100644 index 0000000..0b7a42d --- /dev/null +++ b/src/core/ConfirmationPolicy.ts @@ -0,0 +1,280 @@ +/** + * ConfirmationPolicy — Segment 2.2 (cross-chain SDK abstraction). + * + * Storefronts SHOULD NOT branch on `chainId` to decide "is this safe yet" — + * that pattern has historically drifted (SPD §3.2 cites ETH 12, Polygon 30, + * Base 12, TRON 19, Solana 31; storefront code that hard-codes any subset of + * those is one chain-add away from being wrong). + * + * This interface unifies the "depth required for finality" semantics across + * chain families that disagree about what "depth" even means: + * + * - EVM (Ethereum, Polygon, Base): integer block confirmations on top of the + * canonical chain. A receipt is finalized when `blockNumber - txBlock >= + * requiredConfirmations(chainId)`. + * - TRON: block confirmations against the SR-produced chain. SPD calls 19 + * (≈ 60 s @ 3 s blocks). Same arithmetic as EVM. + * - Solana: there is no "block depth" the way EVM has — Solana has + * **commitment levels** (`processed | confirmed | finalized`). The + * "31 confirmations" figure in the SPD is the slot-progression heuristic + * used by the gateway's Solana indexer to deem a tx irreversible without + * waiting the full ~13 s for `finalized`. The policy exposes both + * `requiredConfirmations` (slot delta — for indexer / UI parity with EVM + * thinking) AND `commitmentLevel` (the actual call shape `web3.js` wants). + * + * The policy is purposefully **read-only** — it never touches the network. A + * storefront calls it to drive UI ("X confirmations remaining"), to wire the + * receipt-wait into the EVM pipeline (`waitForReceipt(hash, depth)`), and to + * decide whether to use `'confirmed'` or `'finalized'` for Solana. + * + * Adding a new chain is one entry in `DEFAULT_CONFIRMATION_THRESHOLDS` plus, + * if it's not EVM or TRON, an explicit family registration in + * `chainFamilyForId`. Storefronts MUST NOT need to be touched. + * + * The type is *additive* — existing `ChainConfig.confirmations` stays valid; + * the policy treats a per-chain override on `ChainConfig` as taking + * precedence over the registry default. This keeps existing consumers + * working with no change. + */ + +import type { ChainConfig } from './types'; + +/** + * Solana commitment level — `web3.js` uses this string verbatim. EVM/TRON + * pipelines do not consume it (they receive `null` for these chains). + * + * - `processed`: optimistic, single-validator vote. Reorg-prone. + * - `confirmed`: super-majority cluster vote (~ 2 s). The default trade-off + * for consumer UX. Reorgs are extremely rare but possible. + * - `finalized`: full root-set finalization (~ 13 s). Effectively irreversible. + */ +export type SolanaCommitmentLevel = 'processed' | 'confirmed' | 'finalized'; + +/** + * Identifies which chain family a chainId belongs to. The SDK uses this to + * pick the right pipeline + commitment vocabulary. + * + * Note: for EVM this is the actual EIP-155 chainId (1, 137, 8453, …); for + * TRON it is the conventional gateway-internal numeric ID (so the storefront + * doesn't need to special-case strings); for Solana it is a synthetic + * gateway-internal ID — Solana clusters don't have an EIP-155 chainId. + */ +export type ChainFamily = 'evm' | 'tron' | 'solana'; + +/** + * Default per-chain confirmation thresholds. Mirrors SPD §3.2 / Segment 2 + * (line 94 of `enhancementplan.md`). These are gateway-canonical values — + * a storefront that needs higher values can pass a custom `ConfirmationPolicy` + * to override. + * + * The TRON and Solana chainIds here are gateway-internal sentinel values. + * They match the ones already used elsewhere in the SDK (see + * `src/core/config.ts` and the per-chain pipelines). + */ +export const DEFAULT_CONFIRMATION_THRESHOLDS: Readonly> = Object.freeze({ + // EVM + 1: 12, // Ethereum mainnet + 137: 30, // Polygon mainnet + 8453: 12, // Base mainnet + // TRON — chainId 728126428 is the mainnet "chain id" surfaced by TronGrid. + // The SDK uses the smaller `1001` sentinel internally (per existing + // gateway convention); we map BOTH so storefronts can pass whichever they + // already use. + 728126428: 19, + 1001: 19, + // Solana — slot-delta heuristic. The gateway-internal chainId for Solana + // mainnet-beta is 901 (see `parity-tests/parity-matrix.json`). 900 is + // testnet; 902 is devnet; we treat all the same for the slot heuristic. + 900: 31, + 901: 31, + 902: 31, +}); + +/** + * Registry mapping chainId → family. Used to pick the right vocabulary + * (`requiredConfirmations` for EVM/TRON; `commitmentLevel` for Solana). + * + * Add new chains here. The default policy resolves unknown chainIds to `evm` + * and to a depth of 12, which is conservative for any L1 and L2 we currently + * care about; it errs on the side of "wait longer than necessary". + */ +export const CHAIN_FAMILY_REGISTRY: Readonly> = Object.freeze({ + // EVM + 1: 'evm', + 137: 'evm', + 8453: 'evm', + // TRON + 728126428: 'tron', + 1001: 'tron', + // Solana + 900: 'solana', + 901: 'solana', + 902: 'solana', +}); + +/** + * Estimated seconds-to-finality per chainId, used by the UI to render + * "this usually takes ~30 s" hints. Values are eyeballed off public + * block-time stats — they are NOT load-bearing for correctness. The only + * consumer is presentational (TopUpModal), so an integrator who tweaks + * them or replaces them entirely cannot break payment flow. + */ +export const DEFAULT_SECONDS_TO_FINALITY: Readonly> = Object.freeze({ + 1: 12 * 12, // ETH ~12 s blocks × 12 confirmations + 137: 30 * 2, // Polygon ~2 s blocks × 30 confirmations + 8453: 12 * 2, // Base ~2 s blocks × 12 confirmations + 728126428: 19 * 3, // TRON ~3 s blocks × 19 confirmations + 1001: 19 * 3, + 900: 31 * 0.4, // Solana ~400 ms slot + 901: 31 * 0.4, + 902: 31 * 0.4, +}); + +/** + * Convenience progress descriptor for UI rendering. The pipeline reports the + * current confirmation count (EVM/TRON) or commitment level (Solana); the UI + * computes an `ETA` text from it. + */ +export interface ConfirmationProgress { + family: ChainFamily; + required: number; + /** Best-effort current depth. For Solana, this is the *slot delta* if the + * caller knows it; otherwise 0 = pending, 1 = confirmed, 2 = finalized. */ + current: number; + /** A render-ready label — "8 of 12 confirmations" / "Confirmed (1 of 2)". */ + label: string; +} + +/** + * Public interface — what the storefront depends on. Concrete instances live + * in `src/{evm,solana,tron}/confirmationPolicy.ts`; the default exported by + * this file composes all three so callers who don't know their chain family + * yet still get a working object. + */ +export interface ConfirmationPolicy { + /** Which family the policy thinks `chainId` belongs to. */ + family(chainId: number): ChainFamily; + + /** + * Number of confirmations / slot-deltas the storefront should wait for. + * Used both as the EVM `waitForReceipt(hash, n)` parameter and as the + * "X of N" denominator in UI. + */ + requiredConfirmations(chainId: number): number; + + /** + * Commitment level for Solana. Returns `null` for chains where the concept + * is meaningless (EVM, TRON). Defaulted to `'confirmed'` because that's + * the UX/safety trade-off the existing `SolanaPaymentPipeline` already + * uses — `'finalized'` is appropriate for high-value flows where the + * extra ~10 s wait is acceptable. + */ + commitmentLevel(chainId: number): SolanaCommitmentLevel | null; + + /** + * Best-effort estimate (in seconds) of how long to wait. The UI uses this + * to render the ETA hint under "Confirming on-chain…". Returns 0 when no + * estimate is registered for `chainId`. + */ + estimatedSecondsToFinality(chainId: number): number; + + /** + * Build a render-ready progress descriptor. The pipeline supplies the + * current confirmation count (or 0/1/2 for the three Solana commitment + * levels); this method packages that with the policy's required value + * and produces the label string. + */ + progress(chainId: number, currentConfirmations: number): ConfirmationProgress; + + /** + * Resolve the per-chain depth using a {@link ChainConfig}. If the config + * supplies its own `confirmations`, that wins; otherwise fall back to + * the policy's default for `config.chainId`. This is the path + * `usePayment` / pipelines should use so that legacy + * `ChainConfig.confirmations` overrides keep working. + */ + resolve(config: ChainConfig): number; +} + +/** + * Default implementation. Storefronts can either: + * 1. Use `defaultConfirmationPolicy` directly (most callers — covers all + * five mainnets out of the box). + * 2. Pass a custom impl into the chain-specific pipeline / hook (e.g. to + * flip Solana to `'finalized'` for a high-value flow). + */ +export class DefaultConfirmationPolicy implements ConfirmationPolicy { + /** + * Optional override to upgrade Solana commitment to `'finalized'` for all + * Solana chainIds. Defaults to `'confirmed'` — see comment on + * `commitmentLevel`. + */ + constructor(private readonly options: { solanaCommitment?: SolanaCommitmentLevel } = {}) {} + + family(chainId: number): ChainFamily { + return CHAIN_FAMILY_REGISTRY[chainId] ?? 'evm'; + } + + requiredConfirmations(chainId: number): number { + const v = DEFAULT_CONFIRMATION_THRESHOLDS[chainId]; + if (typeof v === 'number') return v; + // Conservative fallback for unknown chains. 12 matches Ethereum / Base. + return 12; + } + + commitmentLevel(chainId: number): SolanaCommitmentLevel | null { + if (this.family(chainId) !== 'solana') return null; + return this.options.solanaCommitment ?? 'confirmed'; + } + + estimatedSecondsToFinality(chainId: number): number { + return DEFAULT_SECONDS_TO_FINALITY[chainId] ?? 0; + } + + progress(chainId: number, currentConfirmations: number): ConfirmationProgress { + const family = this.family(chainId); + const required = this.requiredConfirmations(chainId); + const current = Math.max(0, Math.min(currentConfirmations, required)); + + let label: string; + if (family === 'solana') { + // For Solana we render commitment names rather than numeric depth — + // the storefront usually knows whether it's at processed/confirmed/ + // finalized but rarely a sensible "slot delta". + const commitment = this.commitmentLevel(chainId) ?? 'confirmed'; + const stateName = + currentConfirmations <= 0 + ? 'Pending' + : currentConfirmations === 1 + ? 'Confirmed' + : 'Finalized'; + label = `${stateName} (target: ${commitment})`; + } else { + label = `${current} of ${required} confirmations`; + } + return { family, required, current, label }; + } + + resolve(config: ChainConfig): number { + if (typeof config.confirmations === 'number' && config.confirmations > 0) { + return config.confirmations; + } + return this.requiredConfirmations(config.chainId); + } +} + +/** + * Stable singleton — most callers want this. Exported as the default so that + * `import { defaultConfirmationPolicy } from '@web3settle/merchant-sdk'` + * works. + */ +export const defaultConfirmationPolicy: ConfirmationPolicy = new DefaultConfirmationPolicy(); + +/** + * Convenience factory — one-liner for storefronts that want `'finalized'` + * across the board on Solana. Equivalent to + * `new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' })`. + */ +export function createHighValueConfirmationPolicy(): ConfirmationPolicy { + return new DefaultConfirmationPolicy({ solanaCommitment: 'finalized' }); +} diff --git a/src/core/api-client.ts b/src/core/api-client.ts index c59f9f1..77a7662 100644 --- a/src/core/api-client.ts +++ b/src/core/api-client.ts @@ -2,10 +2,12 @@ import { PaymentConfigSchema, PaymentSessionSchema, CreateSessionResponseSchema, + QuoteResponseSchema, Web3SettleApiError, type PaymentConfig, type PaymentSession, type CreateSessionResponse, + type QuoteResponse, } from './types'; interface RequestOptions { @@ -64,6 +66,32 @@ export class Web3SettleApiClient { return this.parse(raw, CreateSessionResponseSchema, 'session'); } + /** + * Server-side USD → token quote backed by Chainlink. Use the returned `amountToken` (atomic, + * decimal string) as the `value` / `amount` arg when building the on-chain tx so the user + * signs exactly what they were quoted. + * + * `token` is either `"native"` for the chain's gas token or a 0x-prefixed ERC20 address. The + * server rejects tokens not enabled on the storefront's active contract. + */ + async fetchQuote( + network: string, + token: string, + amountUsd: number, + signal?: AbortSignal, + ): Promise { + const qs = new URLSearchParams({ + network, + token, + amountUsd: amountUsd.toString(), + }); + const raw = await this.request( + `api/storefronts/${this.storefrontId}/quote?${qs.toString()}`, + { signal }, + ); + return this.parse(raw, QuoteResponseSchema, 'quote'); + } + async getSessionStatus(sessionId: string, signal?: AbortSignal): Promise { assertValidSessionId(sessionId); const raw = await this.request( diff --git a/src/core/contract.ts b/src/core/contract.ts index 56af20c..cede5bd 100644 --- a/src/core/contract.ts +++ b/src/core/contract.ts @@ -8,6 +8,29 @@ import { } from 'viem'; import { PAYMENT_CONTRACT_ABI, ERC20_ABI } from './config'; +/** + * Minimal ABI for an EIP-2612 token's `permit(...)` setter. The SDK uses this + * to submit the permit signature on-chain when `payInToken` flows opt into the + * gasless approval path (item 14.6). + */ +export const ERC20_PERMIT_ABI = [ + { + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'v', type: 'uint8' }, + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + const DEFAULT_RECEIPT_TIMEOUT_MS = 120_000; async function requireAccount(walletClient: WalletClient): Promise<`0x${string}`> { @@ -131,3 +154,35 @@ export async function waitForReceipt( export function parseTokenAmount(amount: string | number, decimals: number): bigint { return parseUnits(String(amount), decimals); } + +/** + * Submit an EIP-2612 `permit(owner, spender, value, deadline, v, r, s)` + * transaction. Used by the EVM pay-token flow (item 14.6) when the token + * supports permit, eliminating the standalone `approve()` round-trip. + */ +export async function submitPermit( + walletClient: WalletClient, + tokenAddress: `0x${string}`, + args: { + owner: `0x${string}`; + spender: `0x${string}`; + value: bigint; + deadline: bigint; + v: number; + r: `0x${string}`; + s: `0x${string}`; + }, +): Promise { + const account = await requireAccount(walletClient); + const data = encodeFunctionData({ + abi: ERC20_PERMIT_ABI, + functionName: 'permit', + args: [args.owner, args.spender, args.value, args.deadline, args.v, args.r, args.s], + }); + return walletClient.sendTransaction({ + account, + to: tokenAddress, + data, + chain: walletClient.chain, + }); +} diff --git a/src/core/telemetry.ts b/src/core/telemetry.ts new file mode 100644 index 0000000..7b21080 --- /dev/null +++ b/src/core/telemetry.ts @@ -0,0 +1,187 @@ +/** + * Telemetry breadcrumbs for payment failures. + * + * Closes a real operational gap: when a customer's pay-in fails on EVM, Solana, + * or TRON, the merchant currently has no visibility into _why_. We surface a + * single, opt-in callback the merchant can wire to their own analytics + * (Sentry, PostHog, Datadog, Segment, plain console). The SDK never phones + * home — emission is purely synchronous, and the callback is the merchant's + * problem to make async/durable. + * + * **Privacy contract.** + * Events do **not** carry PII or financial detail: + * - no plain wallet address (only an opaque hash digest); + * - no payment amount or token symbol; + * - no transaction payload or signed message. + * Anything more granular belongs in the merchant's own server-side trail, where + * they already own the user identity. This SDK is on the customer's device — we + * stay strict by default. + */ +import type { PaymentErrorKind } from './pipeline'; + +/** Chain family the telemetry event came from. Mirrors `PaymentFamily`. */ +export type TelemetryChain = 'evm' | 'solana' | 'tron'; + +/** Payment-flow phases at which a failure can surface. */ +export type TelemetryPhase = + | 'connect' + | 'switch-network' + | 'quote' + | 'approve' + | 'permit' + | 'estimate-gas' + | 'send' + | 'confirm'; + +/** + * A single failure breadcrumb. Field names use SDK terminology, not the + * underlying chain SDK's — so a Solana wallet-reject and an EVM user-reject + * both surface the same `errorCode: 'user-rejected'`. + */ +export interface TelemetryEvent { + /** Chain family the failure originated on. */ + chain: TelemetryChain; + /** Phase of the pay-in flow that triggered it. Useful for grouping. */ + phase: TelemetryPhase; + /** Stable error category — the same enum the pipelines classify on. */ + errorCode: PaymentErrorKind; + /** + * Wallet provider identifier reported by the connector (e.g. `"injected"`, + * `"walletConnect"`, `"phantom"`, `"tronlink"`). Free-form string — keep it + * stable enough to bucket on the merchant's analytics dashboard but never + * include the address. + */ + walletId?: string; + /** + * On-chain MerchantPayIn contract version, when known. Allows the merchant + * to spot regressions caused by a contract upgrade. Free-form so we can + * version EVM (`"3.1.0"`), TRON, and Solana program with different schemes. + */ + contractVersion?: string; + /** Unix epoch milliseconds at the moment the breadcrumb is built. */ + timestamp: number; + /** + * Opaque, deterministic digest of the connected address — letting merchants + * group failures by user without ever seeing the actual address. SHA-256 or + * a 16-char hex prefix is fine. Empty when no wallet was connected yet. + */ + walletDigest?: string; + /** + * Free-text developer hint with the underlying error message after PII + * redaction. The SDK truncates to 240 chars and strips anything that looks + * like a 0x address, base58 pubkey, or UUID. Optional; merchants who don't + * want any free text at all can ignore it. + */ + message?: string; +} + +/** + * Optional callback the merchant supplies. Synchronous: the SDK does not await + * this — if the merchant wants to ship to a server they must promise-wrap. + * Throwing or rejecting from this callback must NEVER break the pay-in flow, + * so all internal callers wrap it in `safeEmit()`. + */ +export type TelemetryCallback = (event: TelemetryEvent) => void; + +/** + * Wrap a callback invocation so a buggy merchant analytics handler can never + * propagate into the payment code path. We swallow + warn (once). + */ +let warnedOnce = false; +export function safeEmit( + callback: TelemetryCallback | undefined, + event: TelemetryEvent, +): void { + if (!callback) return; + try { + callback(event); + } catch (err) { + if (!warnedOnce) { + warnedOnce = true; + console.warn( + '[Web3Settle] telemetry callback threw — subsequent throws will be silenced.', + err, + ); + } + } +} + +/** + * Hash a wallet address into a non-reversible 16-char hex digest using + * SubtleCrypto. Fallback to a short FNV-1a-style hash when SubtleCrypto isn't + * available (older Node/JSDOM contexts in tests). Always returns a string. + */ +export async function hashWalletAddress(address: string | null | undefined): Promise { + if (!address) return undefined; + // Best path: SubtleCrypto SHA-256 → first 16 hex chars. + if (typeof crypto !== 'undefined' && typeof crypto.subtle?.digest === 'function') { + try { + const data = new TextEncoder().encode(address.toLowerCase()); + const digest = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(digest); + let hex = ''; + for (let i = 0; i < 8; i += 1) { + hex += (bytes[i] ?? 0).toString(16).padStart(2, '0'); + } + return hex; + } catch { + // fall through + } + } + // Fallback: deterministic but weaker. Only for environments without SubtleCrypto. + let hash = 0x811c9dc5; + const lower = address.toLowerCase(); + for (let i = 0; i < lower.length; i += 1) { + hash ^= lower.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +/** + * Strip values that look like wallet addresses, pubkeys, or UUIDs from a + * developer error message. Keeps the message under 240 chars. + */ +export function redactErrorMessage(message: string | undefined): string | undefined { + if (!message) return undefined; + let safe = message + // EVM 0x-addresses (40 hex chars) + .replace(/0x[a-fA-F0-9]{40}/g, '0x') + // EVM tx hashes (64 hex chars) + .replace(/0x[a-fA-F0-9]{64}/g, '0x') + // 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, '') + // 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'); + if (safe.length > 240) safe = `${safe.slice(0, 237)}...`; + return safe; +} + +/** + * Build a `TelemetryEvent` from a thrown error + payment context. `errorCode` + * uses the same `PaymentErrorKind` enum the pipelines emit, so merchants + * filter on a stable schema. + */ +export interface BuildEventInput { + chain: TelemetryChain; + phase: TelemetryPhase; + errorCode: PaymentErrorKind; + walletId?: string; + contractVersion?: string; + walletDigest?: string; + rawMessage?: string; +} + +export function buildTelemetryEvent(input: BuildEventInput): TelemetryEvent { + return { + chain: input.chain, + phase: input.phase, + errorCode: input.errorCode, + walletId: input.walletId, + contractVersion: input.contractVersion, + timestamp: Date.now(), + walletDigest: input.walletDigest, + message: redactErrorMessage(input.rawMessage), + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index e8d88bb..ba77cd6 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { TelemetryCallback } from './telemetry'; // Accept EVM hex (0x + 40 hex), Solana base58 (32–44 chars), and TRON base58 (T + 33 chars). // Per-pipeline validators in src/solana/ and src/tron/ tighten this at construction time. @@ -39,6 +40,28 @@ export const PaymentConfigSchema = z.object({ storefrontId: z.string().uuid(), }); +// Server-issued USD→token quote returned by GET /api/storefronts/{id}/quote. The SDK uses +// `amountToken` (atomic, as a string for big numbers) verbatim when building the payInToken / +// payInNative call so the user signs exactly what they were quoted. Slippage between quote and +// confirmation is the merchant's concern — they reconcile USD value at webhook time. +export const QuoteResponseSchema = z.object({ + storefrontId: z.string().uuid(), + network: z.string().min(1), + token: z.string().min(1), + tokenSymbol: z.string().min(1), + tokenDecimals: z.number().int().min(0).max(30), + amountUsd: z.number().or(z.string()).transform((v) => Number(v)), + amountToken: z.string().min(1), + amountTokenDisplay: z.number().or(z.string()).transform((v) => Number(v)), + priceUsd: z.number().or(z.string()).transform((v) => Number(v)), + source: z.string().min(1), + feedAddress: z.string().min(1), + roundId: z.number().or(z.string()), + observedAt: z.string(), +}); + +export type QuoteResponse = z.infer; + export const PaymentSessionSchema = z.object({ id: z.string().uuid(), amount: z.number().positive(), @@ -72,6 +95,20 @@ export interface Web3SettleConfig { theme?: 'dark' | 'light'; onSuccess?: (session: PaymentSession) => void; onError?: (error: Error) => void; + /** + * Optional opt-in failure breadcrumb. When the SDK catches a payment + * failure on EVM, Solana, or TRON, it builds a sanitized + * {@link TelemetryEvent} (no addresses except hashed; no amounts) and + * passes it to this callback. Throwing is caught and ignored — telemetry + * never blocks the user-facing flow. See `core/telemetry.ts` for the + * privacy contract. + */ + onTelemetry?: TelemetryCallback; + /** + * Optional contract version string, surfaced in telemetry events so the + * merchant can spot regressions caused by a contract upgrade. + */ + contractVersion?: string; } export enum PaymentStatus { @@ -128,6 +165,20 @@ export interface TransactionStatusProps { txHash?: string; explorerUrl?: string; error?: string; + /** + * Optional Segment 2.2 inputs — when supplied, the component renders an + * "X of N confirmations" label (or commitment-level state for Solana) + * during {@link PaymentStatus.Confirming}. Both must be set for the label + * to render — supplying only one is a no-op. + * + * The component never imports a chain SDK to read `currentConfirmations`; + * it accepts the value as a prop so the caller (which already has the + * `publicClient` / `connection`) drives the polling loop. + */ + chainId?: number; + /** Best-effort current confirmation depth (for EVM/TRON) or commitment + * rank (0 pending, 1 confirmed, 2 finalized) for Solana. */ + currentConfirmations?: number; } export interface WalletConnectProps { diff --git a/src/evm/confirmationPolicy.ts b/src/evm/confirmationPolicy.ts new file mode 100644 index 0000000..beb1789 --- /dev/null +++ b/src/evm/confirmationPolicy.ts @@ -0,0 +1,62 @@ +/** + * EVM-locked variant of {@link ConfirmationPolicy}. Convenience wrapper for + * EVM-only consumers — if you `import { evmConfirmationPolicy } from + * '@web3settle/merchant-sdk'` you do NOT pull in the Solana / TRON families. + * + * The default policy already covers EVM correctly; this wrapper just + * narrows the family check so an EVM-only storefront can fail loudly when + * given a Solana chainId by mistake (which would otherwise resolve to a + * conservative 12-confirmation default and silently work). + */ + +import { + DefaultConfirmationPolicy, + type ConfirmationPolicy, + type ConfirmationProgress, + type SolanaCommitmentLevel, +} from '../core/ConfirmationPolicy'; +import type { ChainConfig } from '../core/types'; + +const SUPPORTED_EVM_CHAIN_IDS = new Set([1, 137, 8453]); + +class EvmConfirmationPolicy implements ConfirmationPolicy { + private readonly inner = new DefaultConfirmationPolicy(); + + family(chainId: number): 'evm' | 'tron' | 'solana' { + return this.inner.family(chainId); + } + + requiredConfirmations(chainId: number): number { + return this.inner.requiredConfirmations(chainId); + } + + commitmentLevel(chainId: number): SolanaCommitmentLevel | null { + // EVM-locked policy never returns a commitment level. + void chainId; + return null; + } + + estimatedSecondsToFinality(chainId: number): number { + return this.inner.estimatedSecondsToFinality(chainId); + } + + progress(chainId: number, current: number): ConfirmationProgress { + return this.inner.progress(chainId, current); + } + + resolve(config: ChainConfig): number { + if (!SUPPORTED_EVM_CHAIN_IDS.has(config.chainId)) { + // Conservative default; logged so integrators notice the mis-wire. + // Console.warn is called once per resolve — acceptable for an SDK + // diagnostic. + console.warn( + `[w3s] EVM ConfirmationPolicy used with non-EVM chainId ${config.chainId}; ` + + `falling back to default depth. Use the chain-family-specific subpath instead.`, + ); + } + return this.inner.resolve(config); + } +} + +/** Singleton — EVM-locked default. */ +export const evmConfirmationPolicy: ConfirmationPolicy = new EvmConfirmationPolicy(); diff --git a/src/evm/estimateGas.ts b/src/evm/estimateGas.ts new file mode 100644 index 0000000..672a3f0 --- /dev/null +++ b/src/evm/estimateGas.ts @@ -0,0 +1,249 @@ +/** + * EVM gas estimator (item 14.1). + * + * Returns a unified `GasEstimate` for the merchant's MerchantPayIn pay-in call + * so the modal can show "≈ $0.27 fee" before the user signs. Closes GAP-17. + * + * Design choices: + * - We accept an optional `priceUsd` or `fetchPriceUsd` so the SDK never + * forces a network hit just to render a fee. Most merchants already have + * a quote in hand from `/quote`; passing the native-token price along is + * trivial. When omitted, the function returns `usd: null`. + * - The native-token estimate uses `eth_estimateGas` + `eth_gasPrice`. + * EIP-1559 networks return a baseFee + tip; the public client folds these + * into `gasPrice` for legacy callers, which is what we want here — a + * conservative ceiling rather than a precise tip recommendation. + * - For an ERC-20 pay-in we estimate `payInToken(token, amount)` directly. + * We do NOT roll the approval tx into the estimate — approvals only fire + * when allowance < amount, and most repeat customers won't see one. + */ +import { + type Address, + type PublicClient, + encodeFunctionData, + formatUnits, +} from 'viem'; +import { PAYMENT_CONTRACT_ABI, ERC20_ABI } from '../core/config'; +import { NATIVE_TOKEN_SENTINEL, type TokenSelection } from '../core/types'; + +/** + * Unified shape returned by all three chain estimators. Native unit is + * chain-specific (wei for EVM, lamports for Solana, sun for TRON), USD is + * common. + */ +export interface GasEstimate { + /** Total native fee, smallest unit (wei / lamports / sun). */ + native: bigint | number; + /** + * Approximate USD equivalent. `null` when the caller did not supply a + * native-token price oracle — the SDK refuses to silently invent one. + */ + usd: number | null; + /** Per-chain breakdown for debugging / advanced UIs. */ + breakdown: EvmGasBreakdown | SolanaGasBreakdown | TronGasBreakdown; +} + +export interface EvmGasBreakdown { + family: 'evm'; + /** Gas units estimated for the call. */ + gasUnits: bigint; + /** Gas price in wei (legacy) or effective wei-per-gas (EIP-1559 fold). */ + gasPriceWei: bigint; + /** Whether the call was simulated against a native or token pay-in. */ + flow: 'native' | 'token'; +} + +// Re-exported by the chain-specific files so tests can typecheck. +export interface SolanaGasBreakdown { + family: 'solana'; + /** Compute units required by the simulated tx. */ + computeUnits: number; + /** Median priority fee in micro-lamports / CU at the moment of estimate. */ + microLamportsPerCu: number; + /** Static SystemProgram tx fee (5000 lamports per signature). */ + baseLamports: number; +} + +export interface TronGasBreakdown { + family: 'tron'; + /** Energy units the call would consume. */ + energy: number; + /** Bandwidth bytes the call would consume. */ + bandwidth: number; + /** sun price per energy unit at estimate time (default 280 sun/energy). */ + sunPerEnergy: number; +} + +/** + * Optional fee oracle. The SDK can either be told the price up front, or be + * given a fetch function the modal calls once. Passing both is fine — + * `priceUsd` wins (zero-network path). + */ +export interface FeeOracleOptions { + /** Price of the chain's native token in USD (e.g. 3500 for ETH). */ + priceUsd?: number; + /** + * Async fetcher invoked when `priceUsd` is omitted. Returns the price the + * SDK should use for USD conversion. Throwing or returning a non-positive + * number causes `usd: null` rather than a crash. + */ + fetchPriceUsd?: (signal?: AbortSignal) => Promise; + /** + * Multiplier applied to the raw estimate as a safety margin. Defaults to + * 1.20. Set to 1.0 for advanced flows where the caller already pads. + */ + safetyMultiplier?: number; + /** Optional abort signal forwarded to `fetchPriceUsd`. */ + signal?: AbortSignal; +} + +export interface EstimateEvmGasInput { + /** Already-configured public client (chain-bound). */ + publicClient: PublicClient; + /** Sender address — the EOA that would submit the pay-in. */ + account: Address; + /** MerchantPayIn contract on the target chain. */ + contractAddress: Address; + /** Native-token decimals — needed for the USD math. Defaults to 18. */ + nativeDecimals?: number; + /** Either `"native"` for `payInNative`, or the ERC-20 contract address. */ + token: TokenSelection; + /** Token amount (smallest unit for ERC-20, wei for native). */ + amount: bigint; +} + +/** + * Estimate gas for a MerchantPayIn pay-in. Returns the unified `GasEstimate`. + * Throws when the public client refuses to estimate (e.g. revert, RPC down) — + * callers should wrap in try/catch and fall back to a "fee unavailable" UI. + */ +export async function estimateEvmGas( + input: EstimateEvmGasInput, + fee: FeeOracleOptions = {}, +): Promise { + const decimals = input.nativeDecimals ?? 18; + const safety = fee.safetyMultiplier ?? 1.2; + + let data: `0x${string}`; + let value = 0n; + let flow: 'native' | 'token'; + + if (input.token === NATIVE_TOKEN_SENTINEL) { + flow = 'native'; + data = encodeFunctionData({ + abi: PAYMENT_CONTRACT_ABI, + functionName: 'payInNative', + }); + value = input.amount; + } else { + flow = 'token'; + data = encodeFunctionData({ + abi: PAYMENT_CONTRACT_ABI, + functionName: 'payInToken', + args: [input.token as Address, input.amount], + }); + } + + // 1. Gas units. We pass the value through so payable functions don't fail + // on a zero-balance check; the public client will simulate either way. + const gasUnitsRaw = await input.publicClient.estimateGas({ + account: input.account, + to: input.contractAddress, + data, + value, + }); + // Apply safety multiplier in integer math: round up so we never undershoot. + const safetyBps = BigInt(Math.round(safety * 10_000)); + const gasUnits = (gasUnitsRaw * safetyBps + 9_999n) / 10_000n; + + // 2. Gas price. Public client returns the network's recommended price — + // on EIP-1559 chains this is base + tip folded. + const gasPriceWei = await input.publicClient.getGasPrice(); + + const totalWei = gasUnits * gasPriceWei; + + const usd = await convertNativeToUsd(totalWei, decimals, fee); + + return { + native: totalWei, + usd, + breakdown: { + family: 'evm', + gasUnits, + gasPriceWei, + flow, + }, + }; +} + +/** + * Estimate gas for an ERC-20 `approve()` separately so callers can show a + * combined fee when an allowance bump is required. This is only invoked from + * the modal when `checkAllowance < amount`. + */ +export interface EstimateApproveGasInput { + publicClient: PublicClient; + account: Address; + tokenAddress: Address; + spenderAddress: Address; + amount: bigint; + nativeDecimals?: number; +} + +export async function estimateEvmApproveGas( + input: EstimateApproveGasInput, + fee: FeeOracleOptions = {}, +): Promise { + const decimals = input.nativeDecimals ?? 18; + const safety = fee.safetyMultiplier ?? 1.2; + const data = encodeFunctionData({ + abi: ERC20_ABI, + functionName: 'approve', + args: [input.spenderAddress, input.amount], + }); + const gasUnitsRaw = await input.publicClient.estimateGas({ + account: input.account, + to: input.tokenAddress, + data, + }); + const safetyBps = BigInt(Math.round(safety * 10_000)); + const gasUnits = (gasUnitsRaw * safetyBps + 9_999n) / 10_000n; + const gasPriceWei = await input.publicClient.getGasPrice(); + const totalWei = gasUnits * gasPriceWei; + const usd = await convertNativeToUsd(totalWei, decimals, fee); + return { + native: totalWei, + usd, + breakdown: { + family: 'evm', + gasUnits, + gasPriceWei, + flow: 'token', + }, + }; +} + +/** + * Convert smallest-unit native to USD using the supplied oracle. Returns null + * when no oracle is available or the fetcher misbehaves. + */ +async function convertNativeToUsd( + totalNative: bigint, + decimals: number, + fee: FeeOracleOptions, +): Promise { + let priceUsd = fee.priceUsd; + if (priceUsd === undefined && fee.fetchPriceUsd) { + try { + priceUsd = await fee.fetchPriceUsd(fee.signal); + } catch { + return null; + } + } + if (typeof priceUsd !== 'number' || !Number.isFinite(priceUsd) || priceUsd <= 0) { + return null; + } + const nativeAmount = Number(formatUnits(totalNative, decimals)); + if (!Number.isFinite(nativeAmount)) return null; + return nativeAmount * priceUsd; +} diff --git a/src/evm/permit.ts b/src/evm/permit.ts new file mode 100644 index 0000000..ab1d11d --- /dev/null +++ b/src/evm/permit.ts @@ -0,0 +1,290 @@ +/** + * EIP-712 typed-data signing for EIP-2612 `permit` (item 14.6). + * + * When an ERC-20 implements `permit` (USDC, DAI, USDT-on-some-chains, most + * tokens since 2022), the user can grant the merchant contract an allowance + * via an off-chain signature instead of a separate `approve()` transaction. + * That saves them ~$0.50 of gas and one wallet popup. + * + * This module: + * 1. **Detects** support by reading the four EIP-2612 view functions + * (`name`, `version`, `nonces`, `DOMAIN_SEPARATOR`). If any throw the + * token is treated as non-permit. + * 2. **Builds** the EIP-712 typed-data payload. + * 3. **Signs** it via `walletClient.signTypedData`. + * 4. **Splits** the signature into `(v, r, s)` so the caller can submit the + * `permit(...)` tx (or pass it to a meta-transaction relayer). + * + * The MerchantPayIn contract itself does not consume permits today — this + * module is a building block for `payInTokenPermit(...)` once the on-chain + * side ships, and useful right now for merchants who want to cut their own + * approval flow before sending tokens to the contract. + */ +import { + type Address, + type PublicClient, + type WalletClient, + type Hex, + hexToBigInt, + hexToNumber, + isHex, + parseSignature, +} from 'viem'; + +/** Minimal EIP-2612 ABI we read for detection. */ +const EIP2612_ABI = [ + { + inputs: [], + name: 'name', + outputs: [{ name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ name: 'owner', type: 'address' }], + name: 'nonces', + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +/** Default DAI/USDC/USDT permit version when the token doesn't expose `version()`. */ +const DEFAULT_PERMIT_VERSION = '1'; + +export interface PermitSupport { + /** Whether the token responds to all four EIP-2612 view calls. */ + supported: boolean; + /** EIP-712 domain `name`. Available when `supported` is true. */ + name?: string; + /** EIP-712 domain `version`. Defaults to "1" when the token omits the view. */ + version?: string; + /** Current `nonces(owner)` for the owner. */ + nonce?: bigint; +} + +/** + * Probe an ERC-20 for EIP-2612 `permit` support. Returns `{ supported: false }` + * when any of the calls revert. Never throws — callers wire this into the + * pay-token flow as `if (await detectPermitSupport(...).supported) ...`. + */ +export async function detectPermitSupport( + publicClient: PublicClient, + tokenAddress: Address, + owner: Address, +): Promise { + try { + const [name, nonce] = await Promise.all([ + publicClient.readContract({ + address: tokenAddress, + abi: EIP2612_ABI, + functionName: 'name', + }), + publicClient.readContract({ + address: tokenAddress, + abi: EIP2612_ABI, + functionName: 'nonces', + args: [owner], + }), + ]); + + // `version()` is optional even within EIP-2612. USDC returns "1"; DAI uses + // "1" too. Fall back when the call reverts. + let version: string = DEFAULT_PERMIT_VERSION; + try { + const v = await publicClient.readContract({ + address: tokenAddress, + abi: EIP2612_ABI, + functionName: 'version', + }); + if (typeof v === 'string' && v.length > 0) version = v; + } catch { + // keep default + } + + // `DOMAIN_SEPARATOR` confirms the token is fully wired for EIP-712. We + // don't need the value (we'll build the domain ourselves) — we only need + // the call to succeed. + await publicClient.readContract({ + address: tokenAddress, + abi: EIP2612_ABI, + functionName: 'DOMAIN_SEPARATOR', + }); + + return { + supported: true, + name, + version, + nonce, + }; + } catch { + return { supported: false }; + } +} + +/** EIP-2612 typed-data parameters bundled for `signPermit`. */ +export interface SignPermitInput { + walletClient: WalletClient; + /** Connected chain id — must match the wallet's active chain. */ + chainId: number; + tokenAddress: Address; + /** EIP-712 domain `name`. Read with `detectPermitSupport`. */ + tokenName: string; + /** EIP-712 domain `version`. Defaults to "1" if undefined. */ + tokenVersion?: string; + owner: Address; + spender: Address; + /** Amount in smallest unit. */ + value: bigint; + /** Current nonce for the owner. Read with `detectPermitSupport`. */ + nonce: bigint; + /** Unix-seconds deadline. Pass e.g. `now + 30*60` for a 30-min window. */ + deadline: bigint; +} + +export interface PermitSignature { + /** Recovery byte (27 or 28). */ + v: number; + /** EIP-712 `r` component. */ + r: Hex; + /** EIP-712 `s` component. */ + s: Hex; + /** Concatenated 65-byte signature (0x… + r + s + v). */ + signature: Hex; + /** Echo of the deadline used in the signature. */ + deadline: bigint; +} + +const PERMIT_TYPES = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], +} as const; + +/** + * Build the EIP-712 typed-data structure for an EIP-2612 permit. Pure + * function, separated from the wallet sign so tests can assert the payload. + */ +export function buildPermitTypedData(input: Omit) { + return { + domain: { + name: input.tokenName, + version: input.tokenVersion ?? DEFAULT_PERMIT_VERSION, + chainId: input.chainId, + verifyingContract: input.tokenAddress, + }, + types: PERMIT_TYPES, + primaryType: 'Permit' as const, + message: { + owner: input.owner, + spender: input.spender, + value: input.value, + nonce: input.nonce, + deadline: input.deadline, + }, + }; +} + +/** + * Validates the deadline isn't already in the past. Returns the same value + * for ergonomics so callers can chain. + */ +export function assertDeadlineFresh(deadline: bigint, nowSeconds = Math.floor(Date.now() / 1000)): bigint { + if (deadline <= BigInt(nowSeconds)) { + throw new Error( + `Permit deadline ${deadline.toString()} is not in the future (now=${nowSeconds}).`, + ); + } + return deadline; +} + +/** + * Ask the wallet to sign the EIP-2612 permit. Splits the resulting 65-byte + * signature into v/r/s the caller can pass to the token's `permit(...)` tx. + */ +export async function signPermit(input: SignPermitInput): Promise { + assertDeadlineFresh(input.deadline); + + const typedData = buildPermitTypedData(input); + + const [account] = await input.walletClient.getAddresses(); + if (!account) throw new Error('No wallet account connected'); + if (account.toLowerCase() !== input.owner.toLowerCase()) { + throw new Error( + `Wallet account ${account} does not match permit owner ${input.owner} — refusing to sign.`, + ); + } + + // viem's signTypedData returns a 0x-prefixed 65-byte hex string. + const signature = await input.walletClient.signTypedData({ + account, + domain: typedData.domain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }); + if (!isHex(signature) || signature.length !== 132) { + throw new Error(`Wallet returned an unexpected signature length: ${String(signature)}`); + } + + // viem ≥2.21 ships `parseSignature` which gives us yParity-compatible v. + // We normalise to {27, 28} so the contract's standard `permit(v, r, s)` works. + const split = parseSignature(signature); + let v = typeof split.v === 'bigint' ? Number(split.v) : (split.yParity === 0 ? 27 : 28); + if (v < 27) v += 27; + + return { + v, + r: split.r, + s: split.s, + signature, + deadline: input.deadline, + }; +} + +/** + * Sanity-check a permit signature without sending it: re-parse the 65 bytes + * and verify v/r/s shapes. Cheap to run before broadcasting and lets the SDK + * surface "invalid signature" earlier than the contract would. + */ +export function validatePermitSignature(sig: Hex): { valid: boolean; reason?: string } { + if (!isHex(sig)) { + return { valid: false, reason: 'Signature is not 0x-prefixed hex' }; + } + if (sig.length !== 132) { + return { valid: false, reason: `Expected 132 hex chars (65 bytes), got ${sig.length}` }; + } + const r: Hex = `0x${sig.slice(2, 66)}`; + const s: Hex = `0x${sig.slice(66, 130)}`; + const vHex: Hex = `0x${sig.slice(130)}`; + if (hexToBigInt(r) === 0n) return { valid: false, reason: 'r is zero' }; + if (hexToBigInt(s) === 0n) return { valid: false, reason: 's is zero' }; + // Reject high-s per EIP-2 to avoid signature malleability. + const SECP256K1_HALF_N = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0n; + if (hexToBigInt(s) > SECP256K1_HALF_N) { + return { valid: false, reason: 's is in the high half of the curve order (EIP-2 malleability)' }; + } + const v = hexToNumber(vHex); + if (v !== 27 && v !== 28 && v !== 0 && v !== 1) { + return { valid: false, reason: `v must be 0/1/27/28, got ${v}` }; + } + return { valid: true }; +} diff --git a/src/headless/index.ts b/src/headless/index.ts new file mode 100644 index 0000000..0d772bf --- /dev/null +++ b/src/headless/index.ts @@ -0,0 +1,29 @@ +/** + * Framework-agnostic "headless" layer (item 14.5). + * + * This module exposes the SDK's logic without any React rendering. The shapes + * are intentionally plain functions returning plain objects so a non-React + * stack — Vue composables, Svelte stores, Preact signals, vanilla JS, the + * Web Component in `src/wc/` — can wire them up however it likes. + * + * The naming starts with `use` to mirror React conventions so React users can + * import either layer interchangeably; nothing in this directory imports React. + */ +export { + createPayButtonController, + type PayButtonState, + type PayButtonController, + type PayButtonControllerOptions, +} from './usePayButton'; +export { + createWalletConnectController, + type WalletConnectState, + type WalletConnectController, + type WalletConnectControllerOptions, +} from './useWalletConnect'; +export { + createGasEstimateController, + type GasEstimateState, + type GasEstimateController, + type GasEstimateControllerOptions, +} from './useGasEstimate'; diff --git a/src/headless/useGasEstimate.ts b/src/headless/useGasEstimate.ts new file mode 100644 index 0000000..8eff980 --- /dev/null +++ b/src/headless/useGasEstimate.ts @@ -0,0 +1,113 @@ +/** + * Headless gas-estimate controller (item 14.5). + * + * Wraps any of the three chain estimators (`evm/estimateGas`, + * `solana/estimateGas`, `tron/estimateGas`) under a single subscription + * surface so non-React UIs can drive "≈ $X fee" badges. + * + * The estimator is injected as a thunk so the controller stays chain-agnostic + * and the SDK doesn't import wagmi/viem from this directory. + */ +import type { GasEstimate } from '../evm/estimateGas'; + +export interface GasEstimateState { + /** Last successful estimate, if any. */ + estimate: GasEstimate | null; + /** Whether a refresh is in flight. */ + loading: boolean; + /** Last error from the estimator. */ + error: string | null; + /** Wall-clock ms when `estimate` was last set. */ + fetchedAt: number | null; +} + +export interface GasEstimateControllerOptions { + /** Function the controller calls to get a fresh estimate. */ + estimate: (signal?: AbortSignal) => Promise; + /** + * Optional auto-refresh interval (ms). Set to `0` or `undefined` to disable. + * Useful for the modal's footer where the fee badge updates every minute. + */ + refreshIntervalMs?: number; +} + +export interface GasEstimateController { + getState(): GasEstimateState; + subscribe(listener: (state: GasEstimateState) => void): () => void; + /** Run an estimate now. Cancels any in-flight call first. */ + refresh(): Promise; + /** Stop any timer and abort any in-flight call. */ + dispose(): void; +} + +const INITIAL_STATE: GasEstimateState = Object.freeze({ + estimate: null, + loading: false, + error: null, + fetchedAt: null, +}); + +export function createGasEstimateController( + opts: GasEstimateControllerOptions, +): GasEstimateController { + let state: GasEstimateState = INITIAL_STATE; + const listeners = new Set<(s: GasEstimateState) => void>(); + let abort: AbortController | null = null; + let timer: ReturnType | null = null; + + const setState = (partial: Partial) => { + state = { ...state, ...partial }; + for (const l of listeners) { + try { l(state); } catch { /* swallow */ } + } + }; + + const refresh = async () => { + abort?.abort(); + const controller = new AbortController(); + abort = controller; + setState({ loading: true, error: null }); + try { + const result = await opts.estimate(controller.signal); + if (controller.signal.aborted) return; + setState({ estimate: result, loading: false, fetchedAt: Date.now() }); + } catch (err) { + if (controller.signal.aborted) return; + setState({ + loading: false, + error: err instanceof Error ? err.message : 'Gas estimate failed', + }); + } + }; + + const scheduleNext = () => { + if (!opts.refreshIntervalMs || opts.refreshIntervalMs <= 0) return; + timer = setTimeout(() => { + void refresh().finally(scheduleNext); + }, opts.refreshIntervalMs); + }; + + // Kick off auto-refresh if configured. Callers always get a chance to + // subscribe first because we use a microtask to fire. + if (opts.refreshIntervalMs && opts.refreshIntervalMs > 0) { + queueMicrotask(() => { + void refresh().finally(scheduleNext); + }); + } + + return { + getState: () => state, + subscribe: (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + refresh, + dispose() { + if (timer) clearTimeout(timer); + timer = null; + abort?.abort(); + abort = null; + listeners.clear(); + }, + }; +} diff --git a/src/headless/usePayButton.ts b/src/headless/usePayButton.ts new file mode 100644 index 0000000..63a432a --- /dev/null +++ b/src/headless/usePayButton.ts @@ -0,0 +1,167 @@ +/** + * Headless pay-button controller (item 14.5). + * + * Wraps the same payment-config discovery + start-payment plumbing the React + * `` uses, but exposes it as a plain controller with a + * `subscribe` API. Consumers pull a state snapshot, listen for changes, and + * call `start()` to fire the flow. + * + * No React imports here. The Web Component (`src/wc/`) and any Vue/Svelte/JS + * caller drive this directly. + */ +import { Web3SettleApiClient } from '../core/api-client'; +import { + PaymentStatus, + type PaymentConfig, +} from '../core/types'; +import { safeEmit, type TelemetryCallback, buildTelemetryEvent, hashWalletAddress } from '../core/telemetry'; + +/** A snapshot of the controller's current state. */ +export interface PayButtonState { + /** Status enum mirroring `usePayment` from the React layer. */ + status: PaymentStatus; + /** Last payment-config fetched from the backend. `null` until ready. */ + paymentConfig: PaymentConfig | null; + /** Loading flag for the initial config fetch. */ + configLoading: boolean; + /** Last error encountered (config fetch or payment start). */ + error: string | null; + /** Last tx hash returned by the chain. `null` until a tx is broadcast. */ + txHash: string | null; +} + +/** Options for {@link createPayButtonController}. */ +export interface PayButtonControllerOptions { + /** Pre-built API client. Either this or `apiBaseUrl` + `storefrontId` is required. */ + apiClient?: Web3SettleApiClient; + apiBaseUrl?: string; + storefrontId?: string; + /** Optional callback for failure breadcrumbs. See `core/telemetry`. */ + onTelemetry?: TelemetryCallback; + /** + * Optional payment runner. When omitted, `start()` only loads config and + * surfaces the snapshot — useful for non-EVM stacks that handle the chain + * call themselves. When provided, it's invoked with the merged context. + */ + runPayment?: (ctx: { amount: number; paymentConfig: PaymentConfig }) => Promise<{ txHash: string }>; +} + +/** Public API of the headless controller. */ +export interface PayButtonController { + /** Read the latest snapshot synchronously. */ + getState(): PayButtonState; + /** Subscribe to state changes; returns an unsubscribe fn. */ + subscribe(listener: (state: PayButtonState) => void): () => void; + /** Trigger the flow: load config → run payment if a runner was provided. */ + start(amount: number): Promise; + /** Reset to idle. */ + reset(): void; + /** Manually fetch the merchant payment-config. */ + loadConfig(): Promise; +} + +const INITIAL_STATE: PayButtonState = Object.freeze({ + status: PaymentStatus.Idle, + paymentConfig: null, + configLoading: false, + error: null, + txHash: null, +}); + +export function createPayButtonController(opts: PayButtonControllerOptions): PayButtonController { + let apiClient: Web3SettleApiClient; + if (opts.apiClient) { + apiClient = opts.apiClient; + } else if (opts.apiBaseUrl && opts.storefrontId) { + apiClient = new Web3SettleApiClient(opts.apiBaseUrl, opts.storefrontId); + } else { + throw new Error('createPayButtonController requires either apiClient or apiBaseUrl+storefrontId'); + } + + let state: PayButtonState = INITIAL_STATE; + const listeners = new Set<(s: PayButtonState) => void>(); + + const setState = (partial: Partial) => { + state = { ...state, ...partial }; + for (const l of listeners) { + try { + l(state); + } catch { + // Ignore subscriber errors — same posture as `safeEmit`. + } + } + }; + + const loadConfig = async () => { + setState({ configLoading: true, error: null }); + try { + const cfg = await apiClient.fetchPaymentConfig(); + setState({ paymentConfig: cfg, configLoading: false }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load config'; + setState({ configLoading: false, error: message }); + safeEmit(opts.onTelemetry, buildTelemetryEvent({ + chain: 'evm', // config fetch is chain-agnostic; default bucket + phase: 'connect', + errorCode: 'unknown', + rawMessage: message, + })); + } + }; + + const start = async (amount: number) => { + setState({ status: PaymentStatus.Connecting, error: null, txHash: null }); + if (!state.paymentConfig) { + await loadConfig(); + } + if (!state.paymentConfig) { + // loadConfig set the error already + setState({ status: PaymentStatus.Error }); + return; + } + if (!opts.runPayment) { + // Headless caller is in charge of running the chain call. Surface the + // loaded config; flag idle so the caller can drive it. + setState({ status: PaymentStatus.Idle }); + return; + } + try { + setState({ status: PaymentStatus.Sending }); + const result = await opts.runPayment({ + amount, + paymentConfig: state.paymentConfig, + }); + setState({ txHash: result.txHash, status: PaymentStatus.Success }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Payment failed'; + setState({ error: message, status: PaymentStatus.Error }); + const digest = await hashWalletAddress(undefined); + safeEmit(opts.onTelemetry, buildTelemetryEvent({ + chain: 'evm', + phase: 'send', + errorCode: message.toLowerCase().includes('reject') ? 'user-rejected' : 'unknown', + rawMessage: message, + walletDigest: digest, + })); + } + }; + + const reset = () => { + setState({ + status: PaymentStatus.Idle, + txHash: null, + error: null, + }); + }; + + return { + getState: () => state, + subscribe: (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + start, + reset, + loadConfig, + }; +} diff --git a/src/headless/useWalletConnect.ts b/src/headless/useWalletConnect.ts new file mode 100644 index 0000000..98bf9ee --- /dev/null +++ b/src/headless/useWalletConnect.ts @@ -0,0 +1,87 @@ +/** + * Headless wallet-connect controller (item 14.5). + * + * Framework-agnostic alternative to the React `useWallet`. Mirrors the + * connect / disconnect / status surface so non-React consumers can pull a + * snapshot, listen for changes, and drive the connect flow. + * + * The actual chain calls are pluggable via {@link WalletConnectControllerOptions.connect} + * — we don't import wagmi/viem/tronweb/web3.js here. This keeps the bundle + * surface tiny: a Web Component that doesn't talk to any wallet at all just + * uses this to render a button stub. + */ + +export interface WalletConnectState { + /** Last connected address (any chain), or null when disconnected. */ + address: string | null; + /** Connection status — strings rather than enum so callers can extend. */ + status: 'idle' | 'connecting' | 'connected' | 'error'; + /** Last error from connect or disconnect, surfaced as a string. */ + error: string | null; +} + +export interface WalletConnectControllerOptions { + /** + * Caller-supplied connect routine. Returns the connected address on + * success. The controller takes care of state transitions. + */ + connect: () => Promise; + /** Caller-supplied disconnect routine. Optional — defaults to a no-op. */ + disconnect?: () => Promise | void; +} + +export interface WalletConnectController { + getState(): WalletConnectState; + subscribe(listener: (state: WalletConnectState) => void): () => void; + connect(): Promise; + disconnect(): Promise; +} + +const INITIAL_STATE: WalletConnectState = Object.freeze({ + address: null, + status: 'idle' as const, + error: null, +}); + +export function createWalletConnectController( + opts: WalletConnectControllerOptions, +): WalletConnectController { + let state: WalletConnectState = INITIAL_STATE; + const listeners = new Set<(s: WalletConnectState) => void>(); + + const setState = (partial: Partial) => { + state = { ...state, ...partial }; + for (const l of listeners) { + try { l(state); } catch { /* swallow */ } + } + }; + + return { + getState: () => state, + subscribe: (l) => { + listeners.add(l); + return () => listeners.delete(l); + }, + async connect() { + setState({ status: 'connecting', error: null }); + try { + const addr = await opts.connect(); + setState({ address: addr, status: 'connected' }); + } catch (err) { + setState({ + status: 'error', + error: err instanceof Error ? err.message : 'Connect failed', + }); + } + }, + async disconnect() { + try { + if (opts.disconnect) await opts.disconnect(); + } catch (err) { + setState({ error: err instanceof Error ? err.message : 'Disconnect failed' }); + return; + } + setState({ address: null, status: 'idle', error: null }); + }, + }; +} diff --git a/src/hooks/usePayment.ts b/src/hooks/usePayment.ts index db1fb10..00cbfbb 100644 --- a/src/hooks/usePayment.ts +++ b/src/hooks/usePayment.ts @@ -7,15 +7,78 @@ import { executePayInToken, approveToken, checkAllowance, + submitPermit, waitForReceipt, } from '../core/contract'; import { usdToNativeAmount, usdToTokenAmount } from '../core/price-feed'; +import { classifyError as classifyErrorKind } from '../core/pipeline'; +import { + buildTelemetryEvent, + hashWalletAddress, + safeEmit, + type TelemetryCallback, + type TelemetryPhase, +} from '../core/telemetry'; +import { detectPermitSupport, signPermit } from '../evm/permit'; +import { + defaultConfirmationPolicy, + type ConfirmationPolicy, +} from '../core/ConfirmationPolicy'; + +interface StartPaymentOptions { + /** + * Pre-fetched atomic token amount (smallest unit, decimal string) from the server-side + * /quote endpoint. When provided, the hook skips its own CoinGecko-based conversion + * and signs exactly this number — the chain-of-trust runs server → SDK → wallet. The legacy + * client-side price-feed path remains as the fallback for non-EVM chains and for any caller + * that hasn't been migrated to the quote endpoint. + */ + atomicAmount?: string; + /** + * Optional opt-in telemetry hook. The hook also reads a callback off the + * `Web3SettleProvider` config; this prop wins for tests and ad-hoc calls. + */ + onTelemetry?: TelemetryCallback; + /** Wallet provider id for telemetry. e.g. "injected", "walletConnect". */ + walletId?: string; + /** Contract version for telemetry. */ + contractVersion?: string; + /** + * EIP-2612 permit policy (item 14.6). + * + * - `"auto"` (default): probe the token; use permit when supported, fall + * back to `approve()` when not. This is the value most merchants want. + * - `"never"`: always use `approve()`. Useful when the merchant has CSP + * rules that block the EIP-712 sign popup. + * - `"require"`: only use permit. Throws if the token doesn't support it. + * Lets advanced merchants enforce the cheaper path. + * + * Permit lets the user sign an off-chain EIP-712 message instead of paying + * gas for an `approve()` tx — saves ~$0.50 of gas + one wallet popup. + */ + permit?: 'auto' | 'never' | 'require'; + /** Permit deadline in unix-seconds. Defaults to `now + 30*60`. */ + permitDeadlineSeconds?: number; + /** + * Confirmation policy (Segment 2.2). When supplied, the hook delegates depth + * resolution (and Solana commitment selection) to the policy instead of + * branching on `chain.chainId`. Defaults to {@link defaultConfirmationPolicy}. + * `chain.confirmations` continues to take precedence when set — the policy + * only fills in the gap when the per-chain override is absent. + */ + confirmationPolicy?: ConfirmationPolicy; +} interface UsePaymentReturn { status: PaymentStatus; txHash: string | null; error: string | null; - startPayment: (amount: number, chain: ChainConfig, token: TokenSelection) => Promise; + startPayment: ( + amount: number, + chain: ChainConfig, + token: TokenSelection, + opts?: StartPaymentOptions, + ) => Promise; reset: () => void; } @@ -48,7 +111,12 @@ export function usePayment(): UsePaymentReturn { }, []); const startPayment = useCallback( - async (amount: number, chain: ChainConfig, token: TokenSelection): Promise => { + async ( + amount: number, + chain: ChainConfig, + token: TokenSelection, + opts: StartPaymentOptions = {}, + ): Promise => { if (!walletClient) { setError('Wallet not connected'); setStatus(PaymentStatus.Error); @@ -66,7 +134,27 @@ export function usePayment(): UsePaymentReturn { setTxHash(null); setError(null); + // Pre-build the telemetry context once so all catch sites share state. + let phase: TelemetryPhase = 'connect'; + const emit = async (rawErr: unknown) => { + const callback = opts.onTelemetry; + if (!callback) return; + const errMsg = rawErr instanceof Error ? rawErr.message : String(rawErr); + const [signer] = await walletClient.getAddresses().catch(() => [undefined]); + const digest = await hashWalletAddress(signer); + safeEmit(callback, buildTelemetryEvent({ + chain: 'evm', + phase, + errorCode: classifyErrorKind(rawErr), + walletId: opts.walletId, + contractVersion: opts.contractVersion, + walletDigest: digest, + rawMessage: errMsg, + })); + }; + try { + phase = 'switch-network'; const currentChainId = await walletClient.getChainId(); if (currentChainId !== chain.chainId) { await switchChainAsync({ chainId: chain.chainId }); @@ -76,15 +164,31 @@ export function usePayment(): UsePaymentReturn { if (token === NATIVE_TOKEN_SENTINEL) { const nativeDecimals = chain.nativeCurrency?.decimals ?? 18; - const nativeAmount = await usdToNativeAmount(amount, chain.chainId, controller.signal); - const weiAmount = parseUnits(nativeAmount.toFixed(18), nativeDecimals); + let weiAmount: bigint; + if (opts.atomicAmount) { + // Server quote: trust the atomic amount verbatim. + weiAmount = BigInt(opts.atomicAmount); + } else { + // Legacy CoinGecko path — kept for tests and pre-quote callers. + phase = 'quote'; + const nativeAmount = await usdToNativeAmount(amount, chain.chainId, controller.signal); + weiAmount = parseUnits(nativeAmount.toFixed(18), nativeDecimals); + } + phase = 'send'; setStatus(PaymentStatus.Sending); const hash = await executePayInNative(walletClient, contractAddress, weiAmount); setTxHash(hash); + phase = 'confirm'; setStatus(PaymentStatus.Confirming); - const receipt = await waitForReceipt(publicClient, hash, chain.confirmations); + // Segment 2.2: depth comes from the policy (which honours + // `chain.confirmations` when set, falls back to the SPD-canonical + // table otherwise). Storefronts no longer need to branch on + // chainId. + const policy = opts.confirmationPolicy ?? defaultConfirmationPolicy; + const depth = policy.resolve(chain); + const receipt = await waitForReceipt(publicClient, hash, depth); if (receipt.status === 'reverted') { throw new Error('Transaction reverted on-chain'); } @@ -98,11 +202,16 @@ export function usePayment(): UsePaymentReturn { throw new Error(`Token ${token} not found in chain configuration`); } - const tokenAmount = usdToTokenAmount(amount, tokenConfig.symbol); - const rawAmount = parseUnits( - tokenAmount.toFixed(tokenConfig.decimals), - tokenConfig.decimals, - ); + let rawAmount: bigint; + if (opts.atomicAmount) { + rawAmount = BigInt(opts.atomicAmount); + } else { + const tokenAmount = usdToTokenAmount(amount, tokenConfig.symbol); + rawAmount = parseUnits( + tokenAmount.toFixed(tokenConfig.decimals), + tokenConfig.decimals, + ); + } const [ownerAddress] = await walletClient.getAddresses(); if (!ownerAddress) throw new Error('No wallet account connected'); @@ -115,16 +224,64 @@ export function usePayment(): UsePaymentReturn { ); if (currentAllowance < rawAmount) { - setStatus(PaymentStatus.Approving); - const approveHash = await approveToken( - walletClient, - tokenAddress, - contractAddress, - rawAmount, - ); - await waitForReceipt(publicClient, approveHash); + // EIP-2612 permit path (item 14.6). Strategy: + // 1. detect support (cheap — three view calls); + // 2. sign EIP-712 typed data; + // 3. submit `permit(...)` directly to the token (still on-chain, + // but allowed to be a meta-tx in future). The user sees one + // popup for sign + one for the pay-in instead of two full + // transactions for approve + pay-in. + // Falls back gracefully when `permit !== "require"`. + const permitMode = opts.permit ?? 'auto'; + let permitHandled = false; + if (permitMode !== 'never') { + phase = 'permit'; + const support = await detectPermitSupport(publicClient, tokenAddress, ownerAddress); + if (support.supported && support.name && support.nonce !== undefined) { + const deadline = BigInt( + opts.permitDeadlineSeconds ?? Math.floor(Date.now() / 1000) + 30 * 60, + ); + const sig = await signPermit({ + walletClient, + chainId: chain.chainId, + tokenAddress, + tokenName: support.name, + tokenVersion: support.version, + owner: ownerAddress, + spender: contractAddress, + value: rawAmount, + nonce: support.nonce, + deadline, + }); + const permitHash = await submitPermit(walletClient, tokenAddress, { + owner: ownerAddress, + spender: contractAddress, + value: rawAmount, + deadline, + v: sig.v, + r: sig.r, + s: sig.s, + }); + await waitForReceipt(publicClient, permitHash); + permitHandled = true; + } else if (permitMode === 'require') { + throw new Error('Token does not support EIP-2612 permit'); + } + } + if (!permitHandled) { + phase = 'approve'; + setStatus(PaymentStatus.Approving); + const approveHash = await approveToken( + walletClient, + tokenAddress, + contractAddress, + rawAmount, + ); + await waitForReceipt(publicClient, approveHash); + } } + phase = 'send'; setStatus(PaymentStatus.Sending); const hash = await executePayInToken( walletClient, @@ -134,8 +291,12 @@ export function usePayment(): UsePaymentReturn { ); setTxHash(hash); + phase = 'confirm'; setStatus(PaymentStatus.Confirming); - const receipt = await waitForReceipt(publicClient, hash, chain.confirmations); + // Segment 2.2: same policy resolution as the native branch. + const policy = opts.confirmationPolicy ?? defaultConfirmationPolicy; + const depth = policy.resolve(chain); + const receipt = await waitForReceipt(publicClient, hash, depth); if (receipt.status === 'reverted') { throw new Error('Transaction reverted on-chain'); } @@ -145,6 +306,10 @@ export function usePayment(): UsePaymentReturn { setStatus(PaymentStatus.Idle); return; } + // Telemetry breadcrumb (item 14.2). Awaited so the digest hash lands + // before we surface the error to the user — the callback itself is + // sync, the await here is for the sha-256. + await emit(err); setError(classifyError(err)); setStatus(PaymentStatus.Error); } diff --git a/src/hooks/useQuote.ts b/src/hooks/useQuote.ts new file mode 100644 index 0000000..b8bd3b5 --- /dev/null +++ b/src/hooks/useQuote.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import { useWeb3SettleContext } from '../components/Web3SettleProvider'; +import { Web3SettleApiClient } from '../core/api-client'; +import type { QuoteResponse } from '../core/types'; + +interface UseQuoteOptions { + /** Auto-refresh interval. Set to 0 to disable polling — useful for tests. */ + refreshIntervalMs?: number; + /** Skip fetching while any of network/token/amount is unset. */ + enabled?: boolean; +} + +interface UseQuoteResult { + quote: QuoteResponse | null; + isLoading: boolean; + error: string | null; + refresh: () => void; +} + +/** + * Server-issued USD → token quote with auto-refresh. Backed by + * GET /api/storefronts/{id}/quote, which reads Chainlink via our gateway RPCs. + * + * Auto-refresh keeps the user looking at a fresh number while they hover on the review step; + * the *signed* amount is whatever the most recent quote said at the moment they click Pay. + * Slippage between that point and tx confirmation is the merchant's concern (they reconcile + * USD value when our webhook hits their backend), so the SDK doesn't try to lock USD client- + * side or re-quote at confirmation. + */ +export function useQuote( + network: string | null, + token: string | null, + amountUsd: number | null, + options: UseQuoteOptions = {}, +): UseQuoteResult { + const { refreshIntervalMs = 30_000, enabled = true } = options; + const { config } = useWeb3SettleContext(); + + const [quote, setQuote] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + // Bumping `tick` via setTick() is how both auto-refresh ticks AND manual refresh() trigger a + // fresh fetch — including it in the effect deps gives us a single fetch path. + const [tick, setTick] = useState(0); + + const ready = + enabled && Boolean(network) && Boolean(token) && typeof amountUsd === 'number' && amountUsd > 0; + + const refresh = () => setTick((t) => t + 1); + + useEffect(() => { + if (!ready || network === null || token === null || amountUsd === null) { + setQuote(null); + setError(null); + return; + } + + const controller = new AbortController(); + const client = new Web3SettleApiClient(config.apiBaseUrl, config.storefrontId); + setIsLoading(true); + setError(null); + + client + .fetchQuote(network, token, amountUsd, controller.signal) + .then((q) => { + if (!controller.signal.aborted) setQuote(q); + }) + .catch((err: unknown) => { + if (err instanceof DOMException && err.name === 'AbortError') return; + setError(err instanceof Error ? err.message : 'Quote unavailable'); + setQuote(null); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => { + controller.abort(); + }; + }, [ready, network, token, amountUsd, config.apiBaseUrl, config.storefrontId, tick]); + + // Auto-refresh: schedule a tick bump on the requested cadence. Decoupled from the fetch + // effect so re-fetches caused by input changes don't reset the auto-refresh timer. + useEffect(() => { + if (!ready || refreshIntervalMs <= 0) return; + const id = window.setInterval(() => setTick((t) => t + 1), refreshIntervalMs); + return () => window.clearInterval(id); + }, [ready, refreshIntervalMs]); + + return { quote, isLoading, error, refresh }; +} diff --git a/src/index.ts b/src/index.ts index f3694c8..3383c06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,68 @@ export { useWeb3Settle } from './hooks/useWeb3Settle'; export { usePayment } from './hooks/usePayment'; export { useWallet } from './hooks/useWallet'; +// ── EVM utilities ──────────────────────────────────────────────────────────── +export { + estimateEvmGas, + estimateEvmApproveGas, +} from './evm/estimateGas'; +export type { + GasEstimate, + EvmGasBreakdown, + SolanaGasBreakdown, + TronGasBreakdown, + FeeOracleOptions, + EstimateEvmGasInput, + EstimateApproveGasInput, +} from './evm/estimateGas'; +export { + detectPermitSupport, + signPermit, + buildPermitTypedData, + validatePermitSignature, + assertDeadlineFresh, +} from './evm/permit'; +export type { + PermitSupport, + SignPermitInput, + PermitSignature, +} from './evm/permit'; + +// ── Telemetry ──────────────────────────────────────────────────────────────── +export { + buildTelemetryEvent, + hashWalletAddress, + redactErrorMessage, + safeEmit, +} from './core/telemetry'; +export type { + TelemetryEvent, + TelemetryCallback, + TelemetryChain, + TelemetryPhase, + BuildEventInput, +} from './core/telemetry'; + +// ── Confirmation policy (Segment 2.2) ─────────────────────────────────────── +// Cross-chain abstraction over per-chain confirmation/finality. Storefronts +// should consume `defaultConfirmationPolicy` instead of branching on +// `chainId` to decide "is this safe yet". See `core/ConfirmationPolicy.ts`. +export { + DefaultConfirmationPolicy, + defaultConfirmationPolicy, + createHighValueConfirmationPolicy, + DEFAULT_CONFIRMATION_THRESHOLDS, + CHAIN_FAMILY_REGISTRY, + DEFAULT_SECONDS_TO_FINALITY, +} from './core/ConfirmationPolicy'; +export type { + ConfirmationPolicy, + ConfirmationProgress, + ChainFamily, + SolanaCommitmentLevel, +} from './core/ConfirmationPolicy'; +export { evmConfirmationPolicy } from './evm/confirmationPolicy'; + // ── Core ───────────────────────────────────────────────────────────────────── export { Web3SettleApiClient } from './core/api-client'; export { diff --git a/src/solana/SolanaTopUpModal.tsx b/src/solana/SolanaTopUpModal.tsx index 3941f54..9aa6673 100644 --- a/src/solana/SolanaTopUpModal.tsx +++ b/src/solana/SolanaTopUpModal.tsx @@ -331,6 +331,7 @@ export function SolanaTopUpModal({ isOpen, onClose, amount: initialAmount }: Top status={status} txHash={txHash ?? undefined} explorerUrl={selectedChain?.explorerUrl} + chainId={selectedChain?.chainId} /> )} @@ -340,6 +341,7 @@ export function SolanaTopUpModal({ isOpen, onClose, amount: initialAmount }: Top status={status} txHash={txHash ?? undefined} explorerUrl={selectedChain?.explorerUrl} + chainId={selectedChain?.chainId} error={error ?? undefined} /> +`; + +/** Public class. Registered on construction via {@link registerWebComponents}. */ +export class Web3SettlePayButtonElement extends HTMLElement { + static get observedAttributes(): string[] { + return ['amount', 'storefront-id', 'api-base-url', 'label', 'disabled']; + } + + private controller: PayButtonController | null = null; + private unsubscribe: (() => void) | null = null; + private buttonEl: HTMLButtonElement | null = null; + + connectedCallback(): void { + const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); + root.innerHTML = TEMPLATE; + this.buttonEl = root.querySelector('button'); + this.buttonEl?.addEventListener('click', this.handleClick); + this.render(); + } + + disconnectedCallback(): void { + this.buttonEl?.removeEventListener('click', this.handleClick); + this.unsubscribe?.(); + this.unsubscribe = null; + this.controller = null; + } + + attributeChangedCallback(): void { + // Tear down + rebuild the controller when configuration changes. Cheap. + this.unsubscribe?.(); + this.controller = null; + this.render(); + } + + private getController(): PayButtonController | null { + if (this.controller) return this.controller; + const apiBaseUrl = this.getAttribute('api-base-url'); + const storefrontId = this.getAttribute('storefront-id'); + if (!apiBaseUrl || !storefrontId) return null; + try { + this.controller = createPayButtonController({ apiBaseUrl, storefrontId }); + this.unsubscribe = this.controller.subscribe(this.handleStateChange); + return this.controller; + } catch { + return null; + } + } + + private render(): void { + if (!this.buttonEl) return; + const label = this.getAttribute('label'); + const amount = this.getAttribute('amount'); + const fallback = amount ? `Pay $${Number(amount).toFixed(2)}` : 'Pay'; + // If consumer passed children, the will render them — only update + // the text fallback when no slotted content exists. + if (this.childNodes.length === 0) { + this.buttonEl.textContent = label ?? fallback; + } + this.buttonEl.disabled = this.hasAttribute('disabled'); + } + + private handleClick = (): void => { + const controller = this.getController(); + if (!controller) { + this.dispatchEvent(new CustomEvent('payment-error', { + detail: { amount: 0, message: 'Missing storefront-id or api-base-url attribute' }, + })); + return; + } + const amount = Number(this.getAttribute('amount') ?? '0'); + if (!Number.isFinite(amount) || amount <= 0) { + this.dispatchEvent(new CustomEvent('payment-error', { + detail: { amount, message: 'Invalid or missing amount attribute' }, + })); + return; + } + this.dispatchEvent(new CustomEvent('payment-started', { detail: { amount } })); + void controller.start(amount); + }; + + private handleStateChange = (state: PayButtonState): void => { + const amount = Number(this.getAttribute('amount') ?? '0'); + if (state.txHash && state.status === PaymentStatus.Success) { + this.dispatchEvent(new CustomEvent('payment-success', { + detail: { amount, txHash: state.txHash }, + })); + return; + } + if (state.error && state.status === PaymentStatus.Error) { + this.dispatchEvent(new CustomEvent('payment-error', { + detail: { amount, message: state.error }, + })); + } + }; +} + +/** + * Register every web component the SDK exposes. Idempotent — safe to call + * from multiple bundles. Importing `'@web3settle/merchant-sdk/wc'` calls this + * automatically as a side effect. + */ +export function registerWebComponents(): void { + if (typeof customElements === 'undefined') return; + if (!customElements.get('web3settle-pay-button')) { + customElements.define('web3settle-pay-button', Web3SettlePayButtonElement); + } +} + +// Side-effect register on import (kept gated behind the runtime check so the +// module is safe to import in Node/SSR environments). +registerWebComponents(); diff --git a/vite.config.ts b/vite.config.ts index 7c70b42..2b78923 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,6 +51,8 @@ export default defineConfig({ index: resolve(__dirname, 'src/index.ts'), solana: resolve(__dirname, 'src/solana/index.ts'), tron: resolve(__dirname, 'src/tron/index.ts'), + headless: resolve(__dirname, 'src/headless/index.ts'), + wc: resolve(__dirname, 'src/wc/index.ts'), styles: resolve(__dirname, 'src/styles.ts'), }, formats: ['es', 'cjs'],