fix(cctp): mint on Stellar ourselves, because nobody else will - #211
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last leg. A payment reached
completeattestation and stopped: status PROCESSING, no destination hash, merchant unpaid.What was happening
observe()treated Iris'sforwardTxHashas its finality signal and had no other route to a mint. For Stellar that hash never arrives. Iris returns onlyattestation,message,eventNonce,status,cctpVersionanddelayReasonfor this route — there is no Circle-operated relay watching it.Meanwhile the destination contract is wide open:
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
mintRecipientdecodes toCA66Q2WF…T4VSZ, exactly the configured testnetcctpForwarder, and its hookData carries the merchant'sG…address.The fix
observe()now submits the mint when the destination is Stellar and noforwardTxHashcame back.submitMintViaForwarderalready 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_usedis checked first. The worker retries, andmint_and_forwardrejects 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.Verified end to end
Settled the stuck payment through this exact code path:
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.