From fa4098bab32c131d87914cd97e84cb17ebb38be1 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 10 Aug 2026 00:18:56 +0100 Subject: [PATCH] fix(cctp): mint on Stellar ourselves, because nobody else will MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payment reached `complete` attestation and stopped there. Status PROCESSING, no destination hash, merchant unpaid — one step short of done. `observe()` treated Iris's `forwardTxHash` as the finality signal and had no other path to a mint. For Stellar that hash never arrives. Iris returns only `attestation`, `message`, `eventNonce`, `status`, `cctpVersion` and `delayReason` for this route — there is no Circle-operated relay watching it. Meanwhile `CctpForwarder.mint_and_forward(message, attestation)` takes no authorisation argument at all: anyone may submit an attested message, and the contract forwards the USDC to the recipient in the burn's hookData. The mint was permissionless and unclaimed, and we were waiting to be told it had happened. So we submit it. `submitMintViaForwarder` already existed, written as a "fallback if Circle is degraded" and never called; `observe()` now calls it whenever the destination is Stellar and no forwardTxHash came back. The relay wallet pays the Soroban fee and nothing else — the USDC comes out of the attested message, so it never holds funds. Two things make the retry safe: `MessageTransmitter.is_nonce_used` is checked first. The worker retries, and `mint_and_forward` rejects a message it has already consumed — without the check, a successful mint would be indistinguishable from a broken one on the next attempt and the payment would be marked FAILED after the money arrived. Added as a read-only simulation, so it costs nothing. A failed submit returns undefined rather than throwing. Un-minted is a better state than failed: the next attempt can still settle it, and a genuinely broken mint resurfaces with the same error. Verified by settling the stuck payment through this path: nonce false → mint b94b0d00…df7629 → merchant USDC 20.0000000 → 20.0100000 → nonce true. Tests: 322 (+5). Co-Authored-By: Claude Opus 5 --- .../src/modules/cctp/cctp-self-relay.spec.ts | 138 ++++++++++++++++++ apps/api/src/modules/cctp/cctp.service.ts | 77 +++++++++- .../src/modules/cctp/stellar-cctp.client.ts | 68 ++++++++- 3 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/modules/cctp/cctp-self-relay.spec.ts diff --git a/apps/api/src/modules/cctp/cctp-self-relay.spec.ts b/apps/api/src/modules/cctp/cctp-self-relay.spec.ts new file mode 100644 index 0000000..a0f5d86 --- /dev/null +++ b/apps/api/src/modules/cctp/cctp-self-relay.spec.ts @@ -0,0 +1,138 @@ +import type { ConfigService } from '@nestjs/config'; +import { CctpService } from './cctp.service'; +import type { AttestationService } from './attestation.service'; +import type { ForwarderService } from './forwarder.service'; +import type { EvmCctpClient } from './evm-cctp.client'; +import type { StellarCctpClient } from './stellar-cctp.client'; + +/** + * The last leg of a Stellar-destined payment. + * + * Circle's Forwarding Service returns no `forwardTxHash` for this route — + * `mint_and_forward` on the Stellar CctpForwarder takes no authorisation and + * nobody is watching on our behalf. So an attested message sat unminted and the + * payment stopped at PROCESSING, one step short of the merchant being paid. + * These cases pin the behaviour that fixed it. + */ +describe('CctpService — Stellar self-relay mint', () => { + const BURN_TX = '0xburn'; + const NONCE = '0x' + 'ab'.repeat(32); + + function build(overrides: { + isNonceUsed?: jest.Mock; + submitMintViaForwarder?: jest.Mock; + forwardTxHash?: string; + /** Explicit null means "Iris returned no message at all". */ + message?: string | null; + signature?: string | null; + }) { + const stellar = { + isNonceUsed: overrides.isNonceUsed ?? jest.fn().mockResolvedValue(false), + submitMintViaForwarder: + overrides.submitMintViaForwarder ?? + jest.fn().mockResolvedValue('stellar_tx_hash'), + parseBurnEvent: jest.fn(), + } as unknown as StellarCctpClient; + + const evm = { + // Domain 27 is Stellar. Amount/recipient are incidental here. + parseBurnReceipt: jest.fn().mockResolvedValue({ + nonce: null, + amount: 10_000n, + depositor: '0xdepositor', + mintRecipient: '0xforwarder', + destinationDomain: 27, + maxFee: 1n, + }), + } as unknown as EvmCctpClient; + + const attestation = { + pollUntilReady: jest.fn().mockResolvedValue({ + status: 'complete', + message: + overrides.message === null + ? undefined + : (overrides.message ?? '0xmessage'), + attestation: + overrides.signature === null + ? undefined + : (overrides.signature ?? '0xsignature'), + eventNonce: NONCE, + forwardTxHash: overrides.forwardTxHash, + }), + } as unknown as AttestationService; + + const service = new CctpService( + attestation, + {} as ForwarderService, + evm, + stellar, + { + get: jest.fn((k: string) => + k === 'STELLAR_NETWORK' ? 'testnet' : undefined, + ), + } as unknown as ConfigService, + ); + + return { service, stellar }; + } + + it('mints on Stellar when Circle has not', async () => { + const { service, stellar } = build({}); + + const record = await service.observe(BURN_TX, 'ethereum'); + + expect(stellar.submitMintViaForwarder).toHaveBeenCalledWith( + '0xmessage', + '0xsignature', + ); + expect(record.mintTxHash).toBe('stellar_tx_hash'); + }); + + it('leaves it to Circle when a forwardTxHash is present', async () => { + const { service, stellar } = build({ forwardTxHash: '0xcircle' }); + + const record = await service.observe(BURN_TX, 'ethereum'); + + expect(stellar.submitMintViaForwarder).not.toHaveBeenCalled(); + expect(record.mintTxHash).toBe('0xcircle'); + }); + + // The worker retries, and `mint_and_forward` rejects a message it has already + // consumed. Without the nonce check, a successful mint would look identical + // to a broken one on the next attempt and the payment would eventually be + // marked FAILED after the money had already arrived. + it('does not resubmit a message that was already minted', async () => { + const { service, stellar } = build({ + isNonceUsed: jest.fn().mockResolvedValue(true), + }); + + await service.observe(BURN_TX, 'ethereum'); + + expect(stellar.submitMintViaForwarder).not.toHaveBeenCalled(); + }); + + // A payment that is merely un-minted is in a better state than one marked + // failed: the next attempt can still settle it. + it('reports no mint rather than throwing when the submit fails', async () => { + const { service } = build({ + submitMintViaForwarder: jest + .fn() + .mockRejectedValue(new Error('soroban unavailable')), + }); + + const record = await service.observe(BURN_TX, 'ethereum'); + + expect(record.mintTxHash).toBeUndefined(); + expect(record.attestation.status).toBe('complete'); + }); + + it('does not attempt a mint without a message to submit', async () => { + const { service, stellar } = build({ message: null }); + + const record = await service.observe(BURN_TX, 'ethereum'); + + expect(stellar.submitMintViaForwarder).not.toHaveBeenCalled(); + expect(record.mintTxHash).toBeUndefined(); + }); +}); diff --git a/apps/api/src/modules/cctp/cctp.service.ts b/apps/api/src/modules/cctp/cctp.service.ts index e57ccaa..8698eb0 100644 --- a/apps/api/src/modules/cctp/cctp.service.ts +++ b/apps/api/src/modules/cctp/cctp.service.ts @@ -13,7 +13,11 @@ import { } from './stellar-cctp.client.js'; import { getDomain, enabledDomains } from './domains.js'; import { STELLAR_CCTP, cctpEnvFromStellarNetwork } from './contracts.js'; -import type { CctpTransferRecord, CctpTransferRequest } from './types.js'; +import type { + AttestationResponse, + CctpTransferRecord, + CctpTransferRequest, +} from './types.js'; /** * High-level orchestrator for CCTP V2 transfers — the only surface PR C @@ -131,11 +135,23 @@ export class CctpService { ); } - // Step 3 — record the settlement. If Iris has a forwardTxHash, the - // mint is already on-chain. Otherwise the caller is on self-relay - // and would dispatch the mint themselves (not done here — kept - // separate so the service stays composable). + // Step 3 — get the mint on-chain. + // + // If Iris carries a forwardTxHash, Circle already broadcast it. For Stellar + // it never does: `mint_and_forward` on the CctpForwarder takes no + // authorisation and nobody is watching for us, so an attested message just + // sits there. Payments reached `complete` attestation and then stopped, + // one step short of the merchant actually being paid. + // + // So we submit it. The relay wallet pays the Soroban fee and nothing else — + // the USDC comes out of the attested message. const dest = getDomainByDomainNumber(burn.destinationDomain); + let mintTxHash = attestation.forwardTxHash; + + if (!mintTxHash && dest?.kind === 'stellar') { + mintTxHash = await this.selfRelayStellarMint(attestation, txHash); + } + return { request: { // Reconstructed from the burn — sufficient for downstream record- @@ -156,10 +172,59 @@ export class CctpService { nonce: burn.nonce, }, attestation, - mintTxHash: attestation.forwardTxHash, + mintTxHash, }; } + /** + * Submit the Stellar mint ourselves, once, and report the tx hash. + * + * Returns undefined rather than throwing when the mint cannot be made: the + * caller retries, and a payment that is merely un-minted is in a better state + * than one marked failed. A genuinely failed mint surfaces on the next + * attempt with the same error. + */ + private async selfRelayStellarMint( + attestation: AttestationResponse, + burnTxHash: string, + ): Promise { + const { message, attestation: signature, eventNonce } = attestation; + + if (!message || !signature) { + this.logger.warn( + `Attestation for ${burnTxHash} is complete but carries no message/signature — cannot mint.`, + ); + return undefined; + } + + try { + // The worker retries, and `mint_and_forward` rejects a message it has + // already consumed. Without this check a successful mint would look + // identical to a broken one on the following attempt, and the payment + // would eventually be marked FAILED after the money had arrived. + if (eventNonce && (await this.stellar.isNonceUsed(eventNonce))) { + this.logger.log( + `CCTP message for ${burnTxHash} was already minted on Stellar — nothing to do.`, + ); + return undefined; + } + + const hash = await this.stellar.submitMintViaForwarder( + message, + signature, + ); + this.logger.log(`Minted ${burnTxHash} on Stellar via forwarder: ${hash}`); + return hash; + } catch (err) { + this.logger.warn( + `Stellar self-relay mint failed for ${burnTxHash}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return undefined; + } + } + /** * Enabled (source, destination) pairs for the quote engine. Excludes * same-chain self-routes and any chain that's flagged disabled in diff --git a/apps/api/src/modules/cctp/stellar-cctp.client.ts b/apps/api/src/modules/cctp/stellar-cctp.client.ts index 6671485..f1f9904 100644 --- a/apps/api/src/modules/cctp/stellar-cctp.client.ts +++ b/apps/api/src/modules/cctp/stellar-cctp.client.ts @@ -9,6 +9,7 @@ import { TransactionBuilder, nativeToScVal, rpc as stellarRpc, + scValToNative, xdr, } from '@stellar/stellar-sdk'; import { @@ -227,14 +228,73 @@ export class StellarCctpClient { /* ────────────────────────────── 3. Self-relay mint ────────────── */ + /** + * Has this message already been minted on Stellar? + * + * `MessageTransmitter.is_nonce_used` is the destination side's replay guard, + * and it is the only honest way to make a retried mint idempotent. Submitting + * a message twice fails, and a worker that retries has no way to tell "this + * failed" from "this already worked" without asking. + * + * Read-only: simulated, never submitted, so it costs nothing and needs no + * signature. + */ + async isNonceUsed(nonceHex: string): Promise { + const contracts = getStellarContracts(this.env); + const server = this.server(); + + const nonce = Buffer.from(strip0x(nonceHex), 'hex'); + if (nonce.length !== 32) { + throw new Error( + `CCTP nonce must be 32 bytes, got ${nonce.length} (${nonceHex})`, + ); + } + + // Any funded account works as the simulation source — nothing is signed or + // submitted, and the ledger is not touched. + const relaySecret = this.config.get('STELLAR_RELAY_KEYPAIR_SECRET'); + if (!relaySecret) { + throw new Error( + 'checking a CCTP nonce requires STELLAR_RELAY_KEYPAIR_SECRET', + ); + } + const keypair = Keypair.fromSecret(relaySecret); + const account = await server.getAccount(keypair.publicKey()); + + const contract = new Contract(contracts.messageTransmitter); + const tx = new TransactionBuilder(account, { + fee: DEFAULT_BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('is_nonce_used', xdr.ScVal.scvBytes(nonce))) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (stellarRpc.Api.isSimulationError(sim)) { + throw new Error(`is_nonce_used simulation failed: ${sim.error}`); + } + const retval = sim.result?.retval; + if (!retval) { + throw new Error('is_nonce_used returned no value'); + } + return scValToNative(retval) === true; + } + /** * Submit `CctpForwarder.mint_and_forward(message, attestation)` on - * Stellar — the self-relay path for an inbound mint. Not used when - * Circle's Forwarding Service is enabled (which it is in v1), but - * present so we have a fallback if Circle is degraded. + * Stellar. + * + * This is the path that actually settles a Stellar-destined payment. + * `mint_and_forward` takes no authorisation argument — anyone may submit an + * attested message, and the contract forwards the minted USDC to the + * recipient encoded in the burn's hookData. Nobody does it for us: Circle's + * Forwarding Service returns no `forwardTxHash` for this route, so a payment + * whose attestation completed simply sat unminted until someone called this. * * Requires a Stellar relay keypair (`STELLAR_RELAY_KEYPAIR_SECRET`) - * funded with XLM on the appropriate network. + * funded with XLM on the appropriate network. It pays the fee only; the + * USDC comes from the attested message, so this wallet never holds funds. */ async submitMintViaForwarder( message: string,