Skip to content

Commit 140384a

Browse files
committed
Implement EIP-191 signature verification for native ETH payments; enhance payment verification logic and error handling in the x402 service.
1 parent 6851471 commit 140384a

3 files changed

Lines changed: 221 additions & 14 deletions

File tree

devcon/src/pages/api/x402/tickets/verify.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* - Ticket details
1515
*/
1616
import type { NextApiRequest, NextApiResponse } from 'next'
17-
import { verifyPayment, verifyPaymentDirect, verifyPaymentNativeEth, getPaymentRecipient, usdToUsdcAmount, encodeSettlementResponseHeader } from 'services/x402'
17+
import { verifyPayment, verifyPaymentDirect, verifyPaymentNativeEth, getPaymentRecipient, usdToUsdcAmount, encodeSettlementResponseHeader, getPublicClientForChainId } from 'services/x402'
1818
import { getUsdcConfig } from 'services/relayer'
1919
import type { SettleResponse } from 'types/x402'
2020
import { createOrder, confirmOrderPayment } from 'services/pretix'
@@ -52,6 +52,22 @@ interface VerifyRequest {
5252
symbol?: string
5353
/** Token contract address. Required for multi-token chains (e.g. USDC vs USDT0 on Arbitrum). Falls back to USDC if omitted. */
5454
tokenAddress?: string
55+
/**
56+
* EIP-191 signature proving the caller owns the `payer` wallet.
57+
* REQUIRED when `symbol === 'ETH'`. Prevents cross-order tx reuse attacks
58+
* on the native ETH path (USDC/USDT0 are already bound via EIP-3009).
59+
*/
60+
ethPayerSignature?: string
61+
}
62+
63+
/** Build the ETH-only message that the payer signs before their /verify call. */
64+
function buildEthPayerMessage(paymentReference: string, payer: string, chainId: number): string {
65+
return (
66+
'Devcon ticket payment (ETH)\n' +
67+
`Payment reference: ${paymentReference}\n` +
68+
`Payer: ${payer}\n` +
69+
`Chain: ${chainId}`
70+
)
5571
}
5672

5773
interface VerifySuccessResponse {
@@ -197,6 +213,55 @@ export default async function handler(
197213
})
198214
}
199215

216+
// For native ETH, require a signature over paymentReference+payer+chainId to
217+
// cryptographically prove the caller owns the `payer` wallet. USDC/USDT0 are
218+
// already bound via EIP-3009 at prepare-authorization time so no extra
219+
// signature is needed for those paths.
220+
if (body.symbol === 'ETH') {
221+
if (!body.ethPayerSignature || typeof body.ethPayerSignature !== 'string') {
222+
return res.status(400).json({
223+
success: false,
224+
error: 'ethPayerSignature is required for native ETH payments',
225+
})
226+
}
227+
if (body.chainId == null) {
228+
return res.status(400).json({
229+
success: false,
230+
error: 'chainId is required for native ETH payments',
231+
})
232+
}
233+
const message = buildEthPayerMessage(body.paymentReference, body.payer, body.chainId)
234+
// Use verifyMessage which handles BOTH EOA (ECDSA recovery) and smart
235+
// wallet (ERC-1271 isValidSignature) signatures. For contracts it calls
236+
// isValidSignature on the payer address and checks the 0x1626ba7e magic value.
237+
const client = getPublicClientForChainId(body.chainId)
238+
if (!client) {
239+
return res.status(400).json({
240+
success: false,
241+
error: `Unsupported chainId for signature verification: ${body.chainId}`,
242+
})
243+
}
244+
let sigValid = false
245+
try {
246+
sigValid = await client.verifyMessage({
247+
address: body.payer as `0x${string}`,
248+
message,
249+
signature: body.ethPayerSignature as `0x${string}`,
250+
})
251+
} catch (e) {
252+
return res.status(400).json({
253+
success: false,
254+
error: `ethPayerSignature verification failed: ${(e as Error).message}`,
255+
})
256+
}
257+
if (!sigValid) {
258+
return res.status(403).json({
259+
success: false,
260+
error: 'ethPayerSignature does not match the payer address',
261+
})
262+
}
263+
}
264+
200265
// Verify payment on-chain
201266
const expectedAmount = usdToUsdcAmount(pendingOrder.totalUsd)
202267
const paymentProof: X402PaymentProof = {
@@ -211,12 +276,16 @@ export default async function handler(
211276
console.log('[Verify] Attempting primary verification via x402 service', body.chainId != null ? `(chain ${body.chainId})` : '')
212277
let verification = await verifyPayment(paymentProof)
213278

214-
// Only try native ETH when client indicates ETH payment; otherwise we'd wrongly run it for USDC and fail (from/to mismatch)
279+
// Only try native ETH when client indicates ETH payment; otherwise we'd wrongly run it for USDC and fail (from/to mismatch).
280+
// Error prefix match (not exact) because verifyPayment may append diagnostic detail to the message.
215281
if (
216282
!verification.verified &&
217283
body.symbol === 'ETH' &&
218284
body.chainId != null &&
219-
(verification.error === 'No matching transfer found in transaction' || verification.error === 'Invalid payment reference')
285+
(
286+
verification.error?.startsWith('No matching transfer found in transaction') ||
287+
verification.error === 'Invalid payment reference'
288+
)
220289
) {
221290
const expectedAmountWei = pendingOrder.expectedEthAmountWeiByChain?.[String(body.chainId)]
222291
if (!expectedAmountWei) {

devcon/src/pages/tickets/store/checkout.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ function CheckoutContent() {
264264
// Gasless state
265265
const [authorizationData, setAuthorizationData] = useState<any>(null)
266266
const [isExecutingGasless, setIsExecutingGasless] = useState(false)
267+
// True during the verifyPayment poll (between tx broadcast and order confirmation).
268+
// Used to lock token/network selection until verification resolves.
269+
const [isVerifying, setIsVerifying] = useState(false)
270+
// EIP-191 signature binding payer wallet to the payment reference.
271+
// Only populated for the native ETH path (USDC/USDT0 are bound via EIP-3009).
272+
const [ethPayerSignature, setEthPayerSignature] = useState<string | null>(null)
267273

268274
// Payment options (multi-chain)
269275
const [paymentOptions, setPaymentOptions] = useState<PaymentOption[]>([])
@@ -669,7 +675,8 @@ function CheckoutContent() {
669675
isWritePending ||
670676
isTxLoading ||
671677
isSendTxPending ||
672-
isSendTxReceiptLoading
678+
isSendTxReceiptLoading ||
679+
isVerifying
673680

674681
// Invalidate stale payment when user navigates away from payment section
675682
// (e.g. goes back to edit add-ons, contact details, etc.)
@@ -1227,6 +1234,32 @@ function CheckoutContent() {
12271234
setPurchaseError('Invalid transaction request')
12281235
return
12291236
}
1237+
if (!walletClient) {
1238+
setPurchaseError('Wallet not connected')
1239+
return
1240+
}
1241+
1242+
// Sign a payer-proof message BEFORE sending the tx. Binds the wallet to
1243+
// this specific paymentReference+chain so the /verify endpoint can
1244+
// cryptographically confirm the caller owns the payer address (and is
1245+
// not replaying someone else's on-chain tx for a different order).
1246+
const chainIdForSig = Number(paymentDetails.chainId)
1247+
const payerMessage =
1248+
'Devcon ticket payment (ETH)\n' +
1249+
`Payment reference: ${paymentDetails.paymentReference}\n` +
1250+
`Payer: ${address}\n` +
1251+
`Chain: ${chainIdForSig}`
1252+
setPaymentStatus('Sign payer proof in wallet...')
1253+
let sig: string
1254+
try {
1255+
sig = await walletClient.signMessage({ account: address, message: payerMessage })
1256+
} catch (e) {
1257+
setPurchaseError(humanizeWalletError(e))
1258+
setPaymentStatus(null)
1259+
return
1260+
}
1261+
setEthPayerSignature(sig)
1262+
12301263
setPaymentStatus('Confirm in wallet...')
12311264
try {
12321265
await sendTransactionAsync({
@@ -1247,6 +1280,7 @@ function CheckoutContent() {
12471280
const maxAttempts = 5
12481281
const retryDelay = 8000
12491282

1283+
setIsVerifying(true)
12501284
setPaymentStatus(attempt > 1
12511285
? `Waiting for on-chain confirmation... (${attempt}/${maxAttempts})`
12521286
: 'Verifying payment...')
@@ -1263,6 +1297,9 @@ function CheckoutContent() {
12631297
chainId: paymentDetails.chainId,
12641298
symbol: paymentDetails.tokenSymbol,
12651299
tokenAddress: paymentDetails.tokenAddress,
1300+
...(paymentDetails.tokenSymbol === 'ETH' && ethPayerSignature && {
1301+
ethPayerSignature,
1302+
}),
12661303
}),
12671304
})
12681305

@@ -1293,6 +1330,7 @@ function CheckoutContent() {
12931330

12941331
setPurchaseError(data.error || 'Payment verification failed')
12951332
setPaymentStatus(null)
1333+
setIsVerifying(false)
12961334
} catch {
12971335
// Network error — auto-retry
12981336
if (attempt < maxAttempts) {
@@ -1301,6 +1339,7 @@ function CheckoutContent() {
13011339
}
13021340
setPurchaseError('Failed to verify payment')
13031341
setPaymentStatus(null)
1342+
setIsVerifying(false)
13041343
}
13051344
}
13061345

@@ -2143,6 +2182,7 @@ function CheckoutContent() {
21432182
className={`${css['asset-chip']} ${
21442183
tokenFilter === sym ? css['asset-chip--active'] : ''
21452184
}`}
2185+
disabled={isProcessing}
21462186
onClick={() => {
21472187
setTokenFilter(sym)
21482188
// Auto-select the best network for this asset
@@ -2180,7 +2220,7 @@ function CheckoutContent() {
21802220
type="button"
21812221
className={css['network-refresh']}
21822222
onClick={() => fetchPaymentOptions()}
2183-
disabled={paymentOptionsLoading}
2223+
disabled={paymentOptionsLoading || isProcessing}
21842224
>
21852225
Refresh balances
21862226
</button>
@@ -2204,8 +2244,8 @@ function CheckoutContent() {
22042244
className={`${css['network-row']} ${
22052245
isSelected ? css['network-row--selected'] : ''
22062246
} ${!canPay ? css['network-row--insufficient'] : ''}`}
2207-
disabled={!canPay}
2208-
onClick={() => canPay && selectPaymentOption(opt)}
2247+
disabled={!canPay || isProcessing}
2248+
onClick={() => canPay && !isProcessing && selectPaymentOption(opt)}
22092249
>
22102250
<span className={css['network-row-icon']}>
22112251
{NETWORK_LOGOS[chainIdNum] && (

devcon/src/services/x402.ts

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)