Skip to content

fix(cctp): mint on Stellar ourselves, because nobody else will - #211

Merged
0xdevcollins merged 1 commit into
mainfrom
fix/stellar-self-relay-mint
Aug 9, 2026
Merged

fix(cctp): mint on Stellar ourselves, because nobody else will#211
0xdevcollins merged 1 commit into
mainfrom
fix/stellar-self-relay-mint

Conversation

@0xdevcollins

Copy link
Copy Markdown
Owner

The last leg. A payment reached complete attestation and stopped: status PROCESSING, no destination hash, merchant unpaid.

What was happening

observe() treated Iris's forwardTxHash as its finality signal and had no other route 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 the destination contract is wide open:

fn mint_and_forward(env: Env, message: Bytes, attestation: Bytes);

No authorisation argument. Anyone may submit an attested message, and the contract forwards the USDC to the recipient encoded in the burn's hookData.

So the mint was permissionless and unclaimed, and we were sitting waiting to be told it had happened.

Everything else checked out, which is what took the time to establish: the burn's mintRecipient decodes to CA66Q2WF…T4VSZ, exactly the configured testnet cctpForwarder, and its hookData carries the merchant's G… address.

The fix

observe() now submits the mint when the destination is Stellar and no forwardTxHash came back. submitMintViaForwarder already existed — written as a "fallback if Circle is degraded", never called.

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:

  • 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. Read-only simulation, 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.

Verified end to end

Settled the stuck payment through this exact code path:

is_nonce_used  → false
mint submitted → b94b0d00a4af5850baad991a1de062c7695434ee28f27688b7a68ac889df7629  (successful, ledger 4059308)
merchant USDC  → 20.0000000 → 20.0100000
is_nonce_used  → true

Tests: 322 (+5) — mints when Circle hasn't, defers when it has, skips an already-consumed message, survives a failed submit, and won't attempt a mint with no message. 0 lint errors.

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
useroutr-www Ready Ready Preview Aug 9, 2026 11:20pm

@0xdevcollins
0xdevcollins merged commit b385253 into main Aug 9, 2026
8 checks passed
@0xdevcollins
0xdevcollins deleted the fix/stellar-self-relay-mint branch August 9, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant