diff --git a/.gitignore b/.gitignore index 0562aab..d263646 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,10 @@ coverage/ *.tgz .cache/ +# Documentation files +CONTRIBUTION_JUSTIFICATION.md +CONTRIBUTION_OPPORTUNITIES.md +MAINTAINER_ANALYSIS.md +PERSISTENT_NONCE_TRACKING.md + .github/workflows/integration-tests.yml diff --git a/apps/dashboard/src/components/network-card.tsx b/apps/dashboard/src/components/network-card.tsx index e61289e..bdfc09e 100644 --- a/apps/dashboard/src/components/network-card.tsx +++ b/apps/dashboard/src/components/network-card.tsx @@ -92,6 +92,38 @@ export const SUPPORTED_NETWORKS: NetworkConfig[] = [ chainId: 196, testnet: false, }, + { + v1Id: 'arbitrum', + v2Id: 'eip155:42161', + name: 'Arbitrum', + type: 'evm', + chainId: 42161, + testnet: false, + }, + { + v1Id: 'optimism', + v2Id: 'eip155:10', + name: 'Optimism', + type: 'evm', + chainId: 10, + testnet: false, + }, + { + v1Id: 'bnb', + v2Id: 'eip155:56', + name: 'BNB Chain', + type: 'evm', + chainId: 56, + testnet: false, + }, + { + v1Id: 'linea', + v2Id: 'eip155:59144', + name: 'Linea', + type: 'evm', + chainId: 59144, + testnet: false, + }, // ============ EVM Testnets ============ { @@ -134,6 +166,38 @@ export const SUPPORTED_NETWORKS: NetworkConfig[] = [ chainId: 195, testnet: true, }, + { + v1Id: 'arbitrum-sepolia', + v2Id: 'eip155:421614', + name: 'Arbitrum Sepolia', + type: 'evm', + chainId: 421614, + testnet: true, + }, + { + v1Id: 'optimism-sepolia', + v2Id: 'eip155:11155420', + name: 'Optimism Sepolia', + type: 'evm', + chainId: 11155420, + testnet: true, + }, + { + v1Id: 'bnb-testnet', + v2Id: 'eip155:97', + name: 'BNB Chain Testnet', + type: 'evm', + chainId: 97, + testnet: true, + }, + { + v1Id: 'linea-goerli', + v2Id: 'eip155:59140', + name: 'Linea Goerli', + type: 'evm', + chainId: 59140, + testnet: true, + }, // ============ Solana ============ { @@ -168,12 +232,20 @@ const EXPLORER_URLS: Record = { iotex: 'https://iotexscan.io', peaq: 'https://peaq.subscan.io', xlayer: 'https://www.okx.com/explorer/xlayer', + arbitrum: 'https://arbiscan.io', + optimism: 'https://optimistic.etherscan.io', + bnb: 'https://bscscan.com', + linea: 'https://lineascan.build', // EVM Testnets 'base-sepolia': 'https://sepolia.basescan.org', 'polygon-amoy': 'https://amoy.polygonscan.com', 'avalanche-fuji': 'https://testnet.snowtrace.io', 'sei-testnet': 'https://testnet.seitrace.com', 'xlayer-testnet': 'https://www.okx.com/explorer/xlayer-test', + 'arbitrum-sepolia': 'https://sepolia.arbiscan.io', + 'optimism-sepolia': 'https://sepolia-optimism.etherscan.io', + 'bnb-testnet': 'https://testnet.bscscan.com', + 'linea-goerli': 'https://goerli.lineascan.build', // Solana solana: 'https://solscan.io', 'solana-devnet': 'https://solscan.io', diff --git a/package.json b/package.json index dddfefa..d0d0b49 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dev": "turbo dev", "lint": "turbo lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", - "clean": "turbo clean && rm -rf node_modules" + "clean": "turbo clean && rm -rf node_modules", + "of:doctor": "pnpm -C packages/server run of:doctor" }, "packageManager": "pnpm@9.14.2", "engines": { diff --git a/packages/core/src/chains.ts b/packages/core/src/chains.ts index 069e538..f12769c 100644 --- a/packages/core/src/chains.ts +++ b/packages/core/src/chains.ts @@ -79,6 +79,42 @@ export const defaultChains: Record = { blockExplorerUrl: 'https://www.okx.com/explorer/xlayer', isEVM: true, }, + // Arbitrum One Mainnet + '42161': { + chainId: 42161, + name: 'Arbitrum One', + network: 'arbitrum', + rpcUrl: process.env.ARBITRUM_RPC_URL || 'https://arb1.arbitrum.io/rpc', + blockExplorerUrl: 'https://arbiscan.io', + isEVM: true, + }, + // Optimism Mainnet + '10': { + chainId: 10, + name: 'Optimism', + network: 'optimism', + rpcUrl: process.env.OPTIMISM_RPC_URL || 'https://mainnet.optimism.io', + blockExplorerUrl: 'https://optimistic.etherscan.io', + isEVM: true, + }, + // BNB Chain Mainnet + '56': { + chainId: 56, + name: 'BNB Chain', + network: 'bnb', + rpcUrl: process.env.BNB_RPC_URL || 'https://bsc-dataseed1.binance.org', + blockExplorerUrl: 'https://bscscan.com', + isEVM: true, + }, + // Linea Mainnet + '59144': { + chainId: 59144, + name: 'Linea', + network: 'linea', + rpcUrl: process.env.LINEA_RPC_URL || 'https://rpc.linea.build', + blockExplorerUrl: 'https://lineascan.build', + isEVM: true, + }, // Solana Mainnet solana: { chainId: 'solana', @@ -145,6 +181,42 @@ export const defaultChains: Record = { blockExplorerUrl: 'https://www.okx.com/explorer/xlayer-test', isEVM: true, }, + // Arbitrum Sepolia Testnet + '421614': { + chainId: 421614, + name: 'Arbitrum Sepolia', + network: 'arbitrum-sepolia', + rpcUrl: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc', + blockExplorerUrl: 'https://sepolia.arbiscan.io', + isEVM: true, + }, + // Optimism Sepolia Testnet + '11155420': { + chainId: 11155420, + name: 'Optimism Sepolia', + network: 'optimism-sepolia', + rpcUrl: process.env.OPTIMISM_SEPOLIA_RPC_URL || 'https://sepolia.optimism.io', + blockExplorerUrl: 'https://sepolia-optimism.etherscan.io', + isEVM: true, + }, + // BNB Chain Testnet + '97': { + chainId: 97, + name: 'BNB Chain Testnet', + network: 'bnb-testnet', + rpcUrl: process.env.BNB_TESTNET_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545', + blockExplorerUrl: 'https://testnet.bscscan.com', + isEVM: true, + }, + // Linea Goerli Testnet + '59140': { + chainId: 59140, + name: 'Linea Goerli', + network: 'linea-goerli', + rpcUrl: process.env.LINEA_GOERLI_RPC_URL || 'https://rpc.goerli.linea.build', + blockExplorerUrl: 'https://goerli.lineascan.build', + isEVM: true, + }, // Solana Devnet 'solana-devnet': { chainId: 'solana-devnet', @@ -191,6 +263,10 @@ export const networkToChainId: Record = { polygon: 137, sei: 1329, xlayer: 196, + arbitrum: 42161, + optimism: 10, + bnb: 56, + linea: 59144, solana: 'solana', 'solana-mainnet': 'solana', // Alias for compatibility // Testnets @@ -200,6 +276,10 @@ export const networkToChainId: Record = { 'sei-testnet': 1328, sepolia: 11155111, 'xlayer-testnet': 195, + 'arbitrum-sepolia': 421614, + 'optimism-sepolia': 11155420, + 'bnb-testnet': 97, + 'linea-goerli': 59140, 'solana-devnet': 'solana-devnet', }; @@ -216,6 +296,10 @@ export const chainIdToNetwork: Record = { 137: 'polygon', 1329: 'sei', 196: 'xlayer', + 42161: 'arbitrum', + 10: 'optimism', + 56: 'bnb', + 59144: 'linea', solana: 'solana', 'solana-mainnet': 'solana', // Alias // Testnets @@ -225,6 +309,10 @@ export const chainIdToNetwork: Record = { 1328: 'sei-testnet', 11155111: 'sepolia', 195: 'xlayer-testnet', + 421614: 'arbitrum-sepolia', + 11155420: 'optimism-sepolia', + 97: 'bnb-testnet', + 59140: 'linea-goerli', 'solana-devnet': 'solana-devnet', }; @@ -279,6 +367,10 @@ export const productionChains = [ 137, // Polygon 1329, // Sei 196, // XLayer + 42161, // Arbitrum + 10, // Optimism + 56, // BNB Chain + 59144, // Linea 'solana', ] as const; @@ -292,6 +384,10 @@ export const testChains = [ 1328, // Sei Testnet 11155111, // Sepolia 195, // XLayer Testnet + 421614, // Arbitrum Sepolia + 11155420, // Optimism Sepolia + 97, // BNB Chain Testnet + 59140, // Linea Goerli 'solana-devnet', ] as const; @@ -320,6 +416,10 @@ export const networkToCaip2: Record = { polygon: 'eip155:137', sei: 'eip155:1329', xlayer: 'eip155:196', + arbitrum: 'eip155:42161', + optimism: 'eip155:10', + bnb: 'eip155:56', + linea: 'eip155:59144', // Solana solana: `solana:${solanaGenesisHashes.mainnet}`, // EVM Testnets @@ -329,6 +429,10 @@ export const networkToCaip2: Record = { 'sei-testnet': 'eip155:1328', sepolia: 'eip155:11155111', 'xlayer-testnet': 'eip155:195', + 'arbitrum-sepolia': 'eip155:421614', + 'optimism-sepolia': 'eip155:11155420', + 'bnb-testnet': 'eip155:97', + 'linea-goerli': 'eip155:59140', 'solana-devnet': `solana:${solanaGenesisHashes.devnet}`, }; @@ -345,6 +449,10 @@ export const caip2ToNetwork: Record = { 'eip155:137': 'polygon', 'eip155:1329': 'sei', 'eip155:196': 'xlayer', + 'eip155:42161': 'arbitrum', + 'eip155:10': 'optimism', + 'eip155:56': 'bnb', + 'eip155:59144': 'linea', // Solana [`solana:${solanaGenesisHashes.mainnet}`]: 'solana', // EVM Testnets @@ -354,6 +462,10 @@ export const caip2ToNetwork: Record = { 'eip155:1328': 'sei-testnet', 'eip155:11155111': 'sepolia', 'eip155:195': 'xlayer-testnet', + 'eip155:421614': 'arbitrum-sepolia', + 'eip155:11155420': 'optimism-sepolia', + 'eip155:97': 'bnb-testnet', + 'eip155:59140': 'linea-goerli', [`solana:${solanaGenesisHashes.devnet}`]: 'solana-devnet', }; diff --git a/packages/core/src/erc3009.ts b/packages/core/src/erc3009.ts index e35b4a2..31b5974 100644 --- a/packages/core/src/erc3009.ts +++ b/packages/core/src/erc3009.ts @@ -195,9 +195,15 @@ import { privateKeyToAccount } from 'viem/accounts'; import { avalanche, avalancheFuji, + arbitrum, + arbitrumSepolia, base, - baseSepolia, + baseSepolia, + bsc, + bscTestnet, mainnet, + optimism, + optimismSepolia, polygon, polygonAmoy, sepolia, @@ -282,6 +288,23 @@ const xlayerTestnet = defineChain({ testnet: true, }); +const linea = defineChain({ + id: 59144, + name: 'Linea', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.linea.build'] } }, + blockExplorers: { default: { name: 'LineaScan', url: 'https://lineascan.build' } }, +}); + +const lineaGoerli = defineChain({ + id: 59140, + name: 'Linea Goerli', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.goerli.linea.build'] } }, + blockExplorers: { default: { name: 'LineaScan Goerli', url: 'https://goerli.lineascan.build' } }, + testnet: true, +}); + /** * Chain configuration for settlement */ @@ -295,6 +318,10 @@ const chainConfigs: Record = { 137: { chain: polygon, rpcUrl: process.env.POLYGON_RPC_URL || 'https://polygon-rpc.com' }, 1329: { chain: sei, rpcUrl: process.env.SEI_RPC_URL || 'https://evm-rpc.sei-apis.com' }, 196: { chain: xlayer, rpcUrl: process.env.XLAYER_RPC_URL || 'https://rpc.xlayer.tech' }, + 42161: { chain: arbitrum, rpcUrl: process.env.ARBITRUM_RPC_URL || 'https://arb1.arbitrum.io/rpc' }, + 10: { chain: optimism, rpcUrl: process.env.OPTIMISM_RPC_URL || 'https://mainnet.optimism.io' }, + 56: { chain: bsc, rpcUrl: process.env.BNB_RPC_URL || 'https://bsc-dataseed1.binance.org' }, + 59144: { chain: linea, rpcUrl: process.env.LINEA_RPC_URL || 'https://rpc.linea.build' }, // Testnets 43113: { chain: avalancheFuji, rpcUrl: process.env.AVALANCHE_FUJI_RPC_URL || 'https://api.avax-test.network/ext/bc/C/rpc' }, 84532: { chain: baseSepolia, rpcUrl: process.env.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org' }, @@ -302,6 +329,10 @@ const chainConfigs: Record = { 1328: { chain: seiTestnet, rpcUrl: process.env.SEI_TESTNET_RPC_URL || 'https://evm-rpc-testnet.sei-apis.com' }, 11155111: { chain: sepolia, rpcUrl: process.env.SEPOLIA_RPC_URL || 'https://rpc.sepolia.org' }, 195: { chain: xlayerTestnet, rpcUrl: process.env.XLAYER_TESTNET_RPC_URL || 'https://testrpc.xlayer.tech' }, + 421614: { chain: arbitrumSepolia, rpcUrl: process.env.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' }, + 11155420: { chain: optimismSepolia, rpcUrl: process.env.OPTIMISM_SEPOLIA_RPC_URL || 'https://sepolia.optimism.io' }, + 97: { chain: bscTestnet, rpcUrl: process.env.BNB_TESTNET_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545' }, + 59140: { chain: lineaGoerli, rpcUrl: process.env.LINEA_GOERLI_RPC_URL || 'https://rpc.goerli.linea.build' }, }; export interface ERC3009Authorization { @@ -313,12 +344,20 @@ export interface ERC3009Authorization { nonce: Hex; } +// Import NonceValidator type from types.ts to avoid duplication +import type { NonceValidator } from './types.js'; + export interface SettlementParams { chainId: number; tokenAddress: Address; authorization: ERC3009Authorization; signature: Hex; facilitatorPrivateKey: Hex; + /** + * Optional external nonce validator for persistent tracking + * If not provided, falls back to in-memory validation only + */ + nonceValidator?: NonceValidator; } export interface SettlementResult { @@ -334,7 +373,7 @@ export interface SettlementResult { export async function executeERC3009Settlement( params: SettlementParams ): Promise { - const { chainId, tokenAddress, authorization, signature, facilitatorPrivateKey } = params; + const { chainId, tokenAddress, authorization, signature, facilitatorPrivateKey, nonceValidator } = params; console.log('[ERC3009Settlement] Starting settlement:', { chainId, @@ -345,21 +384,53 @@ export async function executeERC3009Settlement( nonce: authorization.nonce, validAfter: authorization.validAfter, validBefore: authorization.validBefore, + hasPersistentValidator: !!nonceValidator, }); - // Deduplication: prevent double-submission of same nonce - if (!tryAcquireNonce(chainId, authorization.from, authorization.nonce)) { - console.warn('[ERC3009Settlement] DUPLICATE BLOCKED: Nonce already being processed:', authorization.nonce); + // Nonce validation with two-tier approach: + // 1. If external validator provided (server with database), use it for persistent tracking + // 2. Otherwise, fall back to in-memory cache only + let nonceAcquired = false; + let nonceRejectionReason: string | undefined; + + if (nonceValidator) { + // Use external persistent validator (L1 cache + L2 database) + const result = await nonceValidator.tryAcquire({ + nonce: authorization.nonce, + from: authorization.from, + chainId, + expiresAt: authorization.validBefore, + }); + nonceAcquired = result.acquired; + nonceRejectionReason = result.reason; + } else { + // Fall back to in-memory only (L1 cache) + nonceAcquired = tryAcquireNonce(chainId, authorization.from, authorization.nonce); + if (!nonceAcquired) { + nonceRejectionReason = 'This authorization is already being processed (in-memory cache)'; + } + } + + if (!nonceAcquired) { + console.warn('[ERC3009Settlement] DUPLICATE BLOCKED:', { + nonce: authorization.nonce, + reason: nonceRejectionReason, + }); return { success: false, - errorMessage: 'Duplicate submission: this authorization is already being processed', + errorMessage: nonceRejectionReason || 'Duplicate submission: this authorization is already being processed', }; } // Get chain config const config = chainConfigs[chainId]; if (!config) { - releaseNonce(chainId, authorization.from, authorization.nonce); + // Release nonce on early failure (before on-chain submission) + if (nonceValidator?.release) { + nonceValidator.release(authorization.nonce, authorization.from, chainId); + } else { + releaseNonce(chainId, authorization.from, authorization.nonce); + } return { success: false, errorMessage: `Unsupported chain ID: ${chainId}`, @@ -416,7 +487,12 @@ export async function executeERC3009Settlement( if (ethBalance < 100000n * gasPrice) { console.error('[ERC3009Settlement] Insufficient ETH for gas!'); - releaseNonce(chainId, authorization.from, authorization.nonce); + // Release nonce on early failure (before on-chain submission) + if (nonceValidator?.release) { + nonceValidator.release(authorization.nonce, authorization.from, chainId); + } else { + releaseNonce(chainId, authorization.from, authorization.nonce); + } return { success: false, errorMessage: 'Facilitator has insufficient ETH for gas', @@ -514,6 +590,12 @@ export async function executeERC3009Settlement( if (receipt.status === 'success') { console.log('[ERC3009Settlement] SUCCESS!'); + + // Mark nonce as successfully settled with transaction hash + if (nonceValidator?.markSettled) { + nonceValidator.markSettled(authorization.nonce, authorization.from, chainId, hash); + } + return { success: true, transactionHash: hash, @@ -556,7 +638,13 @@ export async function executeERC3009Settlement( console.error('[ERC3009Settlement] Error:', error); const errMsg = error instanceof Error ? error.message : 'Unknown error during settlement'; // Release nonce on error so user can retry with same auth if it wasn't submitted - releaseNonce(chainId, authorization.from, authorization.nonce); + // SECURITY NOTE: We only release from cache, NOT from database + // This prevents retry after database has recorded the nonce + if (nonceValidator?.release) { + nonceValidator.release(authorization.nonce, authorization.from, chainId); + } else { + releaseNonce(chainId, authorization.from, authorization.nonce); + } return { success: false, errorMessage: errMsg, diff --git a/packages/core/src/facilitator.ts b/packages/core/src/facilitator.ts index 4679cba..cc6981e 100644 --- a/packages/core/src/facilitator.ts +++ b/packages/core/src/facilitator.ts @@ -2,9 +2,15 @@ import { createPublicClient, http, type Hex, type Address, type Chain, defineCha import { avalanche, avalancheFuji, + arbitrum, + arbitrumSepolia, base, baseSepolia, + bsc, + bscTestnet, mainnet, + optimism, + optimismSepolia, polygon, polygonAmoy, sepolia, @@ -76,6 +82,23 @@ const xlayerTestnet = defineChain({ testnet: true, }); +const linea = defineChain({ + id: 59144, + name: 'Linea', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.linea.build'] } }, + blockExplorers: { default: { name: 'LineaScan', url: 'https://lineascan.build' } }, +}); + +const lineaGoerli = defineChain({ + id: 59140, + name: 'Linea Goerli', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.goerli.linea.build'] } }, + blockExplorers: { default: { name: 'LineaScan Goerli', url: 'https://goerli.lineascan.build' } }, + testnet: true, +}); + /** * Chain ID to viem chain mapping (EVM chains only) */ @@ -89,6 +112,10 @@ const viemChains: Record = { 137: polygon, 1329: sei, 196: xlayer, + 42161: arbitrum, + 10: optimism, + 56: bsc, + 59144: linea, // Testnets 43113: avalancheFuji, 84532: baseSepolia, @@ -96,6 +123,10 @@ const viemChains: Record = { 1328: seiTestnet, 11155111: sepolia, 195: xlayerTestnet, + 421614: arbitrumSepolia, + 11155420: optimismSepolia, + 97: bscTestnet, + 59140: lineaGoerli, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -448,6 +479,7 @@ export class Facilitator { }, signature: signature as Hex, facilitatorPrivateKey: privateKey as Hex, + nonceValidator: this.config.nonceValidator, // Inject persistent nonce validator }); if (result.success) { diff --git a/packages/core/src/tokens.ts b/packages/core/src/tokens.ts index f44d437..4677680 100644 --- a/packages/core/src/tokens.ts +++ b/packages/core/src/tokens.ts @@ -10,6 +10,14 @@ export const knownTokens: Record> = { 84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia 1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum 11155111: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', // Sepolia + 42161: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum One + 421614: '0x75faf114eafb1BDbe2F0316DF893fd58cE9AF907', // Arbitrum Sepolia + 10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism + 11155420: '0x5fd84259d66Cd46123540766Be93DFE6D43130D7', // Optimism Sepolia + 56: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BNB Chain + 97: '0x64544969ed7EBf5f083679233325356EbE738930', // BNB Chain Testnet + 59144: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', // Linea + 59140: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', // Linea Goerli solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // Solana Mainnet 'solana-devnet': '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', // Solana Devnet }, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 784346c..34aca3b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -27,6 +27,21 @@ export interface TokenConfig { chainId: ChainId; } +/** + * Optional nonce validator interface for persistent replay protection + * Allows external systems (like the server) to inject persistent nonce tracking + */ +export interface NonceValidator { + tryAcquire(params: { + nonce: string; + from: string; + chainId: number; + expiresAt: number; + }): { acquired: boolean; reason?: string } | Promise<{ acquired: boolean; reason?: string }>; + release?(nonce: string, from: string, chainId: number): void; + markSettled?(nonce: string, from: string, chainId: number, txHash: string): void; +} + /** * Facilitator configuration */ @@ -40,6 +55,8 @@ export interface FacilitatorConfig { supportedTokens: TokenConfig[]; createdAt: Date; updatedAt: Date; + /** Optional nonce validator for persistent replay protection */ + nonceValidator?: NonceValidator; } /** diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index be5552f..f2c250f 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -11,6 +11,7 @@ "test:custom": "vitest run --testNamePattern='custom domain'", "test:solana": "vitest run src/solana-real.test.ts", "test:base": "vitest run src/base-real.test.ts", + "test:arbitrum": "vitest run src/arbitrum-real.test.ts", "test:real": "vitest run src/*-real.test.ts", "test:all": "vitest run" }, diff --git a/packages/integration-tests/src/arbitrum-real.test.ts b/packages/integration-tests/src/arbitrum-real.test.ts new file mode 100644 index 0000000..5ba388a --- /dev/null +++ b/packages/integration-tests/src/arbitrum-real.test.ts @@ -0,0 +1,356 @@ +/** + * Real Arbitrum (EVM) transaction tests + * + * These tests create and submit REAL transactions on Arbitrum mainnet. + * Only run these manually with a funded wallet! + * + * Usage: pnpm test:arbitrum + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { OpenFacilitator } from '@openfacilitator/sdk'; +import { TEST_CONFIG } from './setup'; +import { + createPublicClient, + createWalletClient, + http, + type Hex, + type Address, + toHex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { arbitrum } from 'viem/chains'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Arbitrum mainnet USDC +const ARBITRUM_USDC = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Address; +const ARBITRUM_CHAIN_ID = 42161; +const ARBITRUM_RPC = 'https://arb1.arbitrum.io/rpc'; + +// Test amount: $0.01 USDC (10000 micro-units, USDC has 6 decimals) +const TEST_AMOUNT = BigInt(10000); + +// ERC-20 balanceOf ABI +const BALANCE_OF_ABI = [ + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const; + +/** + * Load EVM private key from environment or file + */ +function loadEVMPrivateKey(): Hex | null { + // Try environment variable first + if (process.env.TEST_EVM_PRIVATE_KEY) { + const key = process.env.TEST_EVM_PRIVATE_KEY; + return (key.startsWith('0x') ? key : `0x${key}`) as Hex; + } + + // Try loading from a local file + try { + const keyPath = path.join(os.homedir(), '.config', 'evm', 'private_key'); + const key = fs.readFileSync(keyPath, 'utf-8').trim(); + return (key.startsWith('0x') ? key : `0x${key}`) as Hex; + } catch { + // File doesn't exist + } + + return null; +} + +/** + * Get USDC balance on Arbitrum + */ +async function getUSDCBalance(address: Address): Promise { + const client = createPublicClient({ + chain: arbitrum, + transport: http(ARBITRUM_RPC), + }); + + try { + const balance = await client.readContract({ + address: ARBITRUM_USDC, + abi: BALANCE_OF_ABI, + functionName: 'balanceOf', + args: [address], + }); + return balance as bigint; + } catch { + return BigInt(0); + } +} + +/** + * Generate a random nonce for ERC-3009 + */ +function generateNonce(): Hex { + const randomBytes = new Uint8Array(32); + crypto.getRandomValues(randomBytes); + return toHex(randomBytes); +} + +/** + * Sign an ERC-3009 transferWithAuthorization + */ +async function signTransferAuthorization( + privateKey: Hex, + params: { + to: Address; + value: bigint; + validAfter: number; + validBefore: number; + nonce: Hex; + } +): Promise<{ + authorization: { + from: string; + to: string; + value: string; + validAfter: number; + validBefore: number; + nonce: Hex; + }; + signature: Hex +}> { + const account = privateKeyToAccount(privateKey); + const from = account.address; + + // EIP-712 domain for USDC on Arbitrum + const domain = { + name: 'USD Coin', + version: '2', + chainId: ARBITRUM_CHAIN_ID, + verifyingContract: ARBITRUM_USDC, + }; + + // ERC-3009 TransferWithAuthorization types + const types = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }; + + const message = { + from, + to: params.to, + value: params.value, + validAfter: BigInt(params.validAfter), + validBefore: BigInt(params.validBefore), + nonce: params.nonce, + }; + + // Create wallet client for signing + const walletClient = createWalletClient({ + account, + chain: arbitrum, + transport: http(ARBITRUM_RPC), + }); + + // Sign the typed data + const signature = await walletClient.signTypedData({ + domain, + types, + primaryType: 'TransferWithAuthorization', + message, + }); + + const authorization = { + from, + to: params.to, + value: params.value.toString(), + validAfter: params.validAfter, + validBefore: params.validBefore, + nonce: params.nonce, + }; + + return { authorization, signature }; +} + +describe('arbitrum real transactions', () => { + let facilitator: OpenFacilitator; + let privateKey: Hex | null; + let walletAddress: Address; + let payTo: string | undefined; + + beforeAll(async () => { + // Load EVM private key + privateKey = loadEVMPrivateKey(); + if (!privateKey) { + console.log('⚠️ No EVM private key found'); + console.log(' Set TEST_EVM_PRIVATE_KEY env var or create ~/.config/evm/private_key'); + return; + } + + const account = privateKeyToAccount(privateKey); + walletAddress = account.address; + console.log(`📝 Loaded wallet: ${walletAddress}`); + + // Check USDC balance + const balance = await getUSDCBalance(walletAddress); + console.log(`💰 USDC balance: ${Number(balance) / 1e6} USDC`); + + if (balance < TEST_AMOUNT) { + console.log(`⚠️ Insufficient balance for tests (need ${Number(TEST_AMOUNT) / 1e6} USDC)`); + return; + } + + // Initialize facilitator + facilitator = new OpenFacilitator({ + url: TEST_CONFIG.FREE_ENDPOINT, + }); + + // Get supported info to find payTo address + const supported = await facilitator.supported(); + const arbitrumKind = supported.kinds.find(k => + k.network === 'arbitrum' || k.network === 'eip155:42161' + ); + + // Get payTo from signers (uses wildcard key eip155:*) + const signerAddr = supported.signers?.['eip155:*']?.[0]; + + // If signer looks like an address, use it; otherwise use our own wallet + if (signerAddr && signerAddr.startsWith('0x') && signerAddr.length === 42) { + payTo = signerAddr; + } else { + // Use our own address for testing (self-transfer) + payTo = walletAddress; + console.log(`⚠️ Using self-transfer for test (signer: ${signerAddr})`); + } + + console.log(`📬 Pay to: ${payTo}`); + }); + + it('should verify a real signed authorization', async () => { + if (!privateKey || !payTo) { + console.log('Skipping: no private key or payTo address'); + return; + } + + const now = Math.floor(Date.now() / 1000); + const nonce = generateNonce(); + + // Create signed authorization + const { authorization, signature } = await signTransferAuthorization(privateKey, { + to: payTo as Address, + value: TEST_AMOUNT, + validAfter: now - 60, // Valid 1 minute ago + validBefore: now + 3600, // Valid for 1 hour + nonce, + }); + + console.log(`✍️ Created authorization with nonce: ${nonce.slice(0, 20)}...`); + + // Create x402 payment payload + // Map ERC-3009 authorization format to PaymentAuthorization format + const paymentPayload = { + x402Version: 1 as const, + scheme: 'exact', + network: 'eip155:42161', + payload: { + signature, + authorization: { + from: authorization.from, + to: authorization.to, + amount: authorization.value, // Map 'value' to 'amount' + asset: ARBITRUM_USDC, + nonce: authorization.nonce, + validAfter: authorization.validAfter, + validBefore: authorization.validBefore, + }, + }, + }; + + const requirements = { + scheme: 'exact', + network: 'eip155:42161', + maxAmountRequired: TEST_AMOUNT.toString(), + asset: ARBITRUM_USDC, + payTo, + }; + + // Verify + const result = await facilitator.verify(paymentPayload, requirements); + + console.log('📋 Verify result:', result); + + expect(result).toBeDefined(); + }); + + it('should settle a real transaction (WARNING: spends real USDC)', async () => { + if (!privateKey || !payTo) { + console.log('Skipping: no private key or payTo address'); + return; + } + + const now = Math.floor(Date.now() / 1000); + const nonce = generateNonce(); + + // Create signed authorization + const { authorization, signature } = await signTransferAuthorization(privateKey, { + to: payTo as Address, + value: TEST_AMOUNT, + validAfter: now - 60, + validBefore: now + 3600, + nonce, + }); + + console.log(`✍️ Created authorization with nonce: ${nonce.slice(0, 20)}...`); + + // Create x402 payment payload + // Map ERC-3009 authorization format to PaymentAuthorization format + const paymentPayload = { + x402Version: 1 as const, + scheme: 'exact', + network: 'eip155:42161', + payload: { + signature, + authorization: { + from: authorization.from, + to: authorization.to, + amount: authorization.value, // Map 'value' to 'amount' + asset: ARBITRUM_USDC, + nonce: authorization.nonce, + validAfter: authorization.validAfter, + validBefore: authorization.validBefore, + }, + }, + }; + + const requirements = { + scheme: 'exact', + network: 'eip155:42161', + maxAmountRequired: TEST_AMOUNT.toString(), + asset: ARBITRUM_USDC, + payTo, + }; + + // Settle (THIS WILL SPEND REAL USDC!) + console.log('⚠️ Settling real transaction...'); + const result = await facilitator.settle(paymentPayload, requirements); + + console.log('💸 Settle result:', result); + + if (result.success) { + console.log(`✅ Transaction hash: ${result.transactionHash}`); + console.log(` View: https://arbiscan.io/tx/${result.transactionHash}`); + } + + expect(result).toBeDefined(); + }); +}); + +// Run these tests with: pnpm test:arbitrum +// To enable the tests, change describe.skip to describe + diff --git a/packages/sdk/src/networks.ts b/packages/sdk/src/networks.ts index 1835fbc..20eb13b 100644 --- a/packages/sdk/src/networks.ts +++ b/packages/sdk/src/networks.ts @@ -9,6 +9,10 @@ export const NETWORKS: NetworkInfo[] = [ { v1Id: 'iotex', v2Id: 'eip155:4689', name: 'IoTeX', type: 'evm', chainId: 4689, testnet: false }, { v1Id: 'peaq', v2Id: 'eip155:3338', name: 'Peaq', type: 'evm', chainId: 3338, testnet: false }, { v1Id: 'xlayer', v2Id: 'eip155:196', name: 'X Layer', type: 'evm', chainId: 196, testnet: false }, + { v1Id: 'arbitrum', v2Id: 'eip155:42161', name: 'Arbitrum', type: 'evm', chainId: 42161, testnet: false }, + { v1Id: 'optimism', v2Id: 'eip155:10', name: 'Optimism', type: 'evm', chainId: 10, testnet: false }, + { v1Id: 'bnb', v2Id: 'eip155:56', name: 'BNB Chain', type: 'evm', chainId: 56, testnet: false }, + { v1Id: 'linea', v2Id: 'eip155:59144', name: 'Linea', type: 'evm', chainId: 59144, testnet: false }, // EVM Testnets { v1Id: 'base-sepolia', v2Id: 'eip155:84532', name: 'Base Sepolia', type: 'evm', chainId: 84532, testnet: true }, @@ -16,6 +20,10 @@ export const NETWORKS: NetworkInfo[] = [ { v1Id: 'avalanche-fuji', v2Id: 'eip155:43113', name: 'Avalanche Fuji', type: 'evm', chainId: 43113, testnet: true }, { v1Id: 'sei-testnet', v2Id: 'eip155:1328', name: 'Sei Testnet', type: 'evm', chainId: 1328, testnet: true }, { v1Id: 'xlayer-testnet', v2Id: 'eip155:195', name: 'X Layer Testnet', type: 'evm', chainId: 195, testnet: true }, + { v1Id: 'arbitrum-sepolia', v2Id: 'eip155:421614', name: 'Arbitrum Sepolia', type: 'evm', chainId: 421614, testnet: true }, + { v1Id: 'optimism-sepolia', v2Id: 'eip155:11155420', name: 'Optimism Sepolia', type: 'evm', chainId: 11155420, testnet: true }, + { v1Id: 'bnb-testnet', v2Id: 'eip155:97', name: 'BNB Chain Testnet', type: 'evm', chainId: 97, testnet: true }, + { v1Id: 'linea-goerli', v2Id: 'eip155:59140', name: 'Linea Goerli', type: 'evm', chainId: 59140, testnet: true }, // Solana { v1Id: 'solana', v2Id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana', type: 'solana', testnet: false }, diff --git a/packages/server/package.json b/packages/server/package.json index 82abbbf..248e7de 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,7 +20,8 @@ "lint": "tsc --noEmit", "seed": "tsx scripts/seed-transactions.ts", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "of:doctor": "tsx scripts/doctor.ts" }, "dependencies": { "@openfacilitator/core": "workspace:*", diff --git a/packages/server/scripts/doctor.ts b/packages/server/scripts/doctor.ts new file mode 100644 index 0000000..7082b87 --- /dev/null +++ b/packages/server/scripts/doctor.ts @@ -0,0 +1,484 @@ +/** + * OpenFacilitator "doctor" script + * + * Self-hosting diagnostics (env + DB + RPC connectivity). + * + * Usage: + * pnpm -C packages/server run of:doctor + * pnpm -C packages/server run of:doctor -- --json + * + * From repo root: + * pnpm of:doctor + * + * Notes: + * - Non-invasive: does NOT create a database file. If the DB doesn't exist yet, + * we warn and skip table/migration checks. + * - Does not print secrets; only indicates whether values are set. + */ +import 'dotenv/config'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; + +type CheckStatus = 'pass' | 'warn' | 'fail'; + +interface Check { + name: string; + status: CheckStatus; + message: string; + fix?: string; +} + +interface CategoryResult { + category: string; + checks: Check[]; +} + +interface DoctorReport { + ok: boolean; + summary: { passed: number; warnings: number; failures: number }; + meta: { + timestamp: string; + cwd: string; + node: string; + }; + categories: CategoryResult[]; +} + +function parseArgs(argv: string[]): { json: boolean; timeoutMs: number } { + const json = argv.includes('--json'); + const timeoutFlag = argv.find((a) => a.startsWith('--timeout-ms=')); + const timeoutMs = timeoutFlag ? Number(timeoutFlag.split('=')[1]) : 5000; + return { json, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 5000 }; +} + +function formatMs(ms: number): string { + if (!Number.isFinite(ms)) return 'n/a'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function summarize(categories: CategoryResult[]): DoctorReport['summary'] { + let passed = 0; + let warnings = 0; + let failures = 0; + + for (const cat of categories) { + for (const check of cat.checks) { + if (check.status === 'pass') passed++; + else if (check.status === 'warn') warnings++; + else failures++; + } + } + + return { passed, warnings, failures }; +} + +function redactValue(value: string | undefined): string { + return value && value.trim().length > 0 ? '(set)' : '(not set)'; +} + +function envOrDefault(key: string, fallback: string): { value: string; isDefault: boolean } { + const raw = process.env[key]; + if (raw && raw.trim().length > 0) return { value: raw, isDefault: false }; + return { value: fallback, isDefault: true }; +} + +async function checkJsonRpc( + url: string, + payload: unknown, + timeoutMs: number +): Promise<{ status: CheckStatus; message: string }> { + const started = Date.now(); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + const elapsed = Date.now() - started; + + if (!res.ok) { + return { status: 'fail', message: `HTTP ${res.status} (${formatMs(elapsed)})` }; + } + + // Try parse JSON (some providers return HTML on auth errors) + const text = await res.text(); + try { + JSON.parse(text); + } catch { + return { status: 'fail', message: `Non-JSON response (${formatMs(elapsed)})` }; + } + + if (elapsed >= 1000) return { status: 'warn', message: `OK but slow (${formatMs(elapsed)})` }; + return { status: 'pass', message: `OK (${formatMs(elapsed)})` }; + } catch (err) { + const elapsed = Date.now() - started; + const msg = err instanceof Error ? err.message : String(err); + return { status: 'fail', message: `${msg} (${formatMs(elapsed)})` }; + } +} + +function getRepoRelative(p: string): string { + // Best-effort nice output; falls back to absolute + const cwd = process.cwd(); + if (p.startsWith(cwd)) return path.relative(cwd, p) || '.'; + return p; +} + +function checkEnvironment(): CategoryResult { + const checks: Check[] = []; + + // Core auth/encryption + const authSecret = process.env.BETTER_AUTH_SECRET; + const encryptionSecret = process.env.ENCRYPTION_SECRET; + if ((!authSecret || authSecret.trim().length === 0) && (!encryptionSecret || encryptionSecret.trim().length === 0)) { + checks.push({ + name: 'BETTER_AUTH_SECRET / ENCRYPTION_SECRET', + status: 'fail', + message: 'Neither secret is set', + fix: 'Set BETTER_AUTH_SECRET (recommended) or ENCRYPTION_SECRET in packages/server/.env', + }); + } else { + checks.push({ + name: 'BETTER_AUTH_SECRET / ENCRYPTION_SECRET', + status: 'pass', + message: authSecret && authSecret.trim().length > 0 ? 'BETTER_AUTH_SECRET is set' : 'ENCRYPTION_SECRET is set', + }); + } + + // URLs / routing + const betterAuthUrl = envOrDefault('BETTER_AUTH_URL', 'http://localhost:5002'); + checks.push({ + name: 'BETTER_AUTH_URL', + status: betterAuthUrl.isDefault ? 'warn' : 'pass', + message: betterAuthUrl.isDefault ? `Not set (default: ${betterAuthUrl.value})` : 'Set', + }); + + const dashboardUrl = process.env.DASHBOARD_URL; + checks.push({ + name: 'DASHBOARD_URL', + status: dashboardUrl && dashboardUrl.trim().length > 0 ? 'pass' : 'warn', + message: dashboardUrl && dashboardUrl.trim().length > 0 ? 'Set' : 'Not set (may cause CORS issues)', + fix: dashboardUrl && dashboardUrl.trim().length > 0 ? undefined : 'Set DASHBOARD_URL (e.g., http://localhost:3000)', + }); + + // DB path + const dbPath = envOrDefault('DATABASE_PATH', './data/openfacilitator.db'); + checks.push({ + name: 'DATABASE_PATH', + status: dbPath.isDefault ? 'warn' : 'pass', + message: dbPath.isDefault ? `Not set (default: ${dbPath.value})` : `Set (${dbPath.value})`, + }); + + // Stats endpoints require treasury addresses (stats router is always mounted) + const treasuryBase = process.env.TREASURY_BASE; + const treasurySolana = process.env.TREASURY_SOLANA; + checks.push({ + name: 'TREASURY_BASE', + status: treasuryBase && treasuryBase.trim().length > 0 ? 'pass' : 'warn', + message: treasuryBase && treasuryBase.trim().length > 0 ? 'Set' : 'Not set (required for /stats/base)', + fix: treasuryBase && treasuryBase.trim().length > 0 ? undefined : 'Set TREASURY_BASE to your Base treasury address', + }); + checks.push({ + name: 'TREASURY_SOLANA', + status: treasurySolana && treasurySolana.trim().length > 0 ? 'pass' : 'warn', + message: treasurySolana && treasurySolana.trim().length > 0 ? 'Set' : 'Not set (required for /stats/solana)', + fix: treasurySolana && treasurySolana.trim().length > 0 ? undefined : 'Set TREASURY_SOLANA to your Solana treasury address', + }); + + // Misc: access token secret is optional (has a fallback), but warn for production + const accessTokenSecret = process.env.ACCESS_TOKEN_SECRET; + const encryptionKey = process.env.ENCRYPTION_KEY; + checks.push({ + name: 'ACCESS_TOKEN_SECRET', + status: accessTokenSecret && accessTokenSecret.trim().length > 0 ? 'pass' : 'warn', + message: accessTokenSecret && accessTokenSecret.trim().length > 0 ? 'Set' : 'Not set (server will derive a default)', + fix: accessTokenSecret && accessTokenSecret.trim().length > 0 ? undefined : 'Set ACCESS_TOKEN_SECRET for stable access token signing', + }); + checks.push({ + name: 'ENCRYPTION_KEY', + status: encryptionKey && encryptionKey.trim().length > 0 ? 'pass' : 'warn', + message: encryptionKey && encryptionKey.trim().length > 0 ? 'Set' : 'Not set (used to derive access token secret fallback)', + fix: encryptionKey && encryptionKey.trim().length > 0 ? undefined : 'Set ENCRYPTION_KEY (recommended for production)', + }); + + // Optional integrations (no failures) + checks.push({ + name: 'RAILWAY_* (optional)', + status: 'pass', + message: `RAILWAY_TOKEN=${redactValue(process.env.RAILWAY_TOKEN)}, RAILWAY_PROJECT_ID=${redactValue(process.env.RAILWAY_PROJECT_ID)}`, + }); + + checks.push({ + name: 'FREE_FACILITATOR_* (optional)', + status: 'pass', + message: `FREE_FACILITATOR_EVM_KEY=${redactValue(process.env.FREE_FACILITATOR_EVM_KEY)}, FREE_FACILITATOR_SOLANA_KEY=${redactValue(process.env.FREE_FACILITATOR_SOLANA_KEY)}`, + }); + + return { category: 'Environment', checks }; +} + +function checkDatabase(): CategoryResult { + const checks: Check[] = []; + + const configured = process.env.DATABASE_PATH || './data/openfacilitator.db'; + const absolute = path.resolve(configured); + const dir = path.dirname(absolute); + + // Directory checks + if (!fs.existsSync(dir)) { + // Non-invasive: the server will create this directory on first run. + checks.push({ + name: 'Database directory', + status: 'warn', + message: `Not found (${getRepoRelative(dir)})`, + fix: 'Start the server once (it will create the database directory automatically)', + }); + } else { + try { + fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK); + checks.push({ + name: 'Database directory permissions', + status: 'pass', + message: `Readable & writable (${getRepoRelative(dir)})`, + }); + } catch { + checks.push({ + name: 'Database directory permissions', + status: 'fail', + message: `Not readable/writable (${getRepoRelative(dir)})`, + fix: `Ensure the directory is writable: ${dir}`, + }); + } + } + + const exists = fs.existsSync(absolute); + checks.push({ + name: 'Database file', + status: exists ? 'pass' : 'warn', + message: exists ? `Exists (${getRepoRelative(absolute)})` : `Not found (${getRepoRelative(absolute)})`, + fix: exists ? undefined : 'Run the server once to initialize the database and apply migrations', + }); + + if (!exists) { + // Non-invasive: don't create DB file. + return { category: 'Database', checks }; + } + + // Open DB read-only and check migrations + let db: Database.Database | null = null; + try { + db = new Database(absolute, { readonly: true, fileMustExist: true }); + db.prepare('SELECT 1').get(); + checks.push({ name: 'Database connectivity', status: 'pass', message: 'OK' }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + checks.push({ + name: 'Database connectivity', + status: 'fail', + message: msg, + fix: 'Verify DATABASE_PATH and ensure SQLite file is not locked/corrupted', + }); + try { + db?.close(); + } catch { + // ignore + } + return { category: 'Database', checks }; + } + + try { + const hasMigrationsTable = !!db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='migrations'") + .get(); + + if (!hasMigrationsTable) { + checks.push({ + name: 'Migrations table', + status: 'warn', + message: 'Not found (migrations may not have run yet)', + fix: 'Start the server once to create the migrations table and apply migrations', + }); + return { category: 'Database', checks }; + } + + const executed = db.prepare('SELECT name, executed_at FROM migrations ORDER BY id ASC').all() as Array<{ + name: string; + executed_at: string; + }>; + checks.push({ + name: 'Migrations applied', + status: 'pass', + message: `${executed.length} executed`, + }); + + // Compute expected migrations from filesystem (source-of-truth in repo) + // Use script-relative path so it works even if cwd differs. + const migrationsDir = fileURLToPath(new URL('../src/db/migrations', import.meta.url)); + const expected = fs + .readdirSync(migrationsDir) + .filter((f) => f.endsWith('.ts') && f !== 'index.ts') + .map((f) => f.replace(/\.ts$/, '')) + .sort(); + + const executedSet = new Set(executed.map((m) => m.name)); + const pending = expected.filter((m) => !executedSet.has(m)); + + if (pending.length > 0) { + checks.push({ + name: 'Pending migrations', + status: 'warn', + message: `${pending.length} pending: ${pending.join(', ')}`, + fix: 'Start the server to apply pending migrations', + }); + } else { + checks.push({ + name: 'Pending migrations', + status: 'pass', + message: 'None', + }); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + checks.push({ + name: 'Migration inspection', + status: 'warn', + message: msg, + fix: 'If this persists, run the server once and re-run doctor', + }); + } finally { + try { + db?.close(); + } catch { + // ignore + } + } + + return { category: 'Database', checks }; +} + +async function checkNetwork(timeoutMs: number): Promise { + const checks: Check[] = []; + + // Solana endpoints (even if unset, we can report default URLs) + const solMain = envOrDefault('SOLANA_RPC_URL', 'https://api.mainnet-beta.solana.com'); + const solDev = envOrDefault('SOLANA_DEVNET_RPC_URL', 'https://api.devnet.solana.com'); + + const solanaPayload = { jsonrpc: '2.0', id: 1, method: 'getHealth', params: [] }; + const solMainRes = await checkJsonRpc(solMain.value, solanaPayload, timeoutMs); + checks.push({ + name: `Solana Mainnet RPC (${solMain.isDefault ? 'default' : 'env'})`, + status: solMainRes.status, + message: `${solMain.value} → ${solMainRes.message}`, + }); + + const solDevRes = await checkJsonRpc(solDev.value, solanaPayload, timeoutMs); + checks.push({ + name: `Solana Devnet RPC (${solDev.isDefault ? 'default' : 'env'})`, + status: solDevRes.status, + message: `${solDev.value} → ${solDevRes.message}`, + }); + + // EVM endpoints: check only explicitly configured *_RPC_URL values (avoid blasting many public RPCs) + const rpcEnvKeys = Object.keys(process.env) + .filter((k) => k.endsWith('_RPC_URL') && !k.startsWith('SOLANA_')) + .sort(); + + if (rpcEnvKeys.length === 0) { + checks.push({ + name: 'EVM RPCs', + status: 'warn', + message: 'No *_RPC_URL env vars set (will rely on built-in defaults where applicable)', + }); + return { category: 'Network Connectivity', checks }; + } + + const evmPayload = { jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }; + + for (const key of rpcEnvKeys) { + const value = process.env[key]; + if (!value || value.trim().length === 0) { + checks.push({ name: key, status: 'warn', message: 'Set but empty' }); + continue; + } + + const res = await checkJsonRpc(value, evmPayload, timeoutMs); + checks.push({ + name: key, + status: res.status, + message: `${value} → ${res.message}`, + }); + } + + return { category: 'Network Connectivity', checks }; +} + +function printHuman(report: DoctorReport): void { + const { passed, warnings, failures } = report.summary; + + console.log('🩺 OpenFacilitator Doctor'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Node: ${report.meta.node}`); + console.log(`CWD: ${report.meta.cwd}`); + console.log(''); + + for (const category of report.categories) { + console.log(`## ${category.category}`); + for (const check of category.checks) { + const prefix = check.status === 'pass' ? '✅' : check.status === 'warn' ? '⚠️ ' : '❌'; + console.log(`${prefix} ${check.name}: ${check.message}`); + if (check.fix && check.status !== 'pass') { + console.log(` ↳ Fix: ${check.fix}`); + } + } + console.log(''); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Summary: ${passed} passed, ${warnings} warnings, ${failures} failures`); + if (!report.ok) { + console.log('Exit: non-zero (failures detected)'); + } +} + +async function main(): Promise { + const { json, timeoutMs } = parseArgs(process.argv.slice(2)); + + const categories: CategoryResult[] = []; + categories.push(checkEnvironment()); + categories.push(checkDatabase()); + categories.push(await checkNetwork(timeoutMs)); + + const summary = summarize(categories); + const report: DoctorReport = { + ok: summary.failures === 0, + summary, + meta: { + timestamp: new Date().toISOString(), + cwd: process.cwd(), + node: process.version, + }, + categories, + }; + + if (json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printHuman(report); + } + + if (!report.ok) { + process.exitCode = 1; + } +} + +main().catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Doctor failed: ${msg}`); + process.exitCode = 1; +}); + diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index eed6028..33d0b66 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -507,6 +507,23 @@ export function initializeDatabase(dbPath?: string): Database.Database { CREATE INDEX IF NOT EXISTS idx_proxy_urls_facilitator ON proxy_urls(facilitator_id); CREATE INDEX IF NOT EXISTS idx_proxy_urls_slug ON proxy_urls(facilitator_id, slug); + -- Used nonces table (persistent replay attack prevention) + -- SECURITY: This table ensures nonce uniqueness across server restarts + -- Each ERC-3009 authorization can only be settled once + CREATE TABLE IF NOT EXISTS used_nonces ( + nonce TEXT NOT NULL, + from_address TEXT NOT NULL, + chain_id INTEGER NOT NULL, + facilitator_id TEXT NOT NULL, + transaction_hash TEXT, + used_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + PRIMARY KEY (nonce, from_address, chain_id) + ); + + CREATE INDEX IF NOT EXISTS idx_nonces_expires ON used_nonces(expires_at); + CREATE INDEX IF NOT EXISTS idx_nonces_facilitator ON used_nonces(facilitator_id); + -- Refund configuration per facilitator (global enable/disable) CREATE TABLE IF NOT EXISTS refund_configs ( id TEXT PRIMARY KEY, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2679a22..d4c6228 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import { createServer } from './server.js'; import { initializeDatabase } from './db/index.js'; import { initializeAuth } from './auth/index.js'; +import { startNonceCleanupJob } from './services/nonce-cleanup.js'; const PORT = parseInt(process.env.PORT || '5002', 10); const HOST = process.env.HOST || '0.0.0.0'; @@ -14,6 +15,10 @@ async function main() { // Initialize auth initializeAuth(DATABASE_PATH); + // SECURITY: Start background cleanup job for expired nonces + // This prevents unbounded growth of the used_nonces table + const stopCleanup = startNonceCleanupJob(); + // Create and start server const app = createServer(); @@ -22,6 +27,19 @@ async function main() { console.log(` Environment: ${process.env.NODE_ENV || 'development'}`); console.log(` Database: ${DATABASE_PATH}`); }); + + // Graceful shutdown + process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully...'); + stopCleanup(); + process.exit(0); + }); + + process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully...'); + stopCleanup(); + process.exit(0); + }); } main().catch((error) => { diff --git a/packages/server/src/routes/facilitator.ts b/packages/server/src/routes/facilitator.ts index 860d5c4..77f07af 100644 --- a/packages/server/src/routes/facilitator.ts +++ b/packages/server/src/routes/facilitator.ts @@ -23,6 +23,7 @@ import { sendSettlementWebhook, deliverWebhook, generateWebhookSecret, type Prod import { executeAction, type ActionResult } from '../services/actions.js'; import { getWebhookById } from '../db/webhooks.js'; import { getProxyUrlBySlug } from '../db/proxy-urls.js'; +import { createNonceValidator } from '../services/nonce-validator-adapter.js'; import type { Hex } from 'viem'; const router: IRouter = Router(); @@ -344,6 +345,7 @@ router.get('/supported', requireFacilitator, (req: Request, res: Response) => { supportedTokens: JSON.parse(record.supported_tokens) as TokenConfig[], createdAt: new Date(record.created_at), updatedAt: new Date(record.updated_at), + nonceValidator: createNonceValidator(record.id), // SECURITY: Persistent nonce tracking }; const facilitator = createFacilitator(config); @@ -421,6 +423,7 @@ router.post('/verify', requireFacilitator, async (req: Request, res: Response) = supportedTokens: JSON.parse(record.supported_tokens) as TokenConfig[], createdAt: new Date(record.created_at), updatedAt: new Date(record.updated_at), + nonceValidator: createNonceValidator(record.id), // SECURITY: Persistent nonce tracking }; const facilitator = createFacilitator(config); @@ -484,6 +487,7 @@ router.post('/settle', requireFacilitator, async (req: Request, res: Response) = supportedTokens: JSON.parse(record.supported_tokens) as TokenConfig[], createdAt: new Date(record.created_at), updatedAt: new Date(record.updated_at), + nonceValidator: createNonceValidator(record.id), // SECURITY: Persistent nonce tracking }; const facilitator = createFacilitator(config); diff --git a/packages/server/src/services/nonce-cleanup.ts b/packages/server/src/services/nonce-cleanup.ts new file mode 100644 index 0000000..89cd00c --- /dev/null +++ b/packages/server/src/services/nonce-cleanup.ts @@ -0,0 +1,63 @@ +/** + * Cleanup Service for Expired Nonces + * + * SECURITY: This service prevents unbounded growth of the used_nonces table + * by periodically removing nonces that have expired. + * + * Run this as a cron job or background task to maintain database performance. + */ + +import { cleanupExpiredNonces } from './nonce-tracker.js'; + +/** + * Interval for running cleanup (default: 1 hour) + */ +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +/** + * Start the nonce cleanup background job + * + * This will run cleanup at regular intervals to remove expired nonces + * from the database. + * + * @returns Function to stop the cleanup job + */ +export function startNonceCleanupJob(): () => void { + console.log('[NonceCleanup] Starting background cleanup job'); + console.log(`[NonceCleanup] Cleanup will run every ${CLEANUP_INTERVAL_MS / 1000 / 60} minutes`); + + // Run cleanup immediately on startup + runCleanup(); + + // Then run at regular intervals + const intervalId = setInterval(runCleanup, CLEANUP_INTERVAL_MS); + + // Return function to stop the job + return () => { + console.log('[NonceCleanup] Stopping background cleanup job'); + clearInterval(intervalId); + }; +} + +/** + * Run a single cleanup cycle + */ +function runCleanup(): void { + try { + const deletedCount = cleanupExpiredNonces(); + console.log('[NonceCleanup] Cleanup completed:', { + deletedNonces: deletedCount, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('[NonceCleanup] Cleanup failed:', error); + } +} + +/** + * Run cleanup on-demand (useful for manual triggers or testing) + */ +export function runManualCleanup(): number { + console.log('[NonceCleanup] Running manual cleanup...'); + return cleanupExpiredNonces(); +} diff --git a/packages/server/src/services/nonce-tracker.test.ts b/packages/server/src/services/nonce-tracker.test.ts new file mode 100644 index 0000000..94d97fa --- /dev/null +++ b/packages/server/src/services/nonce-tracker.test.ts @@ -0,0 +1,394 @@ +/** + * Comprehensive tests for persistent nonce tracking + * + * CRITICAL: These tests verify replay attack prevention - the most important + * security feature of the nonce tracker. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { initializeDatabase, closeDatabase } from '../db/index.js'; +import { + tryAcquireNonce, + releaseNonce, + markNonceSettled, + cleanupExpiredNonces, + getNonceStats, + type AcquireNonceParams, +} from './nonce-tracker.js'; +import fs from 'fs'; + +describe('Nonce Tracker Service', () => { + // Use unique database file to avoid conflicts with other tests + const testDbPath = `./data/test-nonce-tracker-${Date.now()}.db`; + const testFacilitatorId = 'test-facilitator-123'; + + beforeAll(() => { + // Initialize test database + initializeDatabase(testDbPath); + }); + + afterAll(() => { + // Clean up test database + closeDatabase(); + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath); + } + if (fs.existsSync(testDbPath + '-shm')) { + fs.unlinkSync(testDbPath + '-shm'); + } + if (fs.existsSync(testDbPath + '-wal')) { + fs.unlinkSync(testDbPath + '-wal'); + } + }); + + describe('tryAcquireNonce', () => { + it('should successfully acquire a new nonce', () => { + const params: AcquireNonceParams = { + nonce: '0x1111111111111111111111111111111111111111111111111111111111111111', + from: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + chainId: 8453, // Base + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + }; + + const result = tryAcquireNonce(params); + + expect(result.acquired).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('should reject duplicate nonce from same address on same chain', () => { + const nonce = '0x2222222222222222222222222222222222222222222222222222222222222222'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + // First acquisition should succeed + const result1 = tryAcquireNonce(params); + expect(result1.acquired).toBe(true); + + // Second acquisition should fail + const result2 = tryAcquireNonce(params); + expect(result2.acquired).toBe(false); + expect(result2.reason).toContain('already'); + }); + + it('should allow same nonce from different addresses', () => { + const nonce = '0x3333333333333333333333333333333333333333333333333333333333333333'; + const chainId = 8453; + + const params1: AcquireNonceParams = { + nonce, + from: '0x1111111111111111111111111111111111111111', + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const params2: AcquireNonceParams = { + nonce, + from: '0x2222222222222222222222222222222222222222', + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const result1 = tryAcquireNonce(params1); + expect(result1.acquired).toBe(true); + + const result2 = tryAcquireNonce(params2); + expect(result2.acquired).toBe(true); + }); + + it('should allow same nonce from same address on different chains', () => { + const nonce = '0x4444444444444444444444444444444444444444444444444444444444444444'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + + const params1: AcquireNonceParams = { + nonce, + from, + chainId: 8453, // Base + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const params2: AcquireNonceParams = { + nonce, + from, + chainId: 1, // Ethereum + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const result1 = tryAcquireNonce(params1); + expect(result1.acquired).toBe(true); + + const result2 = tryAcquireNonce(params2); + expect(result2.acquired).toBe(true); + }); + + it('should handle case-insensitive addresses and nonces', () => { + const nonce = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const from = '0xABCDEF1234567890ABCDEF1234567890ABCDEF12'; + const chainId = 8453; + + const params1: AcquireNonceParams = { + nonce: nonce.toUpperCase(), + from: from.toUpperCase(), + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const params2: AcquireNonceParams = { + nonce: nonce.toLowerCase(), + from: from.toLowerCase(), + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const result1 = tryAcquireNonce(params1); + expect(result1.acquired).toBe(true); + + // Should reject because nonce is already acquired (case-insensitive) + const result2 = tryAcquireNonce(params2); + expect(result2.acquired).toBe(false); + }); + + it('should store expiration time correctly', () => { + const nonce = '0x5555555555555555555555555555555555555555555555555555555555555555'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + const expiresAt = Math.floor(Date.now() / 1000) + 7200; // 2 hours from now + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt, + }; + + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + + // Verify it's stored in database + const stats = getNonceStats(testFacilitatorId); + expect(stats.totalNonces).toBeGreaterThan(0); + }); + }); + + describe('releaseNonce', () => { + it('should release nonce from in-memory cache', () => { + const nonce = '0x6666666666666666666666666666666666666666666666666666666666666666'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + // Acquire nonce + const result1 = tryAcquireNonce(params); + expect(result1.acquired).toBe(true); + + // Release nonce from cache (simulates pre-settlement failure) + releaseNonce(nonce, from, chainId); + + // Note: The nonce is still in the database, so re-acquisition should fail + // This is intentional - we only release from cache, not database + const result2 = tryAcquireNonce(params); + expect(result2.acquired).toBe(false); + expect(result2.reason).toContain('already'); + }); + }); + + describe('markNonceSettled', () => { + it('should mark nonce with transaction hash after settlement', () => { + const nonce = '0x7777777777777777777777777777777777777777777777777777777777777777'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + const txHash = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + // Acquire nonce + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + + // Mark as settled + markNonceSettled(nonce, from, chainId, txHash); + + // Clear the in-memory cache to test database lookup + releaseNonce(nonce, from, chainId); + + // Try to acquire again - should fail from database and include transaction hash in reason + const result2 = tryAcquireNonce(params); + expect(result2.acquired).toBe(false); + expect(result2.reason).toContain(txHash); + }); + }); + + describe('cleanupExpiredNonces', () => { + it('should run cleanup without errors', () => { + // Run cleanup - should complete successfully even if no nonces are deleted + const deletedCount = cleanupExpiredNonces(); + + // Verify cleanup runs and returns a number + expect(typeof deletedCount).toBe('number'); + expect(deletedCount).toBeGreaterThanOrEqual(0); + }); + + it('should not delete non-expired nonces', () => { + const nonce = '0x9999999999999999999999999999999999999999999999999999999999999999'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour + }; + + const statsBefore = getNonceStats(testFacilitatorId); + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + + // Run cleanup + cleanupExpiredNonces(); + + // Verify non-expired nonce still exists + const statsAfter = getNonceStats(testFacilitatorId); + expect(statsAfter.totalNonces).toBeGreaterThanOrEqual(statsBefore.totalNonces + 1); + }); + }); + + describe('getNonceStats', () => { + it('should return correct statistics', () => { + const stats = getNonceStats(testFacilitatorId); + + expect(stats).toHaveProperty('totalNonces'); + expect(stats).toHaveProperty('settledNonces'); + expect(stats).toHaveProperty('pendingNonces'); + expect(stats).toHaveProperty('expiredNonces'); + + expect(typeof stats.totalNonces).toBe('number'); + expect(typeof stats.settledNonces).toBe('number'); + expect(typeof stats.pendingNonces).toBe('number'); + expect(typeof stats.expiredNonces).toBe('number'); + + // Basic sanity checks + expect(stats.totalNonces).toBeGreaterThanOrEqual(0); + expect(stats.settledNonces).toBeGreaterThanOrEqual(0); + expect(stats.pendingNonces).toBeGreaterThanOrEqual(0); + expect(stats.settledNonces + stats.pendingNonces).toBeLessThanOrEqual(stats.totalNonces); + }); + }); + + describe('Concurrent Access (Stress Test)', () => { + it('should handle concurrent requests with same nonce safely', () => { + const nonce = '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + // Simulate 10 concurrent requests + const results = Array.from({ length: 10 }, () => tryAcquireNonce(params)); + + // SECURITY: Only ONE should succeed + const acquired = results.filter((r) => r.acquired); + const rejected = results.filter((r) => !r.acquired); + + expect(acquired.length).toBe(1); + expect(rejected.length).toBe(9); + + // All rejected should have a reason + rejected.forEach((result) => { + expect(result.reason).toBeDefined(); + expect(typeof result.reason).toBe('string'); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle very long nonces', () => { + const nonce = '0x' + 'A'.repeat(64); + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + }); + + it('should handle addresses without 0x prefix', () => { + const nonce = '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + const from = '742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; // No 0x prefix + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + }); + + it('should handle past expiration times gracefully', () => { + const nonce = '0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE'; + const from = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; + const chainId = 8453; + + const params: AcquireNonceParams = { + nonce, + from, + chainId, + facilitatorId: testFacilitatorId, + expiresAt: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago + }; + + // Should still acquire (expiration validation happens elsewhere) + // The nonce tracker doesn't validate expiration - that's done in ERC3009 verification + const result = tryAcquireNonce(params); + expect(result.acquired).toBe(true); + }); + }); +}); diff --git a/packages/server/src/services/nonce-tracker.ts b/packages/server/src/services/nonce-tracker.ts new file mode 100644 index 0000000..3bd1f89 --- /dev/null +++ b/packages/server/src/services/nonce-tracker.ts @@ -0,0 +1,325 @@ +/** + * Persistent Nonce Tracker Service + * + * SECURITY: This service prevents replay attacks by ensuring each ERC-3009 authorization + * nonce can only be used once, even across server restarts. + * + * Two-tier approach: + * - L1 Cache: In-memory Map for fast lookups (prevents concurrent duplicates) + * - L2 Storage: SQLite database for persistence (prevents post-restart duplicates) + * + * Critical for financial safety - DO NOT modify without security review. + */ + +import { getDatabase } from '../db/index.js'; +import type Database from 'better-sqlite3'; + +/** + * In-memory nonce cache (L1) + * Key: `${chainId}:${from}:${nonce}` - Value: timestamp when acquired + * + * This provides fast deduplication for concurrent requests before hitting the database. + */ +const processingNonces = new Map(); + +/** + * Time-to-live for in-memory cache entries (10 minutes) + * Entries older than this are cleaned up to prevent memory leaks + */ +const NONCE_CACHE_TTL_MS = 10 * 60 * 1000; + +/** + * Clean up old entries from in-memory cache every 5 minutes + */ +setInterval(() => { + const now = Date.now(); + for (const [key, timestamp] of processingNonces.entries()) { + if (now - timestamp > NONCE_CACHE_TTL_MS) { + processingNonces.delete(key); + } + } +}, 5 * 60 * 1000); + +/** + * Generate cache key for a nonce + */ +function getCacheKey(chainId: number, from: string, nonce: string): string { + return `${chainId}:${from.toLowerCase()}:${nonce.toLowerCase()}`; +} + +/** + * Parameters for acquiring a nonce + */ +export interface AcquireNonceParams { + /** The nonce from the ERC-3009 authorization */ + nonce: string; + /** The payer address (authorization.from) */ + from: string; + /** The chain ID (e.g., 8453 for Base) */ + chainId: number; + /** The facilitator ID processing this authorization */ + facilitatorId: string; + /** Unix timestamp when this authorization expires (authorization.validBefore) */ + expiresAt: number; +} + +/** + * Result of trying to acquire a nonce + */ +export interface AcquireNonceResult { + /** Whether the nonce was successfully acquired */ + acquired: boolean; + /** Human-readable reason if acquisition failed */ + reason?: string; +} + +/** + * Try to acquire a nonce for settlement + * + * SECURITY: This function ensures atomicity through: + * 1. In-memory check (fast path for concurrent requests) + * 2. Database UNIQUE constraint (enforces uniqueness persistently) + * + * @param params Nonce acquisition parameters + * @returns Result indicating success or failure with reason + */ +export function tryAcquireNonce(params: AcquireNonceParams): AcquireNonceResult { + const { nonce, from, chainId, facilitatorId, expiresAt } = params; + + // Normalize inputs to prevent case-sensitivity issues + const normalizedFrom = from.toLowerCase(); + const normalizedNonce = nonce.toLowerCase(); + const cacheKey = getCacheKey(chainId, normalizedFrom, normalizedNonce); + + // L1 Cache: Check in-memory cache first (fast path) + if (processingNonces.has(cacheKey)) { + console.log('[NonceTracker] DUPLICATE BLOCKED (L1 Cache):', { chainId, from: normalizedFrom, nonce: normalizedNonce }); + return { + acquired: false, + reason: 'This authorization is already being processed (concurrent request detected)', + }; + } + + // L2 Storage: Check database for persistent nonce record + const db = getDatabase(); + + try { + // Check if nonce already exists in database + const existing = db + .prepare( + `SELECT nonce, transaction_hash, used_at FROM used_nonces + WHERE nonce = ? AND from_address = ? AND chain_id = ?` + ) + .get(normalizedNonce, normalizedFrom, chainId) as + | { nonce: string; transaction_hash: string | null; used_at: string } + | undefined; + + if (existing) { + console.log('[NonceTracker] DUPLICATE BLOCKED (L2 Database):', { + chainId, + from: normalizedFrom, + nonce: normalizedNonce, + previousTx: existing.transaction_hash, + usedAt: existing.used_at, + }); + + return { + acquired: false, + reason: existing.transaction_hash + ? `This authorization was already settled in transaction ${existing.transaction_hash}` + : 'This authorization nonce has already been used', + }; + } + + // Atomically insert nonce into database + // SECURITY: PRIMARY KEY constraint ensures no race condition between concurrent settlements + const expiresAtTimestamp = new Date(expiresAt * 1000).toISOString(); + + const stmt = db.prepare(` + INSERT INTO used_nonces (nonce, from_address, chain_id, facilitator_id, expires_at) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run(normalizedNonce, normalizedFrom, chainId, facilitatorId, expiresAtTimestamp); + + // Add to in-memory cache after successful database insert + processingNonces.set(cacheKey, Date.now()); + + console.log('[NonceTracker] Nonce acquired successfully:', { + chainId, + from: normalizedFrom, + nonce: normalizedNonce, + facilitatorId, + }); + + return { + acquired: true, + }; + } catch (error) { + // If database insert fails due to UNIQUE constraint, it means another process + // acquired this nonce between our SELECT and INSERT (extremely rare with SQLite) + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + console.warn('[NonceTracker] Race condition detected - nonce acquired by concurrent process:', { + chainId, + from: normalizedFrom, + nonce: normalizedNonce, + }); + + return { + acquired: false, + reason: 'This authorization was acquired by a concurrent settlement request', + }; + } + + // Unexpected error - log and reject for safety + console.error('[NonceTracker] Unexpected error acquiring nonce:', error); + return { + acquired: false, + reason: 'Failed to validate nonce uniqueness - rejecting for safety', + }; + } +} + +/** + * Release a nonce from the in-memory cache + * + * Call this if a transaction fails BEFORE on-chain submission. + * DO NOT call this after successful submission - the nonce remains in the database. + * + * @param nonce The nonce to release + * @param from The payer address + * @param chainId The chain ID + */ +export function releaseNonce(nonce: string, from: string, chainId: number): void { + const normalizedFrom = from.toLowerCase(); + const normalizedNonce = nonce.toLowerCase(); + const cacheKey = getCacheKey(chainId, normalizedFrom, normalizedNonce); + + processingNonces.delete(cacheKey); + + console.log('[NonceTracker] Nonce released from cache (pre-settlement failure):', { + chainId, + from: normalizedFrom, + nonce: normalizedNonce, + }); +} + +/** + * Mark a nonce as successfully settled with transaction hash + * + * Call this after on-chain transaction confirmation. + * + * @param nonce The nonce that was settled + * @param from The payer address + * @param chainId The chain ID + * @param transactionHash The on-chain transaction hash + */ +export function markNonceSettled( + nonce: string, + from: string, + chainId: number, + transactionHash: string +): void { + const normalizedFrom = from.toLowerCase(); + const normalizedNonce = nonce.toLowerCase(); + + const db = getDatabase(); + + try { + const stmt = db.prepare(` + UPDATE used_nonces + SET transaction_hash = ? + WHERE nonce = ? AND from_address = ? AND chain_id = ? + `); + + stmt.run(transactionHash, normalizedNonce, normalizedFrom, chainId); + + console.log('[NonceTracker] Nonce marked as settled:', { + chainId, + from: normalizedFrom, + nonce: normalizedNonce, + transactionHash, + }); + } catch (error) { + console.error('[NonceTracker] Failed to mark nonce as settled:', error); + // Non-critical error - nonce is still tracked, just missing tx hash + } +} + +/** + * Clean up expired nonces from the database + * + * SECURITY: This should run periodically (e.g., via cron) to prevent unbounded growth. + * Only deletes nonces where expires_at < current time. + * + * @returns Number of nonces deleted + */ +export function cleanupExpiredNonces(): number { + const db = getDatabase(); + + try { + const stmt = db.prepare(` + DELETE FROM used_nonces + WHERE expires_at < datetime('now') + `); + + const result = stmt.run(); + const deletedCount = result.changes; + + console.log('[NonceTracker] Cleanup complete:', { + deletedNonces: deletedCount, + timestamp: new Date().toISOString(), + }); + + return deletedCount; + } catch (error) { + console.error('[NonceTracker] Cleanup failed:', error); + return 0; + } +} + +/** + * Get nonce usage statistics for a facilitator + * + * Useful for monitoring and debugging. + * + * @param facilitatorId The facilitator ID + * @returns Statistics about nonce usage + */ +export function getNonceStats(facilitatorId: string): { + totalNonces: number; + settledNonces: number; + pendingNonces: number; + expiredNonces: number; +} { + const db = getDatabase(); + + const total = db + .prepare('SELECT COUNT(*) as count FROM used_nonces WHERE facilitator_id = ?') + .get(facilitatorId) as { count: number }; + + const settled = db + .prepare( + 'SELECT COUNT(*) as count FROM used_nonces WHERE facilitator_id = ? AND transaction_hash IS NOT NULL' + ) + .get(facilitatorId) as { count: number }; + + const pending = db + .prepare( + 'SELECT COUNT(*) as count FROM used_nonces WHERE facilitator_id = ? AND transaction_hash IS NULL' + ) + .get(facilitatorId) as { count: number }; + + const expired = db + .prepare( + "SELECT COUNT(*) as count FROM used_nonces WHERE facilitator_id = ? AND expires_at < datetime('now')" + ) + .get(facilitatorId) as { count: number }; + + return { + totalNonces: total.count, + settledNonces: settled.count, + pendingNonces: pending.count, + expiredNonces: expired.count, + }; +} diff --git a/packages/server/src/services/nonce-validator-adapter.ts b/packages/server/src/services/nonce-validator-adapter.ts new file mode 100644 index 0000000..b31208e --- /dev/null +++ b/packages/server/src/services/nonce-validator-adapter.ts @@ -0,0 +1,60 @@ +/** + * Adapter to bridge the nonce tracker service with the core package's NonceValidator interface + * + * This allows the core package to remain independent while using persistent nonce tracking + * when running in the server environment. + */ + +import type { NonceValidator } from '@openfacilitator/core'; +import { + tryAcquireNonce, + releaseNonce as releaseNonceFromCache, + markNonceSettled, + type AcquireNonceParams, +} from './nonce-tracker.js'; + +/** + * Create a NonceValidator instance for a specific facilitator + * + * @param facilitatorId The facilitator ID to track nonces for + * @returns NonceValidator that can be injected into settlement operations + */ +export function createNonceValidator(facilitatorId: string): NonceValidator { + return { + /** + * Try to acquire a nonce for settlement + * Synchronous wrapper around the nonce tracker + */ + tryAcquire(params: { + nonce: string; + from: string; + chainId: number; + expiresAt: number; + }): { acquired: boolean; reason?: string } { + const acquireParams: AcquireNonceParams = { + nonce: params.nonce, + from: params.from, + chainId: params.chainId, + facilitatorId, + expiresAt: params.expiresAt, + }; + + return tryAcquireNonce(acquireParams); + }, + + /** + * Release a nonce from in-memory cache if settlement fails before submission + * Note: This only releases from cache, NOT from database + */ + release(nonce: string, from: string, chainId: number): void { + releaseNonceFromCache(nonce, from, chainId); + }, + + /** + * Mark a nonce as successfully settled with transaction hash + */ + markSettled(nonce: string, from: string, chainId: number, txHash: string): void { + markNonceSettled(nonce, from, chainId, txHash); + }, + }; +}