Watch an intent
POST /v1/relay answers at the commit point, once the relayer has co-signed. The transaction is not
in a block yet. After it returns you hold an intent ID and a state, and you follow it from there.
| Way | Shape | Best for |
|---|---|---|
GET /v1/relay/{id}/events | Server-sent events, one per state change | One transaction, live |
client.waitForIntent() | Polling with a terminal-state rule | One transaction, without streaming |
GET /v1/intents/{sender}/{nonce} | One authoritative read | "What happened to that one?" |
GET /v1/stream | Everything for one account | A server following many |
All of the code below is in the site's runnable example watch-intent.ts, tested against a stand-in
API that streams in uneven chunks the way a real network does.
One decision, from the state
export type Outcome =
/** Executed and final. The Relay Units are charged. */
| { readonly kind: 'executed'; readonly intent: Intent }
/** Executed and reverted on chain. Charged too: the network charged the relayer the full gas. */
| { readonly kind: 'failed'; readonly intent: Intent }
/** Another transaction took the nonce. Nothing charged; read your nonce and decide afresh. */
| { readonly kind: 'dead'; readonly intent: Intent }
/** Never got past validation; nothing was co-signed. The problem document says what to fix. */
| { readonly kind: 'rejected'; readonly intent: Intent }
/** Not decided yet. This includes EXPIRED_LOCALLY_STILL_VALID, which can still execute. */
| { readonly kind: 'pending'; readonly intent: Intent };
export function outcomeOf(intent: Intent): Outcome {
switch (intent.state) {
case 'EXECUTED_OK':
return intent.final ? { kind: 'executed', intent } : { kind: 'pending', intent };
case 'EXECUTED_FAIL':
return intent.final ? { kind: 'failed', intent } : { kind: 'pending', intent };
case 'DEAD':
return { kind: 'dead', intent };
case 'REJECTED':
return { kind: 'rejected', intent };
default:
return { kind: 'pending', intent };
}
}
| State | Charged | What to do |
|---|---|---|
EXECUTED_OK, final | yes | Done. |
EXECUTED_FAIL, final | yes | It reverted on chain. The network charges the relayer the full gas limit for a failed execution, so the units are spent. Read gasUsed and the contract's own error. |
DEAD | no | Another transaction took the nonce, and the reservation is released. Read your account nonce and decide afresh. |
REJECTED | no | It never got past validation; nothing was co-signed. The problem document says what to fix. |
EXPIRED_LOCALLY_STILL_VALID | reserved | Not final. CoRelayer stopped re-broadcasting, but the transaction is still valid and may execute. Wait, send the same bytes again, or cancel or replace it with a pinned nonce. Do not sign a replacement as if it had failed. (The intent lifecycle) |
An executed state becomes the outcome only once final is true. A block can still be reverted
before finality, and finality is what triggers billing.
Polling, with the rule built in
/** Polls the authoritative read until the intent is decided or `timeoutMs` passes. */
export async function followIntent(
client: CoRelayerClient,
sender: string,
nonce: number,
options: { readonly timeoutMs?: number; readonly onState?: (intent: Intent) => void } = {},
): Promise<Outcome> {
const intent = await client.waitForIntent(sender, nonce, {
intervalMs: 1_000,
timeoutMs: options.timeoutMs ?? 120_000,
...(options.onState === undefined ? {} : { onUpdate: options.onState }),
});
return outcomeOf(intent);
}
waitForIntent stops at a terminal state: EXECUTED_OK, EXECUTED_FAIL, DEAD or REJECTED.
An executed intent can still have final: false, which is why outcomeOf reports it as pending.
Without onError, a failed read is thrown, so a failure never looks like a pending intent. At the
time limit it returns the intent as it stands.
With onError, a failed read is tried again after the milliseconds onError returns, cut short at
the time limit. The time limit ends a run of failed reads too: the wait then returns the last intent
it read, or throws the last error if no read succeeded. Pass a signal to stop the wait early,
including while it waits between reads.
Every API host gives the same answer to GET /v1/intents/{sender}/{nonce}, including a host that did
not take your submission. It is the right call after a timeout, a crash or a restart. client.getRelay(id) resolves either an intent id (erd1…:41) or a
64-character transaction hash, if all you kept was the hash.
The per-intent stream
client.streamRelay(intentId) opens this stream as an EventStream, which handles every line
ending the standard allows. It does not reconnect by itself: to resume after a dropped connection,
open it again with lastEventId set to the stream's lastEventId (see
Event streams). Without the client, a reader for this one stream is
a few lines:
/**
* Reads the per-intent server-sent-events stream: one `intent` event per state change, each
* carrying the full `Intent`. The server closes the stream after the first final event, after
* `DEAD` or `REJECTED`, or after 120,000 ms, and the loop then ends.
*/
export async function* streamIntent(
apiBase: string,
intentId: string,
options: { readonly fetch?: typeof globalThis.fetch; readonly signal?: AbortSignal } = {},
): AsyncGenerator<Intent> {
const fetchImpl = options.fetch ?? globalThis.fetch;
const response = await fetchImpl(
`${apiBase.replace(/\/$/, '')}/v1/relay/${encodeURIComponent(intentId)}/events`,
{
headers: { accept: 'text/event-stream' },
...(options.signal === undefined ? {} : { signal: options.signal }),
},
);
if (!response.ok || response.body === null) {
throw new Error(`Event stream for ${intentId} answered ${response.status}.`);
}
// Server-sent events: frames separated by a blank line, `field: value` lines inside a frame,
// `:` lines are keep-alive comments. It uses a reader loop, because not every browser can iterate
// a ReadableStream with `for await`.
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true }).replaceAll('\r\n', '\n');
let end = buffer.indexOf('\n\n');
while (end !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
end = buffer.indexOf('\n\n');
let event = 'message';
const data: string[] = [];
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
const content = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
if (field === 'event') event = content;
if (field === 'data') data.push(content);
}
if (event === 'intent' && data.length > 0) yield JSON.parse(data.join('\n')) as Intent;
}
}
}
import { streamIntent } from './watch-intent.ts';
export async function show(intentId: string, render: (state: string) => void): Promise<void> {
for await (const intent of streamIntent('https://api.co-relayer.com', intentId)) {
render(intent.state);
}
}
You can read the stream without credentials. When it closes, fall back to followIntent. It never
sends RECEIVED, because it can only be opened once an intent ID exists.
In a browser, EventSource reads the same stream: new EventSource(url) and
addEventListener('intent', …).
What an intent looks like
An intent looks like this (example values):
{
"intentId": "erd1…:41",
"sender": "erd1…",
"nonce": 41,
"txHash": "…",
"relayer": "erd1…",
"state": "EXECUTED_OK",
"final": true,
"ru": 1,
"receivedAtMs": 1789819200000,
"cosignedAtMs": 1789819200031,
"broadcastAtMs": 1789819200062,
"executedBlockTsMs": 1789819200600,
"includedInBlock": { "shard": 1, "nonce": 12345678 },
"acks": [{ "gateway": "G1", "ms": 31 }],
"latency": { "acceptToCosignMs": 31, "addedMs": 62, "inclusionMs": 600, "roundsToInclusion": 1 },
"feePaidAtto": "100000000000000",
"gasUsed": 100000
}
Some members appear only where they apply: deadReason, stuckReason, corrected
(reconciliation turned a DEAD into an executed state) and explorerUrl. advisory appears on
EXPIRED_LOCALLY_STILL_VALID and explains that state. What every latency member measures is on
Telemetry.
For many transactions at once
One stream per transaction does not scale. Use the account stream, GET /v1/stream: notices, intent
changes and quota movements for one account, on one connection. From a server, authenticate with a
native-auth header or a read-scoped key. From a browser, first get a single-use ticket from
POST /v1/stream/tickets with the account and topics. EventSource cannot send a header, and a
token must never travel in a URL.
The account stream is the way to follow many transactions from a server. This deployment does not deliver webhooks or e-mail: registering a webhook endpoint answers 503 with reason: WEBHOOK_DELIVERY_NOT_AVAILABLE, and no e-mail is sent.