@@ -62,7 +62,7 @@ const publicClient = createPublicClient({
6262 transport : getTransport ( chain . id ) ,
6363} )
6464
65- function getPublicClientForChainId ( chainId : number ) {
65+ export function getPublicClientForChainId ( chainId : number ) {
6666 const c = CHAIN_ID_TO_CHAIN [ chainId ]
6767 if ( ! c ) return null
6868 return createPublicClient ( { chain : c , transport : getTransport ( chainId ) } )
@@ -294,7 +294,30 @@ export async function verifyPayment(proof: X402PaymentProof): Promise<X402Paymen
294294 }
295295 }
296296
297- return { verified : false , error : 'No matching transfer found in transaction' }
297+ // Build a descriptive error explaining what was expected vs. what we found.
298+ // Helps users understand failures on smart-wallet flows where the outer
299+ // tx never actually delivers funds to our merchant (e.g., Coinbase Smart
300+ // Wallet + its own paymaster bypasses our relayer).
301+ const transfersFromPayer = transferLogs
302+ . map ( ( log ) => ( {
303+ from : `0x${ log . topics [ 1 ] ?. slice ( 26 ) } ` . toLowerCase ( ) ,
304+ to : `0x${ log . topics [ 2 ] ?. slice ( 26 ) } ` . toLowerCase ( ) ,
305+ value : BigInt ( log . data ) . toString ( ) ,
306+ } ) )
307+ . filter ( ( t ) => t . from === payer . toLowerCase ( ) )
308+ const transfersToRecipient = transferLogs
309+ . map ( ( log ) => ( {
310+ from : `0x${ log . topics [ 1 ] ?. slice ( 26 ) } ` . toLowerCase ( ) ,
311+ to : `0x${ log . topics [ 2 ] ?. slice ( 26 ) } ` . toLowerCase ( ) ,
312+ value : BigInt ( log . data ) . toString ( ) ,
313+ } ) )
314+ . filter ( ( t ) => t . to === recipient . toLowerCase ( ) )
315+ const summary = [
316+ `expected: from=${ payer . toLowerCase ( ) } to=${ recipient . toLowerCase ( ) } value>=${ proof . expectedAmount } ` ,
317+ `from payer (${ transfersFromPayer . length } ): ${ JSON . stringify ( transfersFromPayer ) } ` ,
318+ `to recipient (${ transfersToRecipient . length } ): ${ JSON . stringify ( transfersToRecipient ) } ` ,
319+ ] . join ( ' | ' )
320+ return { verified : false , error : `No matching transfer found in transaction. ${ summary } ` }
298321 } catch ( error ) {
299322 console . error ( 'Error verifying payment:' , error )
300323 return { verified : false , error : `Verification error: ${ ( error as Error ) . message } ` }
@@ -400,6 +423,59 @@ export async function verifyPaymentDirect(
400423 }
401424}
402425
426+ /**
427+ * Walk a `debug_traceTransaction` call tree looking for an internal ETH
428+ * transfer from `fromAddr` to `toAddr` with value >= `minValue`.
429+ * Supports ERC-4337 bundler flows where the outer tx.from is a bundler EOA
430+ * and the real ETH movement happens in an internal call from the smart wallet.
431+ *
432+ * Requires the RPC provider to support `debug_traceTransaction` with the
433+ * `callTracer`. Alchemy, QuickNode, and Infura (paid tier) all support this.
434+ */
435+ type CallTraceNode = {
436+ from ?: string
437+ to ?: string
438+ value ?: string
439+ type ?: string
440+ calls ?: CallTraceNode [ ]
441+ }
442+
443+ async function findInternalEthTransfer (
444+ client : ReturnType < typeof getPublicClientForChainId > ,
445+ txHash : string ,
446+ fromAddrLower : string ,
447+ toAddrLower : string ,
448+ minValue : bigint ,
449+ ) : Promise < { found : boolean ; valueWei ?: bigint ; error ?: string } > {
450+ if ( ! client ) return { found : false , error : 'No RPC client' }
451+ try {
452+ // Cast to any to bypass viem's strict typed RPC schema; debug_traceTransaction
453+ // is a non-standard but widely supported method (Alchemy, QuickNode, Infura).
454+ const trace = ( await ( client as any ) . request ( {
455+ method : 'debug_traceTransaction' ,
456+ params : [ txHash , { tracer : 'callTracer' } ] ,
457+ } ) ) as CallTraceNode
458+
459+ const stack : CallTraceNode [ ] = [ trace ]
460+ const zero = BigInt ( 0 )
461+ while ( stack . length > 0 ) {
462+ const node = stack . pop ( ) !
463+ const nodeValue = node . value ? BigInt ( node . value ) : zero
464+ if (
465+ nodeValue >= minValue &&
466+ node . from ?. toLowerCase ( ) === fromAddrLower &&
467+ node . to ?. toLowerCase ( ) === toAddrLower
468+ ) {
469+ return { found : true , valueWei : nodeValue }
470+ }
471+ if ( node . calls ) stack . push ( ...node . calls )
472+ }
473+ return { found : false }
474+ } catch ( e ) {
475+ return { found : false , error : ( e as Error ) . message }
476+ }
477+ }
478+
403479/**
404480 * Verify a native ETH payment (value transfer) on-chain
405481 */
@@ -454,12 +530,34 @@ export async function verifyPaymentNativeEth(
454530 }
455531
456532 const recipientLower = expectedRecipient . toLowerCase ( )
533+ const payerLower = payer . toLowerCase ( )
457534 const toMatch = tx . to && tx . to . toLowerCase ( ) === recipientLower
458- if ( ! toMatch || tx . from ?. toLowerCase ( ) !== payer . toLowerCase ( ) ) {
459- return { verified : false , error : 'Transaction from/to does not match payment' }
460- }
461- if ( tx . value < BigInt ( expectedAmountWei ) ) {
462- return { verified : false , error : 'Transaction value is less than required amount' }
535+ const fromMatch = tx . from ?. toLowerCase ( ) === payerLower
536+
537+ // Happy path: EOA sends ETH directly — tx.from/tx.to/tx.value all match.
538+ if ( toMatch && fromMatch ) {
539+ if ( tx . value < BigInt ( expectedAmountWei ) ) {
540+ return { verified : false , error : 'Transaction value is less than required amount' }
541+ }
542+ } else {
543+ // Smart wallet / ERC-4337 bundler path: the outer tx is sent by a
544+ // bundler/EntryPoint, and the actual ETH transfer happens in an
545+ // internal call from the smart wallet (payer) to the recipient.
546+ // We inspect the tx trace to find a matching internal transfer.
547+ console . log ( '[x402] Native ETH direct match failed; trying trace-based detection for smart-wallet / 4337 flow' )
548+ const traceMatch = await findInternalEthTransfer (
549+ client ,
550+ txHash ,
551+ payerLower ,
552+ recipientLower ,
553+ BigInt ( expectedAmountWei ) ,
554+ )
555+ if ( ! traceMatch . found ) {
556+ const reason = `tx.from=${ tx . from } (expected payer=${ payer } ), tx.to=${ tx . to } (expected recipient=${ expectedRecipient } ); no matching internal transfer found`
557+ console . warn ( '[x402] Native ETH mismatch:' , reason , traceMatch . error ?? '' )
558+ return { verified : false , error : `Transaction from/to does not match payment: ${ reason } ` }
559+ }
560+ console . log ( '[x402] Native ETH verified via trace; internal transfer value =' , traceMatch . valueWei ?. toString ( ) )
463561 }
464562
465563 const gasCostWei = receipt . gasUsed != null && receipt . effectiveGasPrice != null
0 commit comments