Skip to main content

The intent lifecycle

An intent is one user-signed transaction CoRelayer was asked to relay, keyed by (sender, nonce). This page follows it from the moment a lease is issued to the moment the chain decides — and through the recovery steps in between. Delivery guarantees covers what those states promise; this page covers how an intent moves between them.

The state machine​

StateHeld whereMoves on
ASSIGNEDOnly by you: a lease in hand. The server keeps nothing and reserves nothing.Your POST /v1/relay.
RECEIVEDThe API, while it checks your bytes.Any failed check → REJECTED.
ACCEPTEDUnits and relayer exposure reserved, about to be co-signed.The signer's answer.
COSIGNEDThe commit point has passed: the relayer signature exists.A gateway acknowledgement, a lost slot, or the end of the broadcast window.
BROADCASTAt least one gateway returned our hash. includedInBlock fills in once a block carries it.Chain facts.
EXECUTED_OK / EXECUTED_FAILOn chain. final turns true when the block is final — that is the billing trigger.Nothing: terminal. A revert before finality moves it back to BROADCAST.
EXPIRED_LOCALLY_STILL_VALIDNot terminal. We stopped re-broadcasting; the transaction is still valid.Same bytes, Cancel, Replace, or chain facts.
DEADThe sender's nonce moved past n at a final block and our hash is not the one that executed.Nothing: terminal. Reservation released.
REJECTEDNothing was co-signed. The problem document says why.Nothing: terminal.

Two rules decide terminal states, and both are about honesty rather than convenience:

  • Terminal states come from chain facts keyed by (sender, nonce), never from a hash lookup alone and never from a timeout. A timeout is "unknown", always.
  • DEAD needs two independent sources and a second look 6,000 ms later (ten rounds). A reconciliation that later finds our hash did execute turns DEAD into EXECUTED_* and marks the intent corrected: true; the ledger still bills exactly one row for the slot.

The broadcast window​

After the commit point the service re-broadcasts the identical bytes only on evidence of loss — the transaction is missing from the sender's pool view while the on-chain nonce is still n — checking at 1,800, 3,600, 7,200, 15,000, 30,000, 60,000 and 120,000 ms after the commit.

The window is 120,000 ms. It pauses while the shard is stalled (no new block for 3,000 ms) and has an absolute cap of 1,800,000 ms. When it closes without an outcome, the intent becomes EXPIRED_LOCALLY_STILL_VALID and the problem detail says, verbatim:

"We stopped broadcasting this transaction. It has not executed, but it is still valid and will execute if anyone re-broadcasts it while nonce {n} is unused. To make it impossible, consume nonce {n}: use Cancel, or send any other transaction with nonce {n}."

While an intent is parked there:

  • its Relay Units and the relayer's exposure stay reserved — the risk is real, because anyone holding the bytes can broadcast them;
  • the sender is pinned to nonce n: any submission for another nonce is refused with NONCE_IN_FLIGHT until n is consumed;
  • the only submissions accepted are the same bytes again (a new window), mode: "cancel" and mode: "replace";
  • the account is sent an intent.expired_locally notice carrying the same sentence.

The wrong response is the one that feels natural: treating it as a failure and signing a fresh transaction at n + 1 for the same business action. That creates a second live transaction for one intention. The logical idempotency key exists to catch exactly that.

The re-sign ladder​

When something fails between assignment and execution, recovery climbs a ladder. A higher rung is used only when every lower one cannot work, and only two rungs ever ask for a signature.

RungSituationWhat happensNew signature
0A signer host, an API host, an egress address or a gateway fails.The same co-signed bytes go out through another host or another gateway. Ed25519 is deterministic, so the hash is the same.none
1The lease expired while you were signing, and the relayer is still usable.LEASE_EXPIRED with renewable: true → POST /v1/relay/assign { renewFor } → the identical bytes again.none
2Nothing was co-signed and the named relayer is truly unusable: not renewable, fenced, retired, its hourly budget exhausted on both hosts, or the payload first seen too long ago.RESIGN_REQUIRED, carrying a fresh assignment whose lease is pinned to nonce n.one, same nonce
3Co-signed, but the relayer is under-funded.The service funds that relayer address from the fleet; no bytes change. You see BROADCAST with stuckReason: RELAYER_UNDERFUNDED.none
4Rung 3 is impossible — the relayer is flagged compromised, or the top-up did not execute within 30,000 ms.RESIGN_SAME_NONCE: same nonce, a gas price at least one higher, a pinned lease.one, same nonce, higher gas price
CYou want the intent gone.Cancel — below.one, for a different transaction

A host-local shortage of relayer headroom is never a reason for rung 2; it is handled on rung 0 by forwarding to the peer host. "Re-sign" means the relayer is truly lost, not that one machine was busy.

Why no rung can send twice​

  1. At most one transaction per (sender, nonce) executes — a protocol fact.
  2. Every transaction CoRelayer ever co-signs for one intent carries the same nonce. Re-sign and cancel leases are nonce-pinned, and the signer refuses any other nonce. A wallet that silently produces n + 1 yields a transaction that is never co-signed and is inert forever.
  3. So "nothing was co-signed" does not even have to be globally true. If one host answers RESIGN_REQUIRED just as the other co-signed T(n), the replacement is T′(n): one of the two executes, the other dies, and the executed hash is billed once. The cost of that race is an unnecessary second signature — a UX defect, never a double send.
  4. No state ever suggests or accepts a nonce other than n while a co-signed T(n) is alive.

What a client must do on rungs 2 and 4​

  • Rebuild the same call with relayer = assignment.relayer, nonce = assignment.pinNonce, and a gas price of at least assignment.minGasPrice.
  • Sign once and submit it with the new lease — the pinned one from the problem document.
  • Read the nonce back from the signed transaction. A wallet that re-nonces to n + 1 because the old transaction executed in the meantime has produced something for the wrong slot: discard it and ask GET /v1/intents/{sender}/{n}.
  • A guarded account needs a new guardian co-signature for the re-signed transaction.
  • If the pinned re-sign is answered NONCE_TOO_LOW or INTENT_ALREADY_EXECUTED, the flow is over: slot n was consumed. Do not start a fresh, unpinned flow for the same action; read the slot's outcome.

The worked code is Handle a re-sign request.

Cancel and Replace​

Cancel is a second signature for a second intent — "make slot n harmless". It is not a failover re-sign, so the one-signature rule is untouched, and a user interface must say so.

POST /v1/relay/assign
{ "sender": "erd1…", "cancel": { "nonce": 41 }, "proof": { … } }

returns a lease pinned to nonce 41 with a minimum gas price one above the pending transaction's, on a healthy relayer (the same one when it is healthy). The cancel transaction is fixed by the signer: a 0-value transfer to yourself, empty data, gas limit equal to moveGas. It is an ordinary relayed transaction — one Relay Unit, reserved in the same slot — and whichever of the two executes is billed. The API reports the race truthfully: cancel requested — outcome decided on chain. With nothing to cancel you get NOTHING_TO_CANCEL.

mode: "replace" is the same mechanism for a foreign pending transaction at n — your own non-relayed transaction stuck in the pool. Its gas price must exceed the pooled one, and it is billed normally.

Replacements of any kind must satisfy gasPrice ≥ previous + 1 and at most 2 × minimum + 16. An equal or lower price is refused with REPLACEMENT_UNDERPRICED, because it would never win.

A signed payload that was never co-signed​

Before the commit point, your signed transaction exists only in the memory of the request that carries it. It is not queued, not retried from a buffer, not written to a database row or a log line. A rejection is recorded as {sender, nonce, hash of the signature bytes, code} — never the signature. A crash between ACCEPTED and COSIGNED loses the request, and the client resubmits the same bytes.

Two limits stop a payload someone else holds from being revived later:

  • the signer co-signs nothing without a lease, and a lease needs a fresh presence proof;
  • a payload first seen more than 600,000 ms ago is never co-signed — the answer is RESIGN_REQUIRED with a nonce-pinned lease.

Next​