From 140093c14af628fe456d9517869287d44085da6c Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 9 Aug 2026 23:22:39 +0100 Subject: [PATCH] fix(cctp): decode the V2 burn event, and stop expiring quotes mid-payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate faults, both hit while paying from MetaMask on Sepolia. ## "not found on ethereum (or not a CCTP burn)" for a real, successful burn `TOKEN_MESSENGER_V2_ABI` declared `DepositForBurn` V1-shaped, with a leading `uint64 indexed nonce`. CCTP V2 does not emit that field. One extra parameter changes the whole topic hash — 0xe3db7593… instead of the 0x0c8c1cbd… chains actually log — so no receipt ever matched and every EVM burn was reported as "not a CCTP burn". Successful ones included. Verified against tx 0xce93dc50…c46ca1c9 on Sepolia (block 11454876, status 0x1): it now decodes to 0.01 USDC, destination domain 27 (Stellar), with the depositor intact. Iris had the burn all along — cctpVersion 2, its own eventNonce, waiting on confirmations. The nonce goes with it. V2 assigns no nonce at burn time: the on-chain MessageSent payload carries a zero placeholder, and Circle stamps the real one during attestation. `ParsedBurn.nonce` is `bigint | null` accordingly, and the processor now prefers Iris's `eventNonce`, falling back to the burn's own — Stellar still surfaces one — so `cctpNonce` keeps being populated rather than quietly becoming a column of nulls. Also split the two null paths in `parseBurnReceipt`. They shared one message that led with "not found", so a real burn that merely failed to decode sent you auditing RPC config instead of the ABI. Each now logs what it actually saw, and the no-match case names the topic it wanted. ## Quotes expiring during the payment they were quoted for The TTL was 30 seconds for everything. A wallet payment is read-quote, approve (a transaction, mined), then burn — comfortably longer than that on a busy testnet, so the quote died mid-flow and the payer got an error for doing nothing wrong. 30 seconds prices in rate risk. That risk is real across assets and absent within one: USDC → USDC quotes at exactly 1 now and in ten minutes, and the only other variable is a fixed bps of the merchant's own config. Same-asset quotes now get 600s; anything cross-asset keeps 30. Tests: 303 (+6). The burn spec is pinned to the real Sepolia log and imports the client's own ABI rather than a copy — reverting the ABI fails all three of its cases, which was checked rather than assumed. Co-Authored-By: Claude Opus 5 --- .../src/modules/cctp/attestation.service.ts | 5 +- apps/api/src/modules/cctp/cctp.service.ts | 8 +- .../src/modules/cctp/evm-burn-parse.spec.ts | 82 +++++++++++++++++++ apps/api/src/modules/cctp/evm-cctp.client.ts | 44 ++++++++-- apps/api/src/modules/cctp/types.ts | 20 ++++- .../src/modules/payments/cctp.processor.ts | 9 +- .../src/modules/quotes/quotes.service.spec.ts | 72 +++++++++++++++- apps/api/src/modules/quotes/quotes.service.ts | 36 +++++++- 8 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/modules/cctp/evm-burn-parse.spec.ts diff --git a/apps/api/src/modules/cctp/attestation.service.ts b/apps/api/src/modules/cctp/attestation.service.ts index 4c2892a..a430c21 100644 --- a/apps/api/src/modules/cctp/attestation.service.ts +++ b/apps/api/src/modules/cctp/attestation.service.ts @@ -154,6 +154,8 @@ interface IrisResponse { attestation?: string; forwardTxHash?: string; errorMessage?: string; + /** bytes32. The only place a V2 nonce exists — see AttestationResponse. */ + eventNonce?: string; }>; } @@ -174,6 +176,7 @@ function normalizeIrisResponse(raw: IrisResponse): AttestationResponse { message: msg.message, attestation: msg.attestation, forwardTxHash: msg.forwardTxHash, + eventNonce: msg.eventNonce, }; } if (status === 'failed' || msg.errorMessage) { @@ -182,7 +185,7 @@ function normalizeIrisResponse(raw: IrisResponse): AttestationResponse { error: msg.errorMessage ?? 'attestation failed (no detail from Iris)', }; } - return { status: 'pending_confirmations' }; + return { status: 'pending_confirmations', eventNonce: msg.eventNonce }; } function backoff(attempt: number): number { diff --git a/apps/api/src/modules/cctp/cctp.service.ts b/apps/api/src/modules/cctp/cctp.service.ts index 05ec583..e57ccaa 100644 --- a/apps/api/src/modules/cctp/cctp.service.ts +++ b/apps/api/src/modules/cctp/cctp.service.ts @@ -276,7 +276,13 @@ export class CctpService { /** Shape both EVM and Stellar burn parsers collapse into. */ interface NormalizedBurn { - nonce: bigint; + /** + * Null for EVM burns: CCTP V2 removed the nonce from `DepositForBurn`, and + * Circle assigns it at attestation time instead. Stellar's burn event still + * carries one. Callers that need it should prefer the attestation's + * `eventNonce` and fall back to this. + */ + nonce: bigint | null; /** Always CCTP 6-decimal subunits (Stellar scaling is unwound on parse). */ amount: bigint; depositor: string; diff --git a/apps/api/src/modules/cctp/evm-burn-parse.spec.ts b/apps/api/src/modules/cctp/evm-burn-parse.spec.ts new file mode 100644 index 0000000..a4c12b0 --- /dev/null +++ b/apps/api/src/modules/cctp/evm-burn-parse.spec.ts @@ -0,0 +1,82 @@ +import { ethers } from 'ethers'; +import { TOKEN_MESSENGER_V2_ABI } from './evm-cctp.client'; + +/** + * The `DepositForBurn` log from tx 0xce93dc50…c46ca1c9 on Sepolia + * (block 11454876, status 0x1), copied verbatim off the chain. + */ +const fixture = { + depositForBurnLog: { + topics: [ + '0x0c8c1cbdc5190613ebd485511d4e2812cfa45eecb79d845893331fedad5130a5', + '0x0000000000000000000000001c7d4b196cb0c7b01d743fbc6116a902379c7238', + '0x0000000000000000000000001eeb66317dea19f0c655a55c55f8c6293d488114', + '0x00000000000000000000000000000000000000000000000000000000000003e8', + ], + data: '0x00000000000000000000000000000000000000000000000000000000000027103de86ac50b47eaf2840fe23e48179551660fd1072fba6f445d4a6bd7af4ab93e000000000000000000000000000000000000000000000000000000000000001bda6f9ee0786c812344d82817ef19b648b4af120f8bd10bf658e6b99eacff24b80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000003847414855325a48505357574d554e53423355424844504452425a5345514b5348495156523252375741564a4e5a4535574e515659584958440000000000000000', + }, +}; + +/** + * Pinned against a real burn. + * + * The `DepositForBurn` ABI here was V1-shaped — it declared a leading + * `uint64 indexed nonce` that CCTP V2 does not emit. That single extra field + * changes the event's topic hash, so no log ever matched and every EVM burn was + * reported as "not a CCTP burn", successful ones included. A unit test written + * from the same wrong ABI would have agreed with the bug, so the fixture is a + * receipt taken off Sepolia rather than a hand-built log: + * + * tx 0xce93dc50…c46ca1c9, block 11454876, status 0x1 + * + * If these topics stop decoding, the ABI has drifted from the deployed + * contract again — which is exactly the failure this is here to catch. + */ +describe('EvmCctpClient burn parsing (real Sepolia receipt)', () => { + // The client's own ABI, not a copy of it. A local copy would keep passing + // while the real one drifted, which is the failure this exists to catch. + // Parsing directly rather than through parseBurnReceipt keeps the test off + // the network — the receipt lookup is not what broke. + const iface = new ethers.Interface(TOKEN_MESSENGER_V2_ABI); + + it('the deployed contract emits the V2 topic, not the V1 one', () => { + const v1Style = ethers.id( + 'DepositForBurn(uint64,address,uint256,address,bytes32,uint32,bytes32,bytes32,uint256,uint32,bytes)', + ); + + expect(fixture.depositForBurnLog.topics[0]).toBe( + iface.getEvent('DepositForBurn')!.topicHash, + ); + expect(fixture.depositForBurnLog.topics[0]).not.toBe(v1Style); + }); + + it('decodes the burn a customer actually signed', () => { + const parsed = iface.parseLog({ + topics: [...fixture.depositForBurnLog.topics], + data: fixture.depositForBurnLog.data, + }); + + expect(parsed).not.toBeNull(); + // 0.01 USDC in 6-decimal subunits — the amount on that testnet burn. + expect(parsed!.args.amount).toBe(10000n); + // Domain 27 is Stellar: this burn was headed where we think it was. + expect(Number(parsed!.args.destinationDomain)).toBe(27); + expect((parsed!.args.burnToken as string).toLowerCase()).toBe( + '0x1c7d4b196cb0c7b01d743fbc6116a902379c7238', + ); + expect(parsed!.args.depositor).toBeDefined(); + }); + + it('exposes no nonce, because V2 does not emit one', () => { + const parsed = iface.parseLog({ + topics: [...fixture.depositForBurnLog.topics], + data: fixture.depositForBurnLog.data, + }); + + // Reading `.nonce` off a V2 event yields undefined rather than throwing, + // which is how the old code produced a nonce column full of nothing. + expect( + (parsed!.args as unknown as Record).nonce, + ).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/cctp/evm-cctp.client.ts b/apps/api/src/modules/cctp/evm-cctp.client.ts index afab6f3..ba50063 100644 --- a/apps/api/src/modules/cctp/evm-cctp.client.ts +++ b/apps/api/src/modules/cctp/evm-cctp.client.ts @@ -17,9 +17,15 @@ import type { CctpTransferRequest } from './types.js'; * signature is included because we parse it from receipts to extract * the burn nonce (needed for Iris attestation lookup). */ -const TOKEN_MESSENGER_V2_ABI = [ +export const TOKEN_MESSENGER_V2_ABI = [ 'function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData) returns (uint64 nonce)', - 'event DepositForBurn(uint64 indexed nonce, address indexed burnToken, uint256 amount, address indexed depositor, bytes32 mintRecipient, uint32 destinationDomain, bytes32 destinationTokenMessenger, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData)', + // V2's DepositForBurn, which carries NO nonce — the third indexed field is + // minFinalityThreshold. This was previously declared V1-style, with a leading + // `uint64 indexed nonce`, and that one difference changes the whole topic + // hash: 0xe3db7593… instead of the 0x0c8c1cbd… chains actually emit. Nothing + // ever matched, so every EVM burn came back "not a CCTP burn" — including + // real, successful ones. + 'event DepositForBurn(address indexed burnToken, uint256 amount, address indexed depositor, bytes32 mintRecipient, uint32 destinationDomain, bytes32 destinationTokenMessenger, bytes32 destinationCaller, uint256 maxFee, uint32 indexed minFinalityThreshold, bytes hookData)', ] as const; /** @@ -56,7 +62,14 @@ export interface EvmTransactionPayload { * `nonce` is what Iris keys attestations by, so it's the critical bit. */ export interface ParsedBurn { - nonce: bigint; + /** + * Null on EVM. CCTP V2 does not assign the nonce at burn time — the + * `MessageSent` payload carries a zero placeholder, and Circle stamps the + * real value when it attests, so it arrives later as Iris's `eventNonce`. + * Stellar's burn event does surface one, which is why this is nullable + * rather than gone. + */ + nonce: bigint | null; amount: bigint; depositor: string; mintRecipient: string; @@ -199,6 +212,12 @@ export class EvmCctpClient { * and return the structured fields downstream code needs. Returns * `null` if the tx isn't found or has no matching event (e.g., wrong * tx hash, not a CCTP burn). + * + * The two null cases are logged separately. Callers collapse them into one + * message — "not found on (or not a CCTP burn)" — and while that + * covers both, it points at the wrong one first: it reads as a bad tx hash + * or wrong network, so a real burn that simply failed to decode sends you + * hunting the RPC config instead of the ABI. */ async parseBurnReceipt( chainId: string, @@ -206,7 +225,13 @@ export class EvmCctpClient { ): Promise { const provider = this.providerFor(chainId); const receipt = await provider.getTransactionReceipt(txHash); - if (!receipt) return null; + if (!receipt) { + this.logger.warn( + `No receipt for ${txHash} on ${chainId} at ${this.rpcUrlFor(chainId)} — ` + + `unmined, dropped, or a hash from a different network.`, + ); + return null; + } const iface = new ethers.Interface(TOKEN_MESSENGER_V2_ABI); const expectedTopic = iface.getEvent('DepositForBurn')!.topicHash; @@ -219,7 +244,8 @@ export class EvmCctpClient { }); if (!parsed) continue; return { - nonce: parsed.args.nonce as bigint, + // Not in the V2 event; Iris supplies it at attestation time. + nonce: null, amount: parsed.args.amount as bigint, depositor: parsed.args.depositor as string, mintRecipient: parsed.args.mintRecipient as string, @@ -228,6 +254,14 @@ export class EvmCctpClient { }; } + // The receipt exists, so the tx is real and on the right chain — it just + // holds no log we recognise. Naming the topic we looked for turns an ABI + // mismatch into a one-line diff rather than a hunt. + this.logger.warn( + `Receipt ${txHash} on ${chainId} has ${receipt.logs.length} log(s) but no ` + + `DepositForBurn matching ${expectedTopic}. Either it is not a CCTP burn, ` + + `or the TokenMessenger ABI here has drifted from the deployed contract.`, + ); return null; } diff --git a/apps/api/src/modules/cctp/types.ts b/apps/api/src/modules/cctp/types.ts index 1001f0e..795d046 100644 --- a/apps/api/src/modules/cctp/types.ts +++ b/apps/api/src/modules/cctp/types.ts @@ -103,10 +103,15 @@ export interface CctpBurnResult { sourceDomain: number; /** * Circle's nonce for this burn. Together with the source domain it - * uniquely identifies the message — used as the idempotency key for - * attestation lookups. + * uniquely identifies the message. + * + * Null for EVM burns: CCTP V2 dropped the nonce from `DepositForBurn`, so it + * does not exist at burn time — Circle stamps it during attestation and + * returns it as `AttestationResponse.eventNonce`. Not the idempotency key for + * attestation lookups either, despite what this comment used to claim: those + * go to Iris by `transactionHash`. */ - nonce: bigint; + nonce: bigint | null; } /** State of an attestation as reported by iris-api. */ @@ -120,6 +125,15 @@ export interface AttestationResponse { attestation?: string; /** Set when status === 'failed'. Plain English. */ error?: string; + /** + * bytes32 nonce Circle assigns when it observes the burn. + * + * On CCTP V2 this is the only place a nonce exists for an EVM burn: the + * `DepositForBurn` event does not carry one, and the on-chain `MessageSent` + * payload holds a zero placeholder until attestation. Present on pending + * responses too — Iris assigns it before finality is reached. + */ + eventNonce?: string; /** * When `mintMode: 'forwarder'`, this populates once Circle's forwarder * has broadcast the mint on the destination. Treat it as "fully diff --git a/apps/api/src/modules/payments/cctp.processor.ts b/apps/api/src/modules/payments/cctp.processor.ts index f1a86e0..cb8fab4 100644 --- a/apps/api/src/modules/payments/cctp.processor.ts +++ b/apps/api/src/modules/payments/cctp.processor.ts @@ -68,8 +68,15 @@ export class CctpProcessor extends WorkerHost { // Step 1: PROCESSING — attestation is in. Record the nonce + the // attestation blob (used later for refunds + audits). + // Iris's `eventNonce` first: on CCTP V2 an EVM burn has no nonce of its + // own, because `DepositForBurn` no longer carries one and Circle assigns + // it during attestation. Stellar burns still surface theirs, so fall back + // to that rather than dropping the column on the floor. await this.payments.updateStatus(paymentId, PaymentStatus.PROCESSING, { - cctpNonce: record.burn.nonce.toString(), + cctpNonce: + record.attestation.eventNonce ?? + record.burn.nonce?.toString() ?? + null, cctpAttestation: record.attestation.attestation ?? null, }); diff --git a/apps/api/src/modules/quotes/quotes.service.spec.ts b/apps/api/src/modules/quotes/quotes.service.spec.ts index 263486a..3770356 100644 --- a/apps/api/src/modules/quotes/quotes.service.spec.ts +++ b/apps/api/src/modules/quotes/quotes.service.spec.ts @@ -147,13 +147,83 @@ describe('QuotesService', () => { expect(result.fee).toBe('0.5'); expect(result.feeBps).toBe(50); expect(result.expiresInSeconds).toBeGreaterThan(0); + // USDC → USDC: the rate cannot drift, so this gets the long window that + // survives a wallet's approve-then-burn round trip. It was 30s, which is + // less than that round trip takes. expect(redisMock.setex).toHaveBeenCalledWith( 'quote:quote-1', - 30, + 600, expect.any(String), ); }); + // The window a payer actually gets. A wallet flow is read-quote → approve + // (mined) → burn; 30 seconds did not cover that, so quotes expired + // mid-payment and the payer saw an error for doing nothing wrong. + describe('expiry window', () => { + beforeEach(() => { + prisma.merchant.findUnique.mockResolvedValue(mockMerchant); + bridgeRouterMock.findRoute.mockReturnValue({ + provider: 'cctp', + estimatedTimeMs: 30000, + estimatedFeeBps: 0, + }); + prisma.quote.create.mockResolvedValue(mockQuoteData); + }); + + it('gives same-asset transfers room for a wallet round trip', async () => { + await service.createQuote( + { + fromChain: 'ethereum' as Chain, + fromAsset: 'USDC', + fromAmount: '100', + }, + 'merchant-1', + ); + + const [, ttl] = redisMock.setex.mock.calls[0] as [string, number]; + expect(ttl).toBe(600); + }); + + it('keeps the short window when the assets differ and the rate can move', async () => { + await service.createQuote( + { + fromChain: 'ethereum' as Chain, + fromAsset: 'ETH', + fromAmount: '1', + }, + 'merchant-1', + ); + + const [, ttl] = redisMock.setex.mock.calls[0] as [string, number]; + expect(ttl).toBe(30); + }); + + it('writes the same window to the row as to the Redis lock', async () => { + const before = Date.now(); + await service.createQuote( + { + fromChain: 'ethereum' as Chain, + fromAsset: 'USDC', + fromAmount: '100', + }, + 'merchant-1', + ); + + const createCall = prisma.quote.create.mock.calls[0] as [ + { data: { expiresAt: Date } }, + ]; + const [, ttl] = redisMock.setex.mock.calls[0] as [string, number]; + const rowSeconds = Math.round( + (createCall[0].data.expiresAt.getTime() - before) / 1000, + ); + + // A row that outlives its lock (or vice versa) means one of the two + // decides expiry and the other lies about it. + expect(rowSeconds).toBe(ttl); + }); + }); + it('should apply default toChain and toAsset from merchant', async () => { prisma.merchant.findUnique.mockResolvedValue(mockMerchant); diff --git a/apps/api/src/modules/quotes/quotes.service.ts b/apps/api/src/modules/quotes/quotes.service.ts index 7c3872a..150ef9e 100644 --- a/apps/api/src/modules/quotes/quotes.service.ts +++ b/apps/api/src/modules/quotes/quotes.service.ts @@ -18,8 +18,35 @@ import { Chain } from '@useroutr/types'; import { CreateQuoteDto } from './dto/create-quote.dto'; import { QuoteResponseDto } from './dto/quote-response.dto'; +/** + * How long a quote stays valid. + * + * A quote is locked because the price can move between quoting and executing. + * That risk is real when the assets differ, and it is why this was 30 seconds. + * + * It is not real when they don't. USDC → USDC quotes at a rate of exactly 1 + * whether it is read now or ten minutes from now; the only variable is the fee, + * which is a fixed bps of the merchant's own configuration. Holding those to 30 + * seconds priced in a risk that does not exist — and 30 seconds is less than a + * wallet round trip. Paying from MetaMask means reading the quote, approving + * the token (one transaction, mined), then burning (a second): comfortably past + * 30s on a busy testnet, so the quote expired mid-flow and the payer got an + * error for doing nothing wrong. + */ const QUOTE_TTL_SECONDS = 30; +/** + * Same-asset transfers, where the rate cannot drift. Sized for a two-signature + * wallet flow with room for a slow block, not for a machine-driven API caller. + */ +const SAME_ASSET_QUOTE_TTL_SECONDS = 600; + +function quoteTtlSeconds(fromAsset: string, toAsset: string): number { + return fromAsset === toAsset + ? SAME_ASSET_QUOTE_TTL_SECONDS + : QUOTE_TTL_SECONDS; +} + @Injectable() export class QuotesService { private readonly logger = new Logger(QuotesService.name); @@ -112,7 +139,8 @@ export class QuotesService { const estimatedTimeMs = routeDecision.estimatedTimeMs; // Create quote with TTL - const expiresAt = new Date(Date.now() + QUOTE_TTL_SECONDS * 1000); + const ttlSeconds = quoteTtlSeconds(dto.fromAsset, toAsset); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); const quote = await this.prisma.quote.create({ data: { @@ -135,15 +163,15 @@ export class QuotesService { // Lock in Redis with TTL await this.redis.setex( `quote:${quote.id}`, - QUOTE_TTL_SECONDS, + ttlSeconds, JSON.stringify({ locked: true, createdAt: Date.now() }), ); this.logger.debug( - `Quote ${quote.id} created and locked for ${QUOTE_TTL_SECONDS}s`, + `Quote ${quote.id} created and locked for ${ttlSeconds}s`, ); - return this.toResponseDto(quote, QUOTE_TTL_SECONDS, estimatedTimeMs); + return this.toResponseDto(quote, ttlSeconds, estimatedTimeMs); } catch (error) { this.logger.error('Error creating quote:', error); throw error;