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,