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
138 changes: 138 additions & 0 deletions apps/api/src/modules/cctp/cctp-self-relay.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
77 changes: 71 additions & 6 deletions apps/api/src/modules/cctp/cctp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-
Expand All @@ -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<string | undefined> {
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
Expand Down
68 changes: 64 additions & 4 deletions apps/api/src/modules/cctp/stellar-cctp.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TransactionBuilder,
nativeToScVal,
rpc as stellarRpc,
scValToNative,
xdr,
} from '@stellar/stellar-sdk';
import {
Expand Down Expand Up @@ -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<boolean> {
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<string>('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,
Expand Down
Loading