Skip to main content

One signature

One user action is one signature. There is no flow in CoRelayer — not in the dashboard, not in the SDK, not in the MCP server — that asks a user to sign two variants of the same transaction. This page explains what that rules out and why the rule is worth its cost.

The four steps​

  1. A relayer is assigned. It is in the sender's shard, healthy and funded, and chosen the moment before the user signs. A relayer that is not fit to deliver is never handed out.
  2. The user signs once. One signature, over one transaction, with that relayer named inside it.
  3. CoRelayer co-signs and broadcasts. The relayer signature is added and the relayer pays the network fee in EGLD. The same signed bytes can be re-broadcast through several gateways.
  4. The chain executes it. The sender's account nonce orders the transaction, exactly as if the sender had sent it directly. Custody never changes hands.

There are no pre-signed variants and no step at which CoRelayer could alter what was signed. One more signature is asked for only if the assigned relayer is lost before it co-signs, and then over the same nonce, so the two can never both execute.

The temptation​

A relayer can become unavailable between the moment it is assigned and the moment it co-signs. The obvious fix is to hedge: ask the user to sign the same transaction three times, once per candidate relayer, and submit whichever one still works. It is a small ask in the wallet and it makes a failure mode disappear.

It also creates three live, independently executable payloads for one intention. Each of them is a valid transaction the moment a relayer signs it, each has no expiry, and each is enough on its own. A user who signs three variants has authorised three things while believing they authorised one.

What the rule is​

Per user actionExactly one signature is requested.
Per relay attemptThe signer function is invoked at most once, and calling it again is an error, not a retry.
On retryThe identical bytes are re-sent. A retry never re-signs.
On re-signOnly when the service says nothing was co-signed, and only after the user is told and agrees.

The SDK enforces this structurally rather than by convention. relayOnce wraps the caller's signer in guardSignOnce, so that a second invocation throws instead of signing:

import { guardSignOnce } from '@corelayer/sdk';

const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction));
await sign({ assignment, transaction }); // signs
await sign({ assignment, transaction }); // throws: "relayOnce: the signer was called twice. …"

A rule that lives only inside a private function is a rule nothing can test. This one is exported, so the property has a test of its own — in the SDK's suite, and again in this site's example suite, which counts the signatures every example produces.

Why the relayer is assigned just before you sign​

Assigning late is what makes one signature enough. When you start a transaction, the API returns one relayer that is healthy, funded and in your shard, with a short lease. The lease reserves nothing: it is a freshness bound, because a signed MultiversX transaction never expires on its own. (Intent lifecycle)

Because the choice is made at the last moment, a relayer that is failing is never handed out. And because the user signs after the choice, the relayer named inside the transaction is one the service already knows it can serve. The alternative, signing several variants so that another relayer can be put in later, is the one this rule rules out.

Why a fleet, and not one sponsoring wallet​

Two reasons, both structural rather than a matter of scale.

Shards. The fee payer of a relayed transaction has to be in the sender's shard. One wallet cannot serve every sender; a set with coverage in each shard can. (Shards and routing)

Exposure. Every transaction in flight reserves its worst-case fee against the relayer that will pay it. One wallet is one balance and one point of failure. Several per shard means a relayer can be drained without its shard stopping, and a compromise costs the float of a few wallets rather than everything. (Relayers)

The one case where you are asked again​

If the assigned relayer becomes unavailable before the commit point, the service answers RESIGN_REQUIRED. What that answer means, precisely:

  • nothing was co-signed, so nothing can execute;
  • the transaction you signed is inert and always will be;
  • a fresh assignment for a different relayer is attached, pinned to the same nonce;
  • the decision to sign again belongs to you.

The SDK does not act on this. relayOnce returns it:

const result = await relayOnce({ /* … */ });

if (result.kind === 'resign-required') {
// Nothing was sent. Show the user what happened; only then sign once more, with the
// pinned assignment in result.nextAssignment and the nonce in result.pinnedNonce.
}

The pinned assignment is what makes the second signature safe — its lease is valid for that nonce only. (Handle a re-sign request)

The dashboard shows a dialog naming the old relayer, the new one and the nonce. It never re-signs silently. (The re-sign case)

What is safe to retry automatically​

Retrying is fine as long as nothing is re-signed. Two cases are handled for you:

SituationHandled how
The lease expired but is renewableA new lease is fetched for the same relayer and the identical signed bytes are submitted again. No new signature, no new nonce. The renewal needs a fresh presence proof, so relayOnce does it when the client has a native-auth token for the sender or you pass signProof. With a proof you signed yourself, you get LEASE_EXPIRED back.
The request timed out or the connection droppedRe-send the same bytes, or ask GET /v1/intents/{sender}/{nonce}. Never rebuild, never re-sign.

Both are safe for the same reason: identical bytes are the same transaction, with the same hash, occupying the same slot. The network treats a duplicate as what it is.

What this costs, honestly​

The hedge we refuse would genuinely remove one failure mode. Instead, when a relayer vanishes at the wrong moment, a person has to approve one more transaction. We think that is the right trade: a user who signed once should not have to reason about how many live payloads that produced.

If your integration cannot show a prompt — a fully headless agent, for example — the same rule applies and the answer is the same: re-sign with the new assignment, in code, having first checked that the service told you nothing was sent. The resign member of every relay-path problem document exists to make that check mechanical:

resignMeaning
NONEDo not ask for a signature. Nothing about signing will help.
SAME_BYTESRe-send exactly what you have.
NEW_SIGNATURE_SAME_NONCEOne new signature, same nonce, new relayer.

Never sign because of a status code alone. Sign because resign said so.