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
| State | Held where | Moves on |
|---|---|---|
ASSIGNED | Only by you: a lease in hand. The server keeps nothing and reserves nothing. | Your POST /v1/relay. |
RECEIVED | The API, while it checks your bytes. | Any failed check → REJECTED. |
ACCEPTED | Units and relayer exposure reserved, about to be co-signed. | The signer's answer. |
COSIGNED | The commit point has passed: the relayer signature exists. | A gateway acknowledgement, a lost slot, or the end of the broadcast window. |
BROADCAST | At least one gateway returned our hash. includedInBlock fills in once a block carries it. | Chain facts. |
EXECUTED_OK / EXECUTED_FAIL | On 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_VALID | Not terminal. We stopped re-broadcasting; the transaction is still valid. | Same bytes, Cancel, Replace, or chain facts. |
DEAD | The sender's nonce moved past n at a final block and our hash is not the one that executed. | Nothing: terminal. Reservation released. |
REJECTED | Nothing 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. DEADneeds two independent sources and a second look 6,000 ms later (ten rounds). A reconciliation that later finds our hash did execute turnsDEADintoEXECUTED_*and marks the intentcorrected: 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 withNONCE_IN_FLIGHTuntilnis consumed; - the only submissions accepted are the same bytes again (a new window),
mode: "cancel"andmode: "replace"; - the account is sent an
intent.expired_locallynotice 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.
| Rung | Situation | What happens | New signature |
|---|---|---|---|
| 0 | A 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 |
| 1 | The 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 |
| 2 | Nothing 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 |
| 3 | Co-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 |
| 4 | Rung 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 |
| C | You 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
- At most one transaction per
(sender, nonce)executes — a protocol fact. - 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 + 1yields a transaction that is never co-signed and is inert forever. - So "nothing was co-signed" does not even have to be globally true. If one host answers
RESIGN_REQUIREDjust as the other co-signedT(n), the replacement isT′(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. - No state ever suggests or accepts a nonce other than
nwhile a co-signedT(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 leastassignment.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 + 1because the old transaction executed in the meantime has produced something for the wrong slot: discard it and askGET /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_LOWorINTENT_ALREADY_EXECUTED, the flow is over: slotnwas 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_REQUIREDwith a nonce-pinned lease.
Next
- What each state guarantees, and what is charged: Delivery guarantees
- Following an intent in code: Watch an intent
- Why the second signature is the exception: One signature