Skip to main content

Sign and relay

send-token.ts below is the whole relay flow in one file. The docs test suite type-checks it against the API's generated types and runs it against a stand-in API that verifies its signatures.

The complete version​

send-token.ts
/**
* Sends a token without holding EGLD. This is the whole relay flow.
*
* connect read /v1/network and refuse to go on if it is not the chain you meant
* build an ordinary ESDT transfer, built by sdk-core, no relayer yet
* relay relayOnce: sign a presence proof (a message, not a transaction), assign, verify the
* relayer on chain, sign once, submit
*
* `relayOnce` calls the transaction signer at most once. The only thing it retries by itself is a
* renewable expired lease: it signs a fresh presence proof, renews the lease and resubmits the
* identical signed bytes. A request for a new signature comes back to you as an outcome;
* `sendToken` never acts on it. `resignWith` is the one function that does, and only when you call
* it.
*/

import {
type Assignment,
assignProofMessage,
type CoRelayerClient,
guardSignOnce,
type Network,
type RelayResponse,
relayOnce,
} from '@corelayer/sdk';
import {
Address,
Token,
TokenTransfer,
TransactionsFactoryConfig,
TransferTransactionsFactory,
} from '@multiversx/sdk-core';
import type { Gateway } from './gateway.ts';
import type { RelayerCheck } from './verify-relayer.ts';
import type { UnsignedTransaction, Wallet } from './wallet.ts';

export interface RelayContext {
readonly client: CoRelayerClient;
readonly wallet: Wallet;
/** A MultiversX gateway you choose, used to read the account nonce. */
readonly gateway: Gateway;
/** Checks the assigned relayer on chain against the contract address you pinned. */
readonly relayers: RelayerCheck;
/** The chain ID you mean to sign for. The API's chain ID is checked against it. */
readonly chainId: string;
}

/** Reads the network facts and refuses to continue on any other chain. */
export async function connect(context: RelayContext, signal?: AbortSignal): Promise<Network> {
const { data: network } = await context.client.getNetwork(signal);
if (network.chainId !== context.chainId) {
// An address looks the same on devnet and mainnet, so it never tells you which network you
// are on. Only the chain ID does.
throw new Error(
`Refusing to continue: the API serves chain ${network.chainId}, not ${context.chainId}.`,
);
}
return network;
}

/**
* The presence proof `POST /v1/relay/assign` needs when you hold the sender key. It is a signature
* over a short message, valid once and for 30,000 ms around the server's clock. The time in the
* message comes from the server, so the clock of this machine does not matter. `relayOnce` makes
* these itself when you give it `signProof`; this function is for calling the assign route
* yourself.
*/
export async function presenceProof(context: RelayContext, signal?: AbortSignal) {
const network = await connect(context, signal);
const message = assignProofMessage(network.chainId, context.wallet.address, network.serverTimeMs);
return {
kind: 'key' as const,
serverTimeMs: network.serverTimeMs,
signature: await context.wallet.signProofMessage(message),
};
}

/** What to send, before any relayer is involved. */
export interface Call {
readonly sender: string;
readonly receiver: string;
readonly value: string;
readonly data: string | undefined;
/** Gas for the call itself; the relayed-transaction surcharge is added per assignment. */
readonly gasLimit: number;
}

/** An ESDT transfer, built by sdk-core with this network's gas constants. */
export async function tokenTransfer(
network: Network,
sender: string,
receiver: string,
token: string,
amount: bigint,
): Promise<Call> {
const config = new TransactionsFactoryConfig({ chainID: network.chainId });
config.minGasLimit = BigInt(network.minGasLimit);
config.gasLimitPerByte = BigInt(network.gasPerDataByte);
const factory = new TransferTransactionsFactory({ config });
const tx = await factory.createTransactionForESDTTokenTransfer(Address.newFromBech32(sender), {
receiver: Address.newFromBech32(receiver),
tokenTransfers: [new TokenTransfer({ token: new Token({ identifier: token }), amount })],
});
const plain = tx.toPlainObject();
return {
sender,
receiver: plain.receiver,
value: plain.value,
data: plain.data,
gasLimit: plain.gasLimit,
};
}

/**
* The transaction for one assignment. Only the relayer-specific fields depend on it; the call is
* fixed. Refuses an assignment for another chain.
*/
export function forAssignment(
call: Call,
nonce: number,
assignment: Assignment,
chainId: string,
): UnsignedTransaction {
if (assignment.chainId !== chainId) {
throw new Error(`Assignment is for chain ${assignment.chainId}; refusing to build for it.`);
}
return {
nonce,
value: call.value,
receiver: call.receiver,
sender: call.sender,
gasPrice: assignment.minGasPrice,
// The extra gas for a relayed transaction comes from the assignment. Do not add more on top:
// Relay Units are counted on the worst case the gas limit allows.
gasLimit: call.gasLimit + assignment.extraGasRelayed,
...(call.data === undefined ? {} : { data: call.data }),
chainID: assignment.chainId,
version: 2,
relayer: assignment.relayer, // inside the signed bytes from here on
};
}

export interface SendTokenRequest {
readonly receiver: string;
/** Token identifier. Defaults to the network's payment token (the USDC of that network). */
readonly token?: string;
/** Amount in the token's smallest unit. USDC has 6 decimals, so 5 USDC is 5_000_000n. */
readonly amount: bigint;
/** One key per business action. Retries and re-signs of the same action reuse it. */
readonly intentKey: string;
/**
* The nonce to sign. Defaults to the account's on-chain nonce, which is right for one
* transaction at a time. To send several in a row, count the nonces yourself: n, n + 1, …
*/
readonly nonce?: number;
readonly signal?: AbortSignal;
}

export type SendOutcome =
| { readonly kind: 'relayed'; readonly response: RelayResponse }
| {
/** Nothing was sent, and the transaction already signed can never execute. */
readonly kind: 'resign-required';
readonly call: Call;
readonly previousRelayer: string;
/** The replacement the server already assigned, with a lease pinned to `pinnedNonce`. */
readonly nextAssignment: Assignment | undefined;
readonly pinnedNonce: number;
};

export async function sendToken(
context: RelayContext,
request: SendTokenRequest,
): Promise<SendOutcome> {
const { client, wallet, gateway, relayers, chainId } = context;
const { signal } = request;
const network = await connect(context, signal);

const token = request.token ?? network.paymentToken;
if (token === undefined) {
throw new Error('No token given and the network reports no payment token.');
}
const call = await tokenTransfer(
network,
wallet.address,
request.receiver,
token,
request.amount,
);
const nonce = request.nonce ?? (await gateway.accountNonce(wallet.address, signal));

const result = await relayOnce({
client,
sender: wallet.address,
// Every assign call gets a fresh presence proof: the first one, and the renewal when the lease
// expires while the wallet is signing. A proof is accepted only once.
signProof: (message) => wallet.signProofMessage(message),
intentKey: request.intentKey,
...(signal === undefined ? {} : { signal }),
buildTransaction: (assignment) => forAssignment(call, nonce, assignment, chainId),
// The single signature. The relayer is checked on chain first; if it is not an active
// CoRelayer relayer in your shard, nothing is signed.
signOnce: async ({ assignment, transaction }) => {
await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal);
return wallet.signTransaction(transaction);
},
});

if (result.kind === 'relayed') return { kind: 'relayed', response: result.response };
return {
kind: 'resign-required',
call,
previousRelayer: result.previousRelayer,
nextAssignment: result.nextAssignment,
pinnedNonce: result.pinnedNonce,
};
}

/**
* Signs the same call once more, for the replacement relayer, on the pinned nonce. This is the one
* case in which a second signature is correct. Call it only after a person confirmed, or from an
* agent policy that stops after a fixed number of re-signs.
*
* It uses the assignment that came back with `RESIGN_REQUIRED`. That lease is pinned to the nonce,
* so the second transaction can only replace the first. A new assignment from the assign endpoint
* is not pinned, so it is not safe here.
*/
export async function resignWith(
context: RelayContext,
outcome: Extract<SendOutcome, { kind: 'resign-required' }>,
intentKey: string,
signal?: AbortSignal,
): Promise<RelayResponse> {
const { client, wallet, relayers, chainId } = context;
const assignment = outcome.nextAssignment;
if (assignment === undefined) {
throw new Error(
'The server returned no replacement assignment; ask for one before re-signing.',
);
}
const nonce = assignment.pinNonce ?? outcome.pinnedNonce;
if (nonce !== outcome.pinnedNonce) {
throw new Error(
`Replacement lease is pinned to nonce ${nonce}, expected ${outcome.pinnedNonce}.`,
);
}

await relayers.verify(wallet.address, assignment.relayer, assignment.registryVersion, signal);
const unsigned = forAssignment(outcome.call, nonce, assignment, chainId);
const sign = guardSignOnce(async ({ transaction }) => wallet.signTransaction(transaction));
const signed = await sign({ assignment, transaction: unsigned });
if (signed.nonce !== nonce) {
throw new Error(
`The wallet signed nonce ${signed.nonce}, not the pinned ${nonce}. Nothing sent.`,
);
}

const { data } = await client.relay(
{ tx: signed, lease: assignment.lease },
{ intentKey, ...(signal === undefined ? {} : { signal }) },
);
return data;
}

The signer​

It needs a signer. For a program that holds its own key, this is the whole of it:

wallet.ts
/**
* A signer for a program that holds its own MultiversX key.
*
* CoRelayer never sees a private key. A program that relays through CoRelayer signs the
* presence-proof message that `POST /v1/relay/assign` asks for (an Ed25519 signature over UTF-8
* bytes, with no nonce), and then each transaction once, over bytes that already name the relayer.
*
* The proof is signed **raw**: `UserSigner.sign` over the bytes of the message. A wallet's
* `signMessage` (sdk-core `Account.signMessage`, the sdk-dapp providers) first wraps the message in
* the MultiversX signed-message prefix, and that signature does not verify as a presence proof, so
* a signing service that holds your users' keys must be able to sign raw bytes. The method below
* is named `signProofMessage` so that it is never mistaken for `signMessage`. A native-auth token is
* the other way round: it is signed with the prefix.
*
* In the CoRelayer dashboard, the wallet signs the transaction through its dApp connector, and the
* native-auth login takes the place of the presence proof. This file is for agents, scripts and
* servers, including a server that pays for its users with a sponsor key (`sponsor-relay.ts`).
*/
import { readFileSync } from 'node:fs';
import type { TransactionPlain } from '@corelayer/sdk';
import { Transaction, TransactionComputer, UserSigner } from '@multiversx/sdk-core';

/** A transaction ready to be signed: every field of `TransactionPlain` except the signature. */
export type UnsignedTransaction = Omit<TransactionPlain, 'signature'>;

export interface Wallet {
/** The bech32 address (`erd1…`) of the key. */
readonly address: string;
/**
* The presence proof: a raw Ed25519 signature over the UTF-8 bytes of `message`, with no
* MultiversX signed-message prefix, as 128 hex characters.
*/
signProofMessage(message: string): Promise<string>;
/** Signs the transaction as given, without changing it, and returns it with `signature` set. */
signTransaction(transaction: UnsignedTransaction): Promise<TransactionPlain>;
}

const computer = new TransactionComputer();

function hex(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('hex');
}

/** Wraps an sdk-core `UserSigner`. */
export function walletFromSigner(signer: UserSigner): Wallet {
const address = signer.getAddress().toBech32();

return {
address,

async signProofMessage(message) {
// Raw: the bytes of the message themselves, with no MultiversX signed-message prefix.
return hex(await signer.sign(new TextEncoder().encode(message)));
},

async signTransaction(transaction) {
if (transaction.sender !== address) {
throw new Error(`This wallet is ${address}; refusing to sign for ${transaction.sender}.`);
}
// sdk-core serialises the fields in the order the protocol signs them. `relayer` is one of
// them, so the relayer cannot be changed once this signature exists.
const tx = Transaction.newFromPlainObject({ ...transaction });
const signature = await signer.sign(computer.computeBytesForSigning(tx));
return { ...transaction, signature: hex(signature) };
},
};
}

/**
* Loads a PEM key file. The path is the only thing this function is given; the key never leaves
* this process.
*/
export function walletFromPemFile(path: string): Wallet {
return walletFromSigner(UserSigner.fromPem(readFileSync(path, 'utf8')));
}

signProofMessage signs the presence proof raw: the UTF-8 bytes of the message, with the sender's key, through sdk-core UserSigner.sign. Do not wire signProof to a wallet's signMessage or to sdk-core Account.signMessage. They add the MultiversX message prefix, which a native-auth token needs and a presence proof must not have, and the API answers ASSIGN_PROOF_INVALID. A signing service that holds your users' keys must be able to sign raw bytes.

The example reads the account nonce and the relayer's registry state from a MultiversX gateway you choose. See Verify a relayer.

Using it​

import { CoRelayerClient } from '@corelayer/sdk';
import { Gateway } from './gateway.ts';
import { sendToken } from './send-token.ts';
import { RelayerCheck } from './verify-relayer.ts';
import { walletFromPemFile } from './wallet.ts';

// The devnet CoRelayer contract, from your configuration (see below).
const PINNED_DEVNET_CONTRACT = process.env.CORELAYER_CONTRACT ?? '';

const gateway = new Gateway({ url: 'https://devnet-gateway.multiversx.com' });
const context = {
client: new CoRelayerClient({ baseUrl: 'https://devnet-api.co-relayer.com' }),
wallet: walletFromPemFile(process.env.CORELAYER_PEM ?? ''),
gateway,
relayers: new RelayerCheck({ chainId: 'D', contract: PINNED_DEVNET_CONTRACT, gateway }),
chainId: 'D',
};

const outcome = await sendToken(context, {
receiver: 'erd1…',
amount: 5_000_000n, // 5 USDC: the payment token has 6 decimals
intentKey: 'invoice-2026-0917', // one per business action
});

Set PINNED_DEVNET_CONTRACT to the devnet address in the contract page's "At a glance", or in /.well-known/corelayer.json. Keep it in your own configuration, so an API answer can never change which contract you check against. With no address, the relayer check refuses every relayer and nothing is signed.

The three outcomes​

OutcomeWhat it meansWhat to do
relayedPast the commit point. response.state is COSIGNED or BROADCAST, with the intent id and the hash.Follow it: Watch an intent.
resign-requiredThe relayer became unusable before anything was co-signed. Nothing was sent, and the signed transaction can never execute.Ask the user. If they agree, call resignWith once. Handle a re-sign request.
A thrown ApiErrorRefused before the commit point. Nothing was sent.Branch on code. Errors and retries.

A TransportError or a timeout is not an outcome: you do not know whether the transaction arrived. Send the same signed bytes again, or read GET /v1/intents/{sender}/{nonce}. Do not build or sign a new transaction.

What relayOnce handles for you​

Handled for youWhy it is safe
A renewable expired leaseThe lease is renewed for the same relayer and the same signed bytes are sent again. No new signature, no new nonce. The renewal needs a fresh presence proof, which is why the example passes signProof; with a native-auth token on the client it needs none.
Nonce check after signingIf the wallet changed the nonce, the helper refuses to submit, because that transaction would be a second one the flow does not track.
Returned to youWhy
resign-requiredOnly the user can decide to sign again.
LEASE_EXPIRED after a ready-made proofThe API accepts a proof once, so relayOnce does not send it again. Pass signProof to have the lease renewed.
Everything elseThrown as ApiError, with the server's own retryable and resign.

Within one relayOnce call your signer runs at most once. A second call throws:

import { type Assignment, guardSignOnce } from '@corelayer/sdk';
import type { UnsignedTransaction, Wallet } from './wallet.ts';

export async function signTwice(
wallet: Wallet,
assignment: Assignment,
transaction: UnsignedTransaction,
): Promise<void> {
const sign = guardSignOnce(async (input) => wallet.signTransaction(input.transaction));
await sign({ assignment, transaction }); // signs
await sign({ assignment, transaction }); // throws: "relayOnce: the signer was called twice. …"
}

Without the helper​

Without relayOnce, the flow is an assign call, the relayer check and a relay call. Keep the nonce check:

import type { RelayResponse } from '@corelayer/sdk';
import { type Call, forAssignment, presenceProof, type RelayContext } from './send-token.ts';

export async function relayByHand(
context: RelayContext,
call: Call,
nonce: number,
intentKey: string,
): Promise<RelayResponse> {
const { client, wallet, relayers, chainId } = context;
const sender = wallet.address;
const proof = await presenceProof(context);

const { data: assignment } = await client.assignRelayer({ sender, proof });
await relayers.verify(sender, assignment.relayer, assignment.registryVersion);

const unsigned = forAssignment(call, nonce, assignment, chainId);
const signed = await wallet.signTransaction(unsigned); // once
if (signed.nonce !== unsigned.nonce) throw new Error('The wallet changed the nonce. Nothing sent.');

const { data } = await client.relay({ tx: signed, lease: assignment.lease }, { intentKey });
return data;
}

Gas​

The assignment carries the gas numbers:

From the assignmentUse
minGasPrice, maxGasPriceYour gasPrice must lie between them; the minimum is the right choice.
extraGasRelayedAdd it to the gas your call needs, on every relayed transaction.
extraGasGuardedAdd it as well when your account uses a guardian.

Do not pad "for safety". Relay Units are counted on the worst case your gas limit allows, so unused headroom is something you pay for. POST /v1/quote tells you the cost before you sign. (Relay Units)