// ============================================================================= // Payment proof verification for seller runtime. // // Before executing a job in the TRANSACTION phase, the seller must verify // that a payment proof memo (PAYABLE_TRANSFER or TXHASH) exists and has // non-empty content. This prevents execution without payment confirmation. // // Phase 1 (format check): verifyPaymentProof() — validates memo exists with // a well-formed tx hash. // Phase 2 (on-chain check): verifyPaymentOnChain() — confirms the tx exists // on-chain, succeeded, transferred the correct USDC amount to the correct // recipient, and has not been replayed. // ============================================================================= import { MemoType, type AcpMemoData } from "./types.js"; import { createPublicClient, http, type PublicClient, type Chain } from "viem"; import { base } from "viem/chains"; // ============================================================================= // Types // ============================================================================= export interface PaymentVerificationResult { verified: boolean; memo?: AcpMemoData; reason?: string; } export interface OnChainVerificationResult { verified: boolean; txHash: `0x${string}`; blockNumber?: bigint; from?: string; to?: string; amount?: bigint; reason?: string; } // ============================================================================= // Constants // ============================================================================= /** * Regex that a valid EVM transaction hash must satisfy: * - 0x prefix * - exactly 64 lowercase or uppercase hex characters */ const TX_HASH_REGEX = /^0x[a-fA-F0-9]{64}$/; function getBaseUsdcAddress(): string | null { const raw = process.env.BASE_USDC_ADDRESS?.trim(); if (!raw) { return null; } if (!/^0x[a-fA-F0-9]{40}$/.test(raw)) { console.error( `[paymentVerification] BASE_USDC_ADDRESS is invalid (expected 0x-prefixed 40-char address): ${raw}`, ); return null; } return raw.toLowerCase(); } // ============================================================================= // Replay protection — in-memory seen-tx-hash set // ============================================================================= const seenTxHashes = new Set(); const MAX_SEEN_TX_HASHES = 10_000; /** * Returns true if this tx hash has already been successfully verified. * Purely a read — does not mutate the set. */ function isTxHashSeen(txHash: string): boolean { return seenTxHashes.has(txHash.toLowerCase()); } /** * Record a tx hash as successfully verified. * Evicts the oldest entries when the set exceeds the cap. */ function markTxHashSeen(txHash: string): void { const normalized = txHash.toLowerCase(); if (seenTxHashes.has(normalized)) return; // Evict oldest 10% when at capacity. // Set iteration order is insertion order, so the first entries are oldest. if (seenTxHashes.size >= MAX_SEEN_TX_HASHES) { const evictCount = Math.max(1, Math.floor(MAX_SEEN_TX_HASHES * 0.1)); const iter = seenTxHashes.values(); for (let i = 0; i < evictCount; i++) { const next = iter.next(); if (next.done) break; seenTxHashes.delete(next.value); } } seenTxHashes.add(normalized); } // ============================================================================= // RPC client — lazily created singleton // ============================================================================= let _publicClient: PublicClient | null = null; function getBaseRpcUrl(): string { return ( process.env.BASE_RPC_URL || process.env.ALCHEMY_BASE_HTTP || "https://mainnet.base.org" ); } function getPublicClient(): PublicClient { if (!_publicClient) { const rpcUrl = getBaseRpcUrl(); console.log( `[paymentVerification] Creating Base public client (rpc=${rpcUrl.replace(/\/[^/]*api[^/]*$/i, "/***")})`, ); _publicClient = createPublicClient({ chain: base as Chain, transport: http(rpcUrl), }); } return _publicClient; } // ============================================================================= // Phase 1: Format-level payment proof check (unchanged) // ============================================================================= /** * Verify that a job's memos contain valid payment proof. * * Returns `{ verified: true, memo }` if a PAYABLE_TRANSFER or TXHASH * memo is found whose content is a properly formatted EVM transaction * hash (`0x` followed by 64 hex characters). Otherwise returns * `{ verified: false, reason }` explaining why verification failed. */ export function verifyPaymentProof( memos: AcpMemoData[], ): PaymentVerificationResult { const paymentMemo = memos.find( (m) => m.memoType === MemoType.PAYABLE_TRANSFER || m.memoType === MemoType.TXHASH, ); if (!paymentMemo) { return { verified: false, reason: "No payment proof memo found. Expected PAYABLE_TRANSFER or TXHASH memo.", }; } const content = paymentMemo.content?.trim(); if (!content) { return { verified: false, reason: `Payment memo (type=${MemoType[paymentMemo.memoType]}) has empty content.`, }; } if (!TX_HASH_REGEX.test(content)) { return { verified: false, reason: `Payment memo content is not a valid transaction hash. Expected 0x-prefixed 64-character hex string, got: "${content}".`, }; } return { verified: true, memo: paymentMemo }; } // ============================================================================= // Phase 2: On-chain payment verification // ============================================================================= /** * Verify that a transaction hash corresponds to a real, successful USDC * transfer on Base mainnet that matches the expected recipient and amount. * * Steps: * 1. Replay check — reject previously-seen tx hashes. * 2. Fetch tx receipt — confirm tx exists and succeeded. * 3. Parse Transfer logs — find USDC transfers in the receipt. * 4. Amount check — transfer value >= expectedMinAmount. * 5. Recipient check — transfer `to` matches expectedRecipient. * 6. Record tx hash in seen-set for future replay protection. */ export async function verifyPaymentOnChain( txHash: `0x${string}`, options: { expectedRecipient?: string; expectedMinAmount?: bigint; chainId?: number; } = {}, ): Promise { const tag = `[paymentVerification] tx=${txHash.slice(0, 10)}...`; const baseUsdcAddress = getBaseUsdcAddress(); if (!baseUsdcAddress) { return { verified: false, txHash, reason: "BASE_USDC_ADDRESS is not configured", }; } // ── 1. Replay check ────────────────────────────────────────────────── if (isTxHashSeen(txHash)) { console.log(`${tag} REJECTED — replay detected (tx hash already used)`); return { verified: false, txHash, reason: "Transaction hash already used (replay detected)", }; } // ── 2. Fetch transaction receipt ───────────────────────────────────── const client = getPublicClient(); let receipt: Awaited>; console.log(`${tag} Fetching transaction receipt from Base...`); try { receipt = await client.getTransactionReceipt({ hash: txHash }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`${tag} Failed to fetch receipt: ${msg}`); return { verified: false, txHash, reason: `Failed to fetch transaction receipt: ${msg}`, }; } // ── 3. Verify tx succeeded ─────────────────────────────────────────── if (receipt.status !== "success") { console.log( `${tag} REJECTED — tx status="${receipt.status}" (expected "success")`, ); return { verified: false, txHash, blockNumber: receipt.blockNumber, reason: `Transaction did not succeed (status="${receipt.status}")`, }; } console.log( `${tag} Receipt OK — block=${receipt.blockNumber}, status=success, logs=${receipt.logs.length}`, ); // ── 4. Parse USDC Transfer logs ────────────────────────────────────── // The Transfer event topic0: // keccak256("Transfer(address,address,uint256)") // = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef const TRANSFER_TOPIC0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const usdcTransfers = receipt.logs.filter( (log) => log.address.toLowerCase() === baseUsdcAddress && log.topics[0] === TRANSFER_TOPIC0 && log.topics.length >= 3, ); if (usdcTransfers.length === 0) { console.log(`${tag} REJECTED — no USDC Transfer events found in receipt`); return { verified: false, txHash, blockNumber: receipt.blockNumber, reason: "No USDC transfer events found in transaction", }; } // ── 5. Find a matching transfer ────────────────────────────────────── // Decode each USDC Transfer log and look for one that satisfies both // the recipient and amount constraints. for (const log of usdcTransfers) { // topics[1] = from (address, left-padded to 32 bytes) // topics[2] = to (address, left-padded to 32 bytes) // data = value (uint256) const from = ("0x" + (log.topics[1]?.slice(26) ?? "")).toLowerCase(); const to = ("0x" + (log.topics[2]?.slice(26) ?? "")).toLowerCase(); const value = BigInt(log.data); // Check recipient if ( options.expectedRecipient && to !== options.expectedRecipient.toLowerCase() ) { continue; // wrong recipient — try next transfer log } // Check amount if ( options.expectedMinAmount !== undefined && value < options.expectedMinAmount ) { console.log( `${tag} Transfer to=${to} amount=${value} below minimum=${options.expectedMinAmount} — skipping`, ); continue; // insufficient amount — try next transfer log } // ── 6. Success — record and return ─────────────────────────────── markTxHashSeen(txHash); console.log( `${tag} VERIFIED — from=${from}, to=${to}, amount=${value}, block=${receipt.blockNumber}`, ); return { verified: true, txHash, blockNumber: receipt.blockNumber, from, to, amount: value, }; } // No transfer matched all constraints const transferSummary = usdcTransfers .map((log) => { const to = "0x" + (log.topics[2]?.slice(26) ?? ""); const value = BigInt(log.data); return `to=${to} amount=${value}`; }) .join("; "); const reasons: string[] = []; if (options.expectedRecipient) { reasons.push(`expectedRecipient=${options.expectedRecipient}`); } if (options.expectedMinAmount !== undefined) { reasons.push(`expectedMinAmount=${options.expectedMinAmount}`); } console.log( `${tag} REJECTED — no USDC transfer matched constraints (${reasons.join(", ")}). Found: ${transferSummary}`, ); return { verified: false, txHash, blockNumber: receipt.blockNumber, reason: `No USDC transfer in tx matched constraints (${reasons.join(", ")}). Transfers found: ${transferSummary}`, }; } // ============================================================================= // Test helpers — only for unit tests // ============================================================================= export const __testing = { seenTxHashes, resetSeenTxHashes: () => seenTxHashes.clear(), resetPublicClient: () => { _publicClient = null; }, MAX_SEEN_TX_HASHES, };