From 74c1bfa863319eb192ed0b55422114c960e5a7a0 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Mon, 4 May 2026 15:45:17 +0000 Subject: [PATCH 01/10] WIP: wire USD->token quote flow into TopUpModal + add useQuote hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-flight integration of the merchant-api /api/storefronts/{id}/quote endpoint into the SDK payment modal. Currently not displaying rates or token selection correctly end-to-end — committing the work-in- progress state so it can be iterated on. --- src/components/TopUpModal.tsx | 50 +++++++++++++++++-- src/core/api-client.ts | 28 +++++++++++ src/core/types.ts | 22 +++++++++ src/hooks/usePayment.ts | 51 ++++++++++++++++---- src/hooks/useQuote.ts | 91 +++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useQuote.ts diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 23b0c59..7ae512f 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -4,6 +4,7 @@ import type { TokenConfig, TokenSelection, TopUpModalProps } from '../core/types import { NATIVE_TOKEN_SENTINEL, PaymentStatus } from '../core/types'; import { useWeb3SettleContext } from './Web3SettleProvider'; import { usePayment } from '../hooks/usePayment'; +import { useQuote } from '../hooks/useQuote'; import { useWeb3Settle } from '../hooks/useWeb3Settle'; import { ChainSelector } from './ChainSelector'; import { TokenSelector } from './TokenSelector'; @@ -74,6 +75,22 @@ export function Web3SettleTopUpModal({ : null; const isNativePayment = selectedToken === NATIVE_TOKEN_SENTINEL; + // Fetch the server-issued Chainlink quote whenever we have a chain + token + amount. Keeps + // refreshing every 30s while the user lingers on review so the displayed amount is as fresh + // as the most recent feed update. + const parsedAmountForQuote = parseFloat(amount); + const quoteToken = isNativePayment ? 'native' : (selectedToken ?? null); + const { + quote, + isLoading: quoteLoading, + error: quoteError, + } = useQuote( + selectedChain?.name ?? null, + quoteToken, + Number.isFinite(parsedAmountForQuote) && parsedAmountForQuote > 0 ? parsedAmountForQuote : null, + { enabled: step === 'review' || step === 'token' }, + ); + useEffect(() => { if (!isOpen) return; setStep(initialAmount ? 'wallet' : 'amount'); @@ -174,8 +191,12 @@ export function Web3SettleTopUpModal({ const handleConfirm = useCallback(() => { if (!selectedChain || !selectedToken) return; - void startPayment(Number(amount), selectedChain, selectedToken); - }, [amount, selectedChain, selectedToken, startPayment]); + // Pass the server's atomic quote through so the wallet signs exactly what the user saw, + // rather than re-running a CoinGecko-based conversion that could disagree with the quote. + void startPayment(Number(amount), selectedChain, selectedToken, { + atomicAmount: quote?.amountToken, + }); + }, [amount, selectedChain, selectedToken, startPayment, quote]); const handleBack = useCallback(() => { switch (step) { @@ -397,6 +418,27 @@ export function Web3SettleTopUpModal({ : selectedTokenConfig?.symbol} +
+ You'll send + + {quote ? ( + <> + + {quote.amountTokenDisplay.toFixed(Math.min(8, quote.tokenDecimals))} {quote.tokenSymbol} + + + @ ${quote.priceUsd.toFixed(6)} / {quote.tokenSymbol} · Chainlink + + + ) : quoteLoading ? ( + Pricing… + ) : quoteError ? ( + {quoteError} + ) : ( + + )} + +
Wallet @@ -419,14 +461,16 @@ export function Web3SettleTopUpModal({
)} 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/types.ts b/src/core/types.ts index e8d88bb..f207e69 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -39,6 +39,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(), diff --git a/src/hooks/usePayment.ts b/src/hooks/usePayment.ts index db1fb10..ee13649 100644 --- a/src/hooks/usePayment.ts +++ b/src/hooks/usePayment.ts @@ -11,11 +11,27 @@ import { } from '../core/contract'; import { usdToNativeAmount, usdToTokenAmount } from '../core/price-feed'; +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; +} + 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 +64,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); @@ -76,8 +97,15 @@ 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. + const nativeAmount = await usdToNativeAmount(amount, chain.chainId, controller.signal); + weiAmount = parseUnits(nativeAmount.toFixed(18), nativeDecimals); + } setStatus(PaymentStatus.Sending); const hash = await executePayInNative(walletClient, contractAddress, weiAmount); @@ -98,11 +126,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'); diff --git a/src/hooks/useQuote.ts b/src/hooks/useQuote.ts new file mode 100644 index 0000000..188b651 --- /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 && !!network && !!token && typeof amountUsd === 'number' && amountUsd > 0; + + const refresh = () => setTick((t) => t + 1); + + useEffect(() => { + if (!ready) { + 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 }; +} From f584a0af37ef892159a9eb8e7a960bb230fea924 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Wed, 6 May 2026 17:15:15 -0300 Subject: [PATCH 02/10] Refactor TopUpModal to EVM-only with integrated quote flow Restructures the top-up modal around an EVM-focused flow: filters chains to wagmi-supported set, builds token options inline with stablecoin-first defaults, and pulls balances via getTokenBalance. Solana/Tron continue to flow through their dedicated sub-entrypoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/TopUpModal.tsx | 1282 +++++++++++++++++++++++---------- 1 file changed, 907 insertions(+), 375 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 7ae512f..c9d58a3 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,26 +1,70 @@ -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'; + +// 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 ( @@ -34,14 +78,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 ( + ); } @@ -49,192 +106,198 @@ 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; - - // Fetch the server-issued Chainlink quote whenever we have a chain + token + amount. Keeps - // refreshing every 30s while the user lingers on review so the displayed amount is as fresh - // as the most recent feed update. - const parsedAmountForQuote = parseFloat(amount); - const quoteToken = isNativePayment ? 'native' : (selectedToken ?? null); - const { - quote, - isLoading: quoteLoading, - error: quoteError, - } = useQuote( - selectedChain?.name ?? null, - quoteToken, - Number.isFinite(parsedAmountForQuote) && parsedAmountForQuote > 0 ? parsedAmountForQuote : null, - { enabled: step === 'review' || step === 'token' }, - ); + 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 ( - status === PaymentStatus.Sending || - status === PaymentStatus.Confirming || - status === PaymentStatus.Approving - ) { - setStep('processing'); - } else if (status === PaymentStatus.Success || status === PaymentStatus.Error) { - setStep('result'); + if (tokenOptions.length === 0) { + setSelectedToken(null); + return; } - }, [status]); + 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 && !!selectedChain && !!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, + }); + useEffect(() => { + let cancelled = false; + setTokenBalance(null); + if (!wallet.address || !publicClient || !selectedChain || !selectedTokenOption) return; + + const loadBalance = async () => { + try { + if (selectedTokenOption.isNative) { + const bal = await publicClient.getBalance({ address: wallet.address as `0x${string}` }); + if (!cancelled) { + setTokenBalance(Number(formatUnits(bal, selectedTokenOption.decimals)).toFixed(4)); + } + } else { + const bal = await getTokenBalance( + publicClient, + selectedTokenOption.value as `0x${string}`, + wallet.address as `0x${string}`, + ); + 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 && + !!selectedChain && + !!selectedToken && + !!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; - // Pass the server's atomic quote through so the wallet signs exactly what the user saw, - // rather than re-running a CoinGecko-based conversion that could disagree with the quote. - void startPayment(Number(amount), selectedChain, selectedToken, { - atomicAmount: quote?.amountToken, + const handlePay = useCallback(() => { + if (!canPay || !selectedChain || !selectedToken || !quote || !effectiveAmount) return; + void startPayment(effectiveAmount, selectedChain, selectedToken, { + atomicAmount: quote.amountToken, }); - }, [amount, selectedChain, selectedToken, startPayment, quote]); - - 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]); + }, [canPay, selectedChain, selectedToken, quote, effectiveAmount, startPayment]); 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} - -
-
- You'll send - - {quote ? ( - <> - - {quote.amountTokenDisplay.toFixed(Math.min(8, quote.tokenDecimals))} {quote.tokenSymbol} - - - @ ${quote.priceUsd.toFixed(6)} / {quote.tokenSymbol} · Chainlink - - - ) : quoteLoading ? ( - Pricing… - ) : quoteError ? ( - {quoteError} - ) : ( - - )} - -
-
- 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, +}: { + amountUsd: number | null; + tokenOption: TokenOption | null; + quote: ReturnType['quote']; + quoteLoading: boolean; + quoteError: string | null; + tokenBalance: string | 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} +
+ {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, +}: { + status: PaymentStatus; + txHash: string | null; + explorerUrl?: string; +}) { + const labelByStatus: Record = { + [PaymentStatus.Connecting]: 'Switching network…', + [PaymentStatus.Approving]: 'Approving token spend…', + [PaymentStatus.Sending]: 'Waiting for wallet signature…', + [PaymentStatus.Confirming]: 'Confirming on-chain…', + }; + return ( +
+ +
+
{labelByStatus[status] ?? 'Processing…'}
+ {status === PaymentStatus.Confirming && ( +
+ This usually takes 10–60 seconds. +
+ )} +
+ {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); +} From 92498971069e3aa445eccce7536b0e63cf62cfa5 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 17:04:33 +0000 Subject: [PATCH 03/10] Segment 2.2: SDK ConfirmationPolicy abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per `enhancementplan.md` Segment 2.2, abstract per-chain confirmation / finality into an SDK-level `ConfirmationPolicy` so storefronts no longer have to branch on `chainId` to decide "is this safe yet". The SPD-canonical thresholds (ETH 12, Polygon 30, Base 12, TRON 19, Solana 31) and the Solana commitment vocabulary live in one place — `core/ConfirmationPolicy.ts` — and per-chain locked variants under `evm/`, `solana/`, `tron/` re-export through the existing subpaths. The EVM `usePayment` hook, Solana `SolanaPaymentPipeline`, and the shared `TransactionStatus` / `TopUpModal` UI now drive depth + commitment + ETA copy off the policy instead of inline `chain.confirmations` reads. `ChainConfig.confirmations` overrides still win when set, so existing consumers keep working with no change to the public API surface. Adds 32 unit tests in `src/__tests__/confirmationPolicy.test.ts`. --- src/__tests__/confirmationPolicy.test.ts | 230 +++++++++++++++++++ src/components/TopUpModal.tsx | 27 ++- src/components/TransactionStatus.tsx | 73 ++++-- src/core/ConfirmationPolicy.ts | 280 +++++++++++++++++++++++ src/core/types.ts | 14 ++ src/evm/confirmationPolicy.ts | 63 +++++ src/hooks/usePayment.ts | 25 +- src/index.ts | 20 ++ src/solana/SolanaTopUpModal.tsx | 2 + src/solana/confirmationPolicy.ts | 67 ++++++ src/solana/index.ts | 37 +++ src/solana/pipeline.ts | 41 +++- src/tron/TronTopUpModal.tsx | 2 + src/tron/confirmationPolicy.ts | 52 +++++ src/tron/index.ts | 33 +++ 15 files changed, 941 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/confirmationPolicy.test.ts create mode 100644 src/core/ConfirmationPolicy.ts create mode 100644 src/evm/confirmationPolicy.ts create mode 100644 src/solana/confirmationPolicy.ts create mode 100644 src/tron/confirmationPolicy.ts 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/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index c9d58a3..5740d8e 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -10,6 +10,7 @@ import { useQuote } from '../hooks/useQuote'; import { useWeb3Settle } from '../hooks/useWeb3Settle'; import { CHAIN_ICONS } from '../core/config'; import { getTokenBalance } from '../core/contract'; +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 @@ -362,6 +363,7 @@ export function Web3SettleTopUpModal({ status={status} txHash={txHash} explorerUrl={selectedChain?.explorerUrl} + chainId={selectedChain?.chainId} /> ) : (
@@ -856,10 +858,12 @@ function ProcessingState({ status, txHash, explorerUrl, + chainId, }: { status: PaymentStatus; txHash: string | null; explorerUrl?: string; + chainId?: number; }) { const labelByStatus: Record = { [PaymentStatus.Connecting]: 'Switching network…', @@ -867,14 +871,33 @@ function ProcessingState({ [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 && ( -
- This usually takes 10–60 seconds. +
+ {hint}
)}
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/types.ts b/src/core/types.ts index f207e69..59bc539 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -150,6 +150,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..fc66f5a --- /dev/null +++ b/src/evm/confirmationPolicy.ts @@ -0,0 +1,63 @@ +/** + * 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. + // eslint-disable-next-line no-console + 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/hooks/usePayment.ts b/src/hooks/usePayment.ts index ee13649..8e8eed4 100644 --- a/src/hooks/usePayment.ts +++ b/src/hooks/usePayment.ts @@ -10,6 +10,10 @@ import { waitForReceipt, } from '../core/contract'; import { usdToNativeAmount, usdToTokenAmount } from '../core/price-feed'; +import { + defaultConfirmationPolicy, + type ConfirmationPolicy, +} from '../core/ConfirmationPolicy'; interface StartPaymentOptions { /** @@ -20,6 +24,14 @@ interface StartPaymentOptions { * that hasn't been migrated to the quote endpoint. */ atomicAmount?: string; + /** + * 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 { @@ -112,7 +124,13 @@ export function usePayment(): UsePaymentReturn { setTxHash(hash); 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'); } @@ -168,7 +186,10 @@ export function usePayment(): UsePaymentReturn { setTxHash(hash); 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'); } diff --git a/src/index.ts b/src/index.ts index f3694c8..3cda83b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,26 @@ export { useWeb3Settle } from './hooks/useWeb3Settle'; export { usePayment } from './hooks/usePayment'; export { useWallet } from './hooks/useWallet'; +// ── 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} />
+ {gasEstimate && ( +
+ Network fee + + {typeof gasEstimate.usd === 'number' + ? `≈ $${gasEstimate.usd < 0.01 ? gasEstimate.usd.toFixed(4) : gasEstimate.usd.toFixed(2)}` + : '—'} + +
+ )} {insufficientBalance && (
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/solana/estimateGas.ts b/src/solana/estimateGas.ts new file mode 100644 index 0000000..b0be26a --- /dev/null +++ b/src/solana/estimateGas.ts @@ -0,0 +1,217 @@ +/** + * Solana fee estimator (item 14.1). + * + * Solana's "gas" model is two pieces: a fixed signature fee (5 000 lamports + * per signature, hard-coded by the runtime) plus an optional priority fee in + * micro-lamports per compute unit. We: + * 1. simulate the transaction to learn how many compute units it actually + * uses (`simulateTransaction.unitsConsumed`); + * 2. ask the cluster for recent priority fees and take a prudent median; + * 3. add the static signature fee. + * + * The total is returned in lamports plus an optional USD conversion via the + * caller-supplied price oracle. + */ +import { + type Connection, + PublicKey, + Transaction, + type TransactionInstruction, +} from '@solana/web3.js'; +import { + buildPayInNativeInstruction, + buildPayInTokenInstruction, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, +} from './instructions'; +import { deriveConfigPda, hexToBytes32 } from './pda'; +import { NATIVE_TOKEN_SENTINEL, type TokenSelection } from '../core/types'; +import type { + GasEstimate, + SolanaGasBreakdown, + FeeOracleOptions, +} from '../evm/estimateGas'; + +/** Lamports the cluster charges per signature. Static. */ +export const LAMPORTS_PER_SIGNATURE = 5_000; +/** Solana native decimals. */ +const SOL_DECIMALS = 9; +/** Default ceiling for compute units when simulation can't tell us. */ +const DEFAULT_COMPUTE_UNITS = 200_000; + +export interface EstimateSolanaGasInput { + /** Connected web3.js connection. */ + connection: Connection; + /** Sender pubkey (the customer's wallet). */ + sender: PublicKey; + /** MerchantPayIn program ID. */ + programId: PublicKey; + /** Per-merchant 32-byte identifier (hex string with or without 0x). */ + merchantId: string; + /** `"native"` for SOL pay-in, or the SPL mint address. */ + token: TokenSelection; + /** Amount in smallest unit (lamports for native, raw decimals for SPL). */ + amount: bigint; + /** SPL token mint when `token !== "native"`. Optional shortcut for tests. */ + tokenMint?: PublicKey; +} + +/** Compute the Associated Token Account address for (owner, mint). */ +function getAssociatedTokenAddress(owner: PublicKey, mint: PublicKey): PublicKey { + return PublicKey.findProgramAddressSync( + [owner.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], + ASSOCIATED_TOKEN_PROGRAM_ID, + )[0]; +} + +/** + * Simulate the pay-in tx to read `unitsConsumed`, then add a priority fee from + * `getRecentPrioritizationFees`. Returns lamports + breakdown + USD. + */ +export async function estimateSolanaGas( + input: EstimateSolanaGasInput, + fee: FeeOracleOptions = {}, +): Promise { + const merchantBytes = hexToBytes32(input.merchantId); + + // Build the same instruction the pipeline would submit. + let ix: TransactionInstruction; + if (input.token === NATIVE_TOKEN_SENTINEL) { + ix = buildPayInNativeInstruction( + { + programId: input.programId, + merchantId: merchantBytes, + sender: input.sender, + }, + input.amount, + ); + } else { + const mint = input.tokenMint ?? new PublicKey(String(input.token)); + const senderAta = getAssociatedTokenAddress(input.sender, mint); + const [configPda] = deriveConfigPda(input.programId, merchantBytes); + const vaultAta = getAssociatedTokenAddress(configPda, mint); + ix = buildPayInTokenInstruction( + { + programId: input.programId, + merchantId: merchantBytes, + sender: input.sender, + tokenMint: mint, + senderTokenAccount: senderAta, + vaultTokenAccount: vaultAta, + }, + input.amount, + ); + } + + // 1. Compute units via simulation. Some endpoints disallow `replaceRecentBlockhash` + // — we call it best-effort, fall back to the default. + let computeUnits = DEFAULT_COMPUTE_UNITS; + try { + const tx = new Transaction().add(ix); + tx.feePayer = input.sender; + // Avoid hitting the chain for a real blockhash here: simulateTransaction + // accepts a recent blockhash from the cluster, but for a CU read we can use + // a placeholder when the connection supports `replaceRecentBlockhash`. + const { blockhash } = await input.connection.getLatestBlockhash('confirmed'); + tx.recentBlockhash = blockhash; + const sim = await input.connection.simulateTransaction(tx, undefined); + const consumed = sim.value.unitsConsumed; + if (typeof consumed === 'number' && consumed > 0) { + computeUnits = consumed; + } + } catch { + // Keep DEFAULT_COMPUTE_UNITS — surface a conservative estimate. + } + + // 2. Median priority fee across recent blocks. Returns micro-lamports per CU. + let microLamportsPerCu = 0; + try { + const recent = await input.connection.getRecentPrioritizationFees(); + if (Array.isArray(recent) && recent.length > 0) { + // Median is more robust to spikes than mean. + const sorted = [...recent] + .map((r) => Number(r.prioritizationFee ?? 0)) + .filter((n) => Number.isFinite(n) && n >= 0) + .sort((a, b) => a - b); + if (sorted.length > 0) { + microLamportsPerCu = sorted[Math.floor(sorted.length / 2)] ?? 0; + } + } + } catch { + // Networks that don't support the RPC just skip priority — base fee still applies. + } + + // 3. Total lamports = base + (priority µLAM/CU × CU) / 1e6. + const priorityLamports = Math.ceil((microLamportsPerCu * computeUnits) / 1_000_000); + const totalLamports = LAMPORTS_PER_SIGNATURE + priorityLamports; + + const usd = await convertLamportsToUsd(totalLamports, fee); + + const breakdown: SolanaGasBreakdown = { + family: 'solana', + computeUnits, + microLamportsPerCu, + baseLamports: LAMPORTS_PER_SIGNATURE, + }; + + return { + native: totalLamports, + usd, + breakdown, + }; +} + +async function convertLamportsToUsd( + lamports: 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 sol = lamports / 10 ** SOL_DECIMALS; + return sol * priceUsd; +} + +/** + * Simulate-only variant exposed for tests: returns just the compute-unit count + * without the priority/network round-trip. Lets tests assert builder shape + * without mocking the whole connection. + */ +export function buildSolanaEstimateInstruction( + input: EstimateSolanaGasInput, +): TransactionInstruction { + const merchantBytes = hexToBytes32(input.merchantId); + if (input.token === NATIVE_TOKEN_SENTINEL) { + return buildPayInNativeInstruction( + { + programId: input.programId, + merchantId: merchantBytes, + sender: input.sender, + }, + input.amount, + ); + } + const mint = input.tokenMint ?? new PublicKey(String(input.token)); + const senderAta = getAssociatedTokenAddress(input.sender, mint); + const [configPda] = deriveConfigPda(input.programId, merchantBytes); + const vaultAta = getAssociatedTokenAddress(configPda, mint); + return buildPayInTokenInstruction( + { + programId: input.programId, + merchantId: merchantBytes, + sender: input.sender, + tokenMint: mint, + senderTokenAccount: senderAta, + vaultTokenAccount: vaultAta, + }, + input.amount, + ); +} diff --git a/src/tron/estimateGas.ts b/src/tron/estimateGas.ts new file mode 100644 index 0000000..8d6c287 --- /dev/null +++ b/src/tron/estimateGas.ts @@ -0,0 +1,200 @@ +/** + * TRON fee estimator (item 14.1). + * + * TRON does not have "gas". Smart-contract calls cost two resources: + * - **Energy**: consumed by VM execution. Convertible from sun (1 TRX = 1e6 + * sun) at ~280 sun per energy unit on mainnet (the rate is set by the + * Witnesses, not us). + * - **Bandwidth**: consumed by the raw tx bytes. Most callers have free + * daily bandwidth; if not it costs 1 sun per byte. + * + * `triggerconstantcontract` (a.k.a. `triggerSmartContract` with + * `_isConstant: true` in TronWeb) returns `energy_used` without sending the tx, + * which is the equivalent of EVM's `eth_estimateGas`. We then convert energy → + * sun → TRX → USD using the caller-supplied price oracle. + */ +import type { TronWebLike } from './tronweb-global'; +import { getTronWeb } from './tronweb-global'; +import { NATIVE_TOKEN_SENTINEL, type TokenSelection } from '../core/types'; +import type { + GasEstimate, + TronGasBreakdown, + FeeOracleOptions, +} from '../evm/estimateGas'; + +/** Sun per energy unit on TRON mainnet (set by the Witnesses; ~280 since 2023). */ +export const DEFAULT_SUN_PER_ENERGY = 280; +/** TRX decimals — 6 (1 TRX = 1e6 sun). */ +const TRX_DECIMALS = 6; +/** Conservative default transaction byte size when we can't read it. */ +const DEFAULT_TX_BANDWIDTH_BYTES = 270; + +/** TRON base58 (T + 33 chars). */ +const TRON_BASE58 = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; + +function assertTronAddress(address: string, kind: string): void { + if (!TRON_BASE58.test(address)) { + throw new Error(`Invalid TRON ${kind} address: "${address}"`); + } +} + +export interface EstimateTronGasInput { + /** MerchantPayIn contract address (base58). */ + contractAddress: string; + /** `"native"` for `payInNative`, or the TRC-20 contract address. */ + token: TokenSelection; + /** Amount in smallest unit (sun for TRX, raw decimals for TRC-20). */ + amount: bigint; + /** Optional sender override. Defaults to `tronWeb.defaultAddress.base58`. */ + sender?: string; + /** + * Optional cluster sun-per-energy rate. Falls back to {@link DEFAULT_SUN_PER_ENERGY} + * if not set. Mainnet ≈ 280 today; testnet may differ. + */ + sunPerEnergy?: number; + /** Allow callers (tests) to inject a TronWeb instance. */ + tronWebOverride?: TronWebLike; +} + +/** + * Run a constant call against the contract to read the energy estimate, then + * fold the standard TRON resource model. + */ +export async function estimateTronGas( + input: EstimateTronGasInput, + fee: FeeOracleOptions = {}, +): Promise { + assertTronAddress(input.contractAddress, 'merchant contract'); + const sunPerEnergy = input.sunPerEnergy ?? DEFAULT_SUN_PER_ENERGY; + + const tw = input.tronWebOverride ?? getTronWeb(); + if (!tw) { + throw new Error( + 'TronLink (or compatible TRON wallet) is not available. Install the extension and reload.', + ); + } + const sender = input.sender ?? (typeof tw.defaultAddress.base58 === 'string' ? tw.defaultAddress.base58 : ''); + if (!sender) { + throw new Error('TRON wallet is locked. Unlock it and try again.'); + } + assertTronAddress(sender, 'sender'); + + const isNative = input.token === NATIVE_TOKEN_SENTINEL; + // TronWeb's transactionBuilder lives off the same root. We declare the slim + // shape we need rather than depending on the full type — keeps the SDK + // tolerant across TronWeb minor versions. + const txBuilder = (tw as unknown as { + transactionBuilder?: { + triggerConstantContract: ( + contract: string, + functionSelector: string, + options: Record, + params: { type: string; value: string | number }[], + sender: string, + ) => Promise<{ + energy_used?: number; + energy_penalty?: number; + result?: { result: boolean; message?: string }; + transaction?: { raw_data_hex?: string }; + }>; + }; + }).transactionBuilder; + if (!txBuilder?.triggerConstantContract) { + throw new Error('TronWeb instance does not expose transactionBuilder.triggerConstantContract'); + } + + let functionSelector: string; + let params: { type: string; value: string | number }[]; + let callValue = 0; + if (isNative) { + functionSelector = 'payInNative()'; + params = []; + callValue = Number(input.amount); + } else { + assertTronAddress(String(input.token), 'token'); + functionSelector = 'payInToken(address,uint256)'; + params = [ + { type: 'address', value: String(input.token) }, + { type: 'uint256', value: input.amount.toString() }, + ]; + } + + let energy = 0; + let bandwidth = DEFAULT_TX_BANDWIDTH_BYTES; + try { + const result = await txBuilder.triggerConstantContract( + input.contractAddress, + functionSelector, + callValue ? { callValue } : {}, + params, + sender, + ); + if (typeof result.energy_used === 'number') { + energy = result.energy_used; + } + const rawHex = result.transaction?.raw_data_hex; + if (typeof rawHex === 'string' && rawHex.length > 0) { + // Hex string → byte length (2 hex chars per byte). Fall back if odd. + bandwidth = Math.ceil(rawHex.length / 2); + } + } catch (err) { + throw new Error( + `TRON triggerConstantContract failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const energySun = energy * sunPerEnergy; + // Bandwidth is "free" up to the daily quota, but the SDK takes the + // conservative path: report the full sun cost. Merchants who want the free + // path can subtract `bandwidth` from `sunCost` — we expose both pieces. + const bandwidthSun = bandwidth; // 1 sun/byte when paid. + const sunCost = energySun + bandwidthSun; + + const usd = await convertSunToUsd(sunCost, fee); + + const breakdown: TronGasBreakdown = { + family: 'tron', + energy, + bandwidth, + sunPerEnergy, + }; + + return { + native: sunCost, + usd, + breakdown, + }; +} + +async function convertSunToUsd( + sun: 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 trx = sun / 10 ** TRX_DECIMALS; + return trx * priceUsd; +} + +/** + * Convenience helper exposed for tests: same shape as `estimateTronGas`'s + * return, computed purely from inputs without hitting any chain. + */ +export function computeTronCost( + energy: number, + bandwidth: number, + sunPerEnergy = DEFAULT_SUN_PER_ENERGY, +): { sunCost: number; energySun: number; bandwidthSun: number } { + const energySun = energy * sunPerEnergy; + const bandwidthSun = bandwidth; + return { energySun, bandwidthSun, sunCost: energySun + bandwidthSun }; +} From 2323db933bf3591b91b0693a49279827911114af Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 17:34:47 +0000 Subject: [PATCH 05/10] Segment 14.2: telemetry breadcrumb on payment failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in onTelemetry callback so merchants get a single PII-safe TelemetryEvent per failed pay-in. Callback runs through safeEmit so a buggy analytics handler can never break the flow. - core/telemetry: buildTelemetryEvent, redactErrorMessage, hashWalletAddress (SubtleCrypto SHA-256 → 16-char hex digest with a deterministic FNV-1a fallback for older runtimes), safeEmit. - Privacy contract: no plain addresses (only the hash digest), no amounts, message redacted (0x-addresses, tx hashes, UUIDs, base58 pubkeys) and truncated to 240 chars. - Web3SettleConfig grows optional onTelemetry + contractVersion fields. - Solana + TRON payment hooks: phase tracking + emit on catch. - 13 tests covering event shape, redaction, hash determinism, callback-throws-safely, and the no-op no-callback path. EVM hook integration ships in the next commit alongside permit, where phase tracking is shared between the two paths. --- src/__tests__/telemetry.test.ts | 125 +++++++++++++++++++++ src/core/telemetry.ts | 188 ++++++++++++++++++++++++++++++++ src/core/types.ts | 15 +++ src/solana/useSolanaPayment.ts | 39 ++++++- src/tron/useTronPayment.ts | 40 ++++++- 5 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/telemetry.test.ts create mode 100644 src/core/telemetry.ts 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/core/telemetry.ts b/src/core/telemetry.ts new file mode 100644 index 0000000..378b88f --- /dev/null +++ b/src/core/telemetry.ts @@ -0,0 +1,188 @@ +/** + * 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; + // eslint-disable-next-line no-console + 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 59bc539..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. @@ -94,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 { diff --git a/src/solana/useSolanaPayment.ts b/src/solana/useSolanaPayment.ts index 9ccc91a..09ebd87 100644 --- a/src/solana/useSolanaPayment.ts +++ b/src/solana/useSolanaPayment.ts @@ -3,6 +3,19 @@ import { useWallet } from '@solana/wallet-adapter-react'; import { PaymentStatus, type ChainConfig, type TokenSelection } from '../core/types'; import { classifyError, PaymentPipelineError } from '../core/pipeline'; import { useSolanaPipeline } from './SolanaProvider'; +import { + buildTelemetryEvent, + hashWalletAddress, + safeEmit, + type TelemetryCallback, + type TelemetryPhase, +} from '../core/telemetry'; + +interface SolanaStartPaymentOpts { + onTelemetry?: TelemetryCallback; + walletId?: string; + contractVersion?: string; +} interface UseSolanaPaymentReturn { status: PaymentStatus; @@ -12,6 +25,7 @@ interface UseSolanaPaymentReturn { amount: number, chain: ChainConfig, token: TokenSelection, + opts?: SolanaStartPaymentOpts, ) => Promise; reset: () => void; } @@ -54,7 +68,12 @@ export function useSolanaPayment(): UseSolanaPaymentReturn { }, []); const startPayment = useCallback( - async (amount: number, chain: ChainConfig, token: TokenSelection) => { + async ( + amount: number, + chain: ChainConfig, + token: TokenSelection, + opts: SolanaStartPaymentOpts = {}, + ) => { if (!wallet.publicKey) { setError('Wallet not connected'); setStatus(PaymentStatus.Error); @@ -65,13 +84,17 @@ export function useSolanaPayment(): UseSolanaPaymentReturn { setTxHash(null); setError(null); + let phase: TelemetryPhase = 'connect'; try { + phase = 'quote'; const raw = await pipeline.quoteAmount(amount, chain, token); + phase = 'send'; setStatus(PaymentStatus.Sending); const hash = await pipeline.execute(chain, token, raw); setTxHash(hash); + phase = 'confirm'; setStatus(PaymentStatus.Confirming); const receipt = await pipeline.waitForReceipt(hash); if (!receipt.success) { @@ -80,6 +103,20 @@ export function useSolanaPayment(): UseSolanaPaymentReturn { setStatus(PaymentStatus.Success); } catch (err) { + if (opts.onTelemetry) { + const errMsg = err instanceof Error ? err.message : String(err); + const digest = await hashWalletAddress(wallet.publicKey?.toBase58()); + const errKind = err instanceof PaymentPipelineError ? err.kind : classifyError(err); + safeEmit(opts.onTelemetry, buildTelemetryEvent({ + chain: 'solana', + phase, + errorCode: errKind, + walletId: opts.walletId, + contractVersion: opts.contractVersion, + walletDigest: digest, + rawMessage: errMsg, + })); + } setError(classifyMessage(err)); setStatus(PaymentStatus.Error); } diff --git a/src/tron/useTronPayment.ts b/src/tron/useTronPayment.ts index 58cb99b..216373d 100644 --- a/src/tron/useTronPayment.ts +++ b/src/tron/useTronPayment.ts @@ -2,6 +2,19 @@ import { useCallback, useState } from 'react'; import { PaymentStatus, type ChainConfig, type TokenSelection } from '../core/types'; import { classifyError, PaymentPipelineError } from '../core/pipeline'; import { useTronWeb3SettleContext } from './TronProvider'; +import { + buildTelemetryEvent, + hashWalletAddress, + safeEmit, + type TelemetryCallback, + type TelemetryPhase, +} from '../core/telemetry'; + +interface TronStartPaymentOpts { + onTelemetry?: TelemetryCallback; + walletId?: string; + contractVersion?: string; +} interface UseTronPaymentReturn { status: PaymentStatus; @@ -11,6 +24,7 @@ interface UseTronPaymentReturn { amount: number, chain: ChainConfig, token: TokenSelection, + opts?: TronStartPaymentOpts, ) => Promise; reset: () => void; } @@ -52,7 +66,12 @@ export function useTronPayment(): UseTronPaymentReturn { }, []); const startPayment = useCallback( - async (amount: number, chain: ChainConfig, token: TokenSelection) => { + async ( + amount: number, + chain: ChainConfig, + token: TokenSelection, + opts: TronStartPaymentOpts = {}, + ) => { if (!wallet.connected || !wallet.address) { setError('TRON wallet not connected'); setStatus(PaymentStatus.Error); @@ -63,10 +82,13 @@ export function useTronPayment(): UseTronPaymentReturn { setTxHash(null); setError(null); + let phase: TelemetryPhase = 'connect'; try { + phase = 'quote'; const raw = await pipeline.quoteAmount(amount, chain, token); if (await pipeline.needsApproval(chain, token, raw)) { + phase = 'approve'; setStatus(PaymentStatus.Approving); const approveTx = await pipeline.approve(chain, token, raw); // Confirm the approval lands before submitting the pay-in — otherwise @@ -77,10 +99,12 @@ export function useTronPayment(): UseTronPaymentReturn { } } + phase = 'send'; setStatus(PaymentStatus.Sending); const hash = await pipeline.execute(chain, token, raw); setTxHash(hash); + phase = 'confirm'; setStatus(PaymentStatus.Confirming); const receipt = await pipeline.waitForReceipt(hash); if (!receipt.success) { @@ -89,6 +113,20 @@ export function useTronPayment(): UseTronPaymentReturn { setStatus(PaymentStatus.Success); } catch (err) { + if (opts.onTelemetry) { + const errMsg = err instanceof Error ? err.message : String(err); + const digest = await hashWalletAddress(wallet.address); + const errKind = err instanceof PaymentPipelineError ? err.kind : classifyError(err); + safeEmit(opts.onTelemetry, buildTelemetryEvent({ + chain: 'tron', + phase, + errorCode: errKind, + walletId: opts.walletId, + contractVersion: opts.contractVersion, + walletDigest: digest, + rawMessage: errMsg, + })); + } setError(classifyMessage(err)); setStatus(PaymentStatus.Error); } From b7fdfb3bed0b37ff7db4c3bb20f8cc4c1c034496 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 17:35:06 +0000 Subject: [PATCH 06/10] Segment 14.6: EIP-712 permit signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the EVM pay-token flow skip the standalone approve() tx when the token implements EIP-2612, saving the user one wallet popup and ~$0.50 of gas. - evm/permit: - detectPermitSupport probes name / version / nonces / DOMAIN_SEPARATOR. Never throws — returns { supported: false } when any view reverts. - buildPermitTypedData and signPermit produce a viem-compatible EIP-712 payload, normalise v to {27,28}, and refuse to sign when the connected account doesn't match the permit owner. - validatePermitSignature: cheap pre-broadcast check (length, r/s ranges, EIP-2 high-s rejection, v ∈ {0,1,27,28}). - assertDeadlineFresh: throws when the deadline is already past. - core/contract: ERC20_PERMIT_ABI + submitPermit() helper that posts the on-chain permit(...) tx. - hooks/usePayment: new permit?: 'auto' | 'never' | 'require' option on startPayment. Default 'auto': probe → sign → submit permit; fall back to approve() if the token doesn't support permit. 'require' throws on unsupported tokens; 'never' forces the legacy approve() path. Also wires the segment-14.2 telemetry phase tracking through the EVM hook for parity with Solana / TRON. The MerchantPayIn contract itself does not consume permits today — this ships the SDK-side primitive ready for payInTokenPermit(...) once the on-chain side lands, and it is already useful for merchants who want a gasless approval before sending tokens to the contract. --- src/core/contract.ts | 55 ++++++++ src/evm/permit.ts | 290 ++++++++++++++++++++++++++++++++++++++++ src/hooks/usePayment.ts | 127 ++++++++++++++++-- 3 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 src/evm/permit.ts 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/evm/permit.ts b/src/evm/permit.ts new file mode 100644 index 0000000..21eb3b5 --- /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: nonce as bigint, + }; + } 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 = `0x${sig.slice(2, 66)}` as Hex; + const s = `0x${sig.slice(66, 130)}` as Hex; + const vHex = `0x${sig.slice(130)}` as Hex; + 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/hooks/usePayment.ts b/src/hooks/usePayment.ts index 8e8eed4..00cbfbb 100644 --- a/src/hooks/usePayment.ts +++ b/src/hooks/usePayment.ts @@ -7,9 +7,19 @@ 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, @@ -24,6 +34,31 @@ interface StartPaymentOptions { * 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 @@ -99,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 }); @@ -115,14 +170,17 @@ export function usePayment(): UsePaymentReturn { 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); // Segment 2.2: depth comes from the policy (which honours // `chain.confirmations` when set, falls back to the SPD-canonical @@ -166,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, @@ -185,6 +291,7 @@ export function usePayment(): UsePaymentReturn { ); setTxHash(hash); + phase = 'confirm'; setStatus(PaymentStatus.Confirming); // Segment 2.2: same policy resolution as the native branch. const policy = opts.confirmationPolicy ?? defaultConfirmationPolicy; @@ -199,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); } From aac73ebc6648504ada7805861a7b997a12ca284d Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 17:35:21 +0000 Subject: [PATCH 07/10] Segment 14.5: headless hooks + web components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new subpath exports so non-React stacks can drive the SDK: - @web3settle/merchant-sdk/headless - createPayButtonController: load merchant payment-config and run an injected runPayment() routine; subscribe() returns the same snapshot shape useState would. - createWalletConnectController: connect / disconnect with a pluggable connect routine — keeps wagmi / viem / tronweb / web3.js out of the headless bundle. - createGasEstimateController: wraps any of the three estimators from segment 14.1 under a refresh + auto-refresh interval API, dispatchable from any UI framework. - @web3settle/merchant-sdk/wc - native HTMLElement (no Lit, no framework runtime). attributeChangedCallback rebuilds the controller; the button emits payment-started / payment-success / payment-error CustomEvents the merchant can listen for. - registerWebComponents() is idempotent and runs as a side effect on import; gated behind a customElements check so the module is safe to require in Node / SSR. Both subpaths reuse the existing controllers; nothing else in the SDK imports them, keeping the EVM/Solana/TRON entry bundles unchanged. vite.config.ts: new entries for headless + wc, both built as ES + CJS. Bundle: dist/headless.js 2.36 kB, dist/wc.js 3.63 kB (gzip). --- src/headless/index.ts | 29 ++++++ src/headless/useGasEstimate.ts | 113 +++++++++++++++++++++ src/headless/usePayButton.ts | 163 ++++++++++++++++++++++++++++++ src/headless/useWalletConnect.ts | 87 ++++++++++++++++ src/wc/index.ts | 29 ++++++ src/wc/pay-button.ts | 164 +++++++++++++++++++++++++++++++ vite.config.ts | 2 + 7 files changed, 587 insertions(+) create mode 100644 src/headless/index.ts create mode 100644 src/headless/useGasEstimate.ts create mode 100644 src/headless/usePayButton.ts create mode 100644 src/headless/useWalletConnect.ts create mode 100644 src/wc/index.ts create mode 100644 src/wc/pay-button.ts 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..03519fa --- /dev/null +++ b/src/headless/usePayButton.ts @@ -0,0 +1,163 @@ +/** + * 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 { + if (!opts.apiClient && !(opts.apiBaseUrl && opts.storefrontId)) { + throw new Error('createPayButtonController requires either apiClient or apiBaseUrl+storefrontId'); + } + const apiClient = opts.apiClient ?? new Web3SettleApiClient(opts.apiBaseUrl as string, opts.storefrontId as string); + + 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/wc/index.ts b/src/wc/index.ts new file mode 100644 index 0000000..6c20e9c --- /dev/null +++ b/src/wc/index.ts @@ -0,0 +1,29 @@ +/** + * Web Components subpath entry — `@web3settle/merchant-sdk/wc`. + * + * Why native HTMLElement and not Lit: + * - Lit pulls ~10 kB of runtime; we already kept the bundle slim by + * hand-rolling Solana instructions, so adding a dependency for one button + * doesn't fit the brief. + * - The button is a thin shell — it only needs `connectedCallback`, + * `disconnectedCallback`, attribute reflection and shadow DOM. Native is + * enough. + * + * Consumers wire it up like any other custom element: + * + * ```html + * + * + * + * + * ``` + */ +export { Web3SettlePayButtonElement, registerWebComponents } from './pay-button'; diff --git a/src/wc/pay-button.ts b/src/wc/pay-button.ts new file mode 100644 index 0000000..b1b515f --- /dev/null +++ b/src/wc/pay-button.ts @@ -0,0 +1,164 @@ +/** + * `` native Web Component. + * + * Reuses the headless controller (`createPayButtonController`) under the hood + * so the same code path drives both the React `` and + * this Web Component. The element renders a single button with a hover state + * and emits a small CustomEvent vocabulary the merchant can listen for. + * + * Events: + * - `payment-started` detail: `{ amount: number }` + * - `payment-success` detail: `{ amount: number; txHash: string }` + * - `payment-error` detail: `{ amount: number; message: string }` + * + * Attributes: + * - `amount` (required) — USD amount to charge + * - `storefront-id` (required) — UUID + * - `api-base-url` (required) — Web3Settle API base URL + * - `label` (optional) — button text override + * - `disabled` (boolean attribute) — disables click + */ +import { + createPayButtonController, + type PayButtonController, + type PayButtonState, +} from '../headless/usePayButton'; + +const TEMPLATE = ` + + +`; + +/** 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 { + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + } + this.shadowRoot!.innerHTML = TEMPLATE; + this.buttonEl = this.shadowRoot!.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 === 'success') { + this.dispatchEvent(new CustomEvent('payment-success', { + detail: { amount, txHash: state.txHash }, + })); + return; + } + if (state.error && state.status === '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'], From d370b60daadacf18ab126bb099517bdcf2294b46 Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 17:35:35 +0000 Subject: [PATCH 08/10] Bump @web3settle/merchant-sdk to 0.5.0 Surfaces the new public API from segments 14.1, 14.2, 14.5, 14.6: - estimateEvmGas / estimateEvmApproveGas + GasEstimate types - detectPermitSupport / signPermit / buildPermitTypedData / validatePermitSignature / assertDeadlineFresh + Permit* types - buildTelemetryEvent / hashWalletAddress / redactErrorMessage / safeEmit + TelemetryEvent / TelemetryCallback / TelemetryChain / TelemetryPhase / BuildEventInput Solana / TRON fee estimators are exported from the existing @web3settle/merchant-sdk/solana and /tron subpaths via their own type-only exports off `evm/estimateGas` (kept colocated to keep one GasEstimate shape across all three chains). The headless and wc subpaths land in this release; package.json "exports" map already advertises ./headless and ./wc. CHANGELOG entry added under [0.5.0] - 2026-05-09. --- CHANGELOG.md | 14 ++++++++++++++ package.json | 12 +++++++++++- src/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) 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.json b/package.json index d61340b..0e7fcf2 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": [ diff --git a/src/index.ts b/src/index.ts index 3cda83b..3383c06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,48 @@ 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 From 7dcedf36a50f019dcfb88d1db9c4e26e5d5e91fd Mon Sep 17 00:00:00 2001 From: Marcelo Ceccon Date: Sat, 9 May 2026 20:04:59 +0000 Subject: [PATCH 09/10] CI: fix lint and typecheck in V0.5.0 SDK additions - src/components/TopUpModal.tsx: replace `!!`/`as` casts with `Boolean()` and locally-typed `account` const; escape apostrophes; drop invalid `role="text"`. - src/evm/permit.ts: drop redundant `as bigint` and `as Hex` casts now that the template literal is annotated `: Hex` directly. - src/evm/confirmationPolicy.ts, src/core/telemetry.ts: remove stale `eslint-disable no-console` directives (warn/error console use is allowed). - src/headless/usePayButton.ts: branch on apiClient vs apiBaseUrl+storefrontId so TS narrows the constructor args without `!`. - src/hooks/useQuote.ts: explicit null guards before calling fetchQuote. - src/wc/pay-button.ts: use shadowRoot fallback, swap string compares for PaymentStatus enum to satisfy no-unsafe-enum-comparison. --- src/components/TopUpModal.tsx | 27 ++++++++++++++------------- src/core/telemetry.ts | 1 - src/evm/confirmationPolicy.ts | 1 - src/evm/permit.ts | 8 ++++---- src/headless/usePayButton.ts | 8 ++++++-- src/hooks/useQuote.ts | 6 +++--- src/wc/pay-button.ts | 13 ++++++------- 7 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index bd9ac13..5d86ad5 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -187,7 +187,7 @@ export function Web3SettleTopUpModal({ selectedChain?.name ?? null, quoteToken, effectiveAmount, - { enabled: isOpen && status === PaymentStatus.Idle && !!selectedChain && !!quoteToken }, + { enabled: isOpen && status === PaymentStatus.Idle && Boolean(selectedChain) && Boolean(quoteToken) }, ); // ── Token balance (best-effort; non-blocking) ────────────────────────── @@ -205,9 +205,10 @@ export function Web3SettleTopUpModal({ useEffect(() => { let cancelled = false; setGasEstimate(null); + const account = wallet.address; if ( !publicClient || - !wallet.address || + !account || !selectedChain || !selectedTokenOption || !quote @@ -223,7 +224,7 @@ export function Web3SettleTopUpModal({ const est = await estimateEvmGas( { publicClient, - account: wallet.address as `0x${string}`, + account, contractAddress: selectedChain.contractAddress as `0x${string}`, nativeDecimals: selectedChain.nativeCurrency?.decimals ?? 18, token: tokenForEstimate, @@ -247,12 +248,13 @@ export function Web3SettleTopUpModal({ useEffect(() => { let cancelled = false; setTokenBalance(null); - if (!wallet.address || !publicClient || !selectedChain || !selectedTokenOption) return; + const account = wallet.address; + if (!account || !publicClient || !selectedChain || !selectedTokenOption) return; const loadBalance = async () => { try { if (selectedTokenOption.isNative) { - const bal = await publicClient.getBalance({ address: wallet.address as `0x${string}` }); + const bal = await publicClient.getBalance({ address: account }); if (!cancelled) { setTokenBalance(Number(formatUnits(bal, selectedTokenOption.decimals)).toFixed(4)); } @@ -260,7 +262,7 @@ export function Web3SettleTopUpModal({ const bal = await getTokenBalance( publicClient, selectedTokenOption.value as `0x${string}`, - wallet.address as `0x${string}`, + account, ); if (!cancelled) { setTokenBalance(Number(formatUnits(bal, selectedTokenOption.decimals)).toFixed(4)); @@ -326,9 +328,9 @@ export function Web3SettleTopUpModal({ const canPay = wallet.isConnected && - !!selectedChain && - !!selectedToken && - !!quote && + Boolean(selectedChain) && + Boolean(selectedToken) && + Boolean(quote) && !quoteLoading && !quoteError && !isProcessing; @@ -428,7 +430,6 @@ export function Web3SettleTopUpModal({ {typeof fixedAmount === 'number' ? (
- You'll send + You'll send {formatTokenAmount(quote.amountTokenDisplay, quote.tokenDecimals)} {quote.tokenSymbol} @@ -1047,7 +1048,7 @@ function ConfigErrorState({ error, onRetry }: { error: string; onRetry: () => vo
-

Couldn't load payment options

+

Couldn't load payment options

{error}

diff --git a/src/core/telemetry.ts b/src/core/telemetry.ts index 378b88f..7b21080 100644 --- a/src/core/telemetry.ts +++ b/src/core/telemetry.ts @@ -98,7 +98,6 @@ export function safeEmit( } catch (err) { if (!warnedOnce) { warnedOnce = true; - // eslint-disable-next-line no-console console.warn( '[Web3Settle] telemetry callback threw — subsequent throws will be silenced.', err, diff --git a/src/evm/confirmationPolicy.ts b/src/evm/confirmationPolicy.ts index fc66f5a..beb1789 100644 --- a/src/evm/confirmationPolicy.ts +++ b/src/evm/confirmationPolicy.ts @@ -49,7 +49,6 @@ class EvmConfirmationPolicy implements ConfirmationPolicy { // Conservative default; logged so integrators notice the mis-wire. // Console.warn is called once per resolve — acceptable for an SDK // diagnostic. - // eslint-disable-next-line no-console console.warn( `[w3s] EVM ConfirmationPolicy used with non-EVM chainId ${config.chainId}; ` + `falling back to default depth. Use the chain-family-specific subpath instead.`, diff --git a/src/evm/permit.ts b/src/evm/permit.ts index 21eb3b5..ab1d11d 100644 --- a/src/evm/permit.ts +++ b/src/evm/permit.ts @@ -129,7 +129,7 @@ export async function detectPermitSupport( supported: true, name, version, - nonce: nonce as bigint, + nonce, }; } catch { return { supported: false }; @@ -272,9 +272,9 @@ export function validatePermitSignature(sig: Hex): { valid: boolean; reason?: st if (sig.length !== 132) { return { valid: false, reason: `Expected 132 hex chars (65 bytes), got ${sig.length}` }; } - const r = `0x${sig.slice(2, 66)}` as Hex; - const s = `0x${sig.slice(66, 130)}` as Hex; - const vHex = `0x${sig.slice(130)}` as Hex; + 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. diff --git a/src/headless/usePayButton.ts b/src/headless/usePayButton.ts index 03519fa..63a432a 100644 --- a/src/headless/usePayButton.ts +++ b/src/headless/usePayButton.ts @@ -69,10 +69,14 @@ const INITIAL_STATE: PayButtonState = Object.freeze({ }); export function createPayButtonController(opts: PayButtonControllerOptions): PayButtonController { - if (!opts.apiClient && !(opts.apiBaseUrl && opts.storefrontId)) { + 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'); } - const apiClient = opts.apiClient ?? new Web3SettleApiClient(opts.apiBaseUrl as string, opts.storefrontId as string); let state: PayButtonState = INITIAL_STATE; const listeners = new Set<(s: PayButtonState) => void>(); diff --git a/src/hooks/useQuote.ts b/src/hooks/useQuote.ts index 188b651..b8bd3b5 100644 --- a/src/hooks/useQuote.ts +++ b/src/hooks/useQuote.ts @@ -44,12 +44,12 @@ export function useQuote( const [tick, setTick] = useState(0); const ready = - enabled && !!network && !!token && typeof amountUsd === 'number' && amountUsd > 0; + enabled && Boolean(network) && Boolean(token) && typeof amountUsd === 'number' && amountUsd > 0; const refresh = () => setTick((t) => t + 1); useEffect(() => { - if (!ready) { + if (!ready || network === null || token === null || amountUsd === null) { setQuote(null); setError(null); return; @@ -61,7 +61,7 @@ export function useQuote( setError(null); client - .fetchQuote(network!, token!, amountUsd!, controller.signal) + .fetchQuote(network, token, amountUsd, controller.signal) .then((q) => { if (!controller.signal.aborted) setQuote(q); }) diff --git a/src/wc/pay-button.ts b/src/wc/pay-button.ts index b1b515f..05155ae 100644 --- a/src/wc/pay-button.ts +++ b/src/wc/pay-button.ts @@ -23,6 +23,7 @@ import { type PayButtonController, type PayButtonState, } from '../headless/usePayButton'; +import { PaymentStatus } from '../core/types'; const TEMPLATE = `