Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/api/src/modules/cctp/attestation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ interface IrisResponse {
attestation?: string;
forwardTxHash?: string;
errorMessage?: string;
/** bytes32. The only place a V2 nonce exists — see AttestationResponse. */
eventNonce?: string;
}>;
}

Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/modules/cctp/cctp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions apps/api/src/modules/cctp/evm-burn-parse.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).nonce,
).toBeUndefined();
});
});
44 changes: 39 additions & 5 deletions apps/api/src/modules/cctp/evm-cctp.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -199,14 +212,26 @@ 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 <chain> (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,
txHash: string,
): Promise<ParsedBurn | null> {
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;
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down
20 changes: 17 additions & 3 deletions apps/api/src/modules/cctp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/modules/payments/cctp.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
72 changes: 71 additions & 1 deletion apps/api/src/modules/quotes/quotes.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading