Verify a relayer
Before you sign a transaction, check that the relayer it names is an active CoRelayer relayer, in
your shard, on the network you are signing for. Every CoRelayer SDK runs this check for you when you
give its relay flow a relayer verifier. This page explains the check, shows how to turn it on in each
SDK, and lists the steps for a program that calls the API directly.
Why it matters
Under Relayed v3 the relayer address is part of the bytes you sign, so nobody can change it afterwards. An impostor relayer could still hold on to a transaction you signed for it. A signed MultiversX transaction does not expire, so the impostor could co-sign and broadcast it at any later time, or never send it at all.
So do not sign for a relayer you cannot tie to CoRelayer. A name, a tag or a branding file cannot
do that, because anyone can publish one. Use them to find things, and use the registry in the
CoRelayer contract to decide. You read the registry through a node you chose.
Pin the contract address
The whole check depends on the contract address. Pin it in your own configuration, keyed by chain
id (1 for mainnet, D for devnet), and never take it from the same API response that gives you a
relayer. The address is also published on the contract page, in
the pricing document and in the discovery files. Those are copies for convenience. If one of them
disagrees with your pin, keep your pin.
An SDK verifier with no contract address fails every check with the reason no-contract, and the
relay flow stops before your wallet is asked to sign. That is on purpose: a relayer that cannot be
checked is never signed for.
The owner and relayer wallets are the same on devnet and mainnet, and the contract address may be
the same too. An address that is Active in the devnet registry tells you nothing about mainnet.
Always query the contract of the chain id you are about to sign for, and key every cache by that
chain id.
What the check does
- It computes the shard of the relayer and of the sender from the two addresses. This needs no network call. A relayer in another shard cannot pay for your transaction, so the check refuses it without asking the chain.
- It calls the view
getRelayerStateon your pinned contract, through a MultiversX gateway that CoRelayer does not run, and accepts only2(Active). - It caches a positive answer, keyed by chain id, the
registryVersionof the assignment and the relayer. The registry version changes whenever the registry does, and a new version needs a new check.
getRelayerState | As returnData[0] | State | What to do |
|---|---|---|---|
| 2 | "Ag==" | Active | Sign. |
| 3 | "Aw==" | Draining | Do not sign a new transaction. Ask for a new assignment. |
| 0, 1, 4 | empty, "AQ==", "BA==" | Not registered, registered but not active, retired | Refuse, and treat the API that offered it as suspect. |
Use the public gateway of your chain id unless you run your own: https://gateway.multiversx.com
for chain 1 and https://devnet-gateway.multiversx.com for chain D. Any observer node you trust
works the same way.
Let the SDK run it
Each SDK has a relayer verifier. You create it with a gateway, your pinned contract address and the
chain id, and pass it to the relay flow. The flow runs it after the API assigns a relayer and before
your transaction is built or signed. If the check fails, the flow stops with the verifier's
RelayerVerificationError, and your wallet is never asked to sign.
The verifier behaves the same way in every SDK:
- It compares the shards first, and asks the gateway only when they match.
- It accepts only state 2 (Active).
- It keeps each accepted relayer in its cache, keyed by chain id, registry version and relayer, for as long as the verifier exists. The registry version in that key comes from the assignment, so the cache relies on the API to report a new version when the registry changes. Create one verifier per network and reuse it. You can share it between threads or tasks.
- It gives the gateway 5,000 ms to answer, unless you set another time limit. When the gateway does not answer, or answers with an error, the relayer is not verified and nothing is signed.
The error carries the relayer address, the raw state when the gateway answered, and a reason:
| Reason | When | What to do |
|---|---|---|
shard-mismatch | The relayer is in a different shard from the sender. | Do not sign. Ask for a new assignment. |
not-active | The contract says the relayer is not Active. | Do not sign for this relayer. If the state is 3 (Draining), ask for a new assignment. Otherwise treat the API that offered it as suspect. |
unreachable | The gateway did not answer within the time limit, or answered with an error. | Try again in a moment, or ask another gateway you trust. |
no-contract | The verifier has no contract address. | Set the contract address of this chain id in your configuration. |
malformed | The relayer or the sender address could not be read. | Do not sign. |
In Go the reason is a RelayerVerificationFailure string with these values. In Rust it is an enum,
and as_str() returns these names.
TypeScript
import { createRelayerVerifier, type RelayOnceOptions, relayOnce } from '@corelayer/sdk';
/** `contract` is the CoRelayer contract of chain `1`, from your own configuration. */
export function createMainnetRelay(contract: string) {
// Create the verifier once and reuse it: it remembers the relayers it has confirmed.
const verifier = createRelayerVerifier({
gateway: 'https://gateway.multiversx.com', // a gateway CoRelayer does not run
contract,
chainId: '1',
});
// Each call checks the assigned relayer before your wallet is asked to sign.
return (options: RelayOnceOptions) =>
relayOnce({ ...options, verifyRelayer: (assignment) => verifier(assignment, options.sender) });
}
relayOnce throws the verifier's error unchanged. Set timeoutMs to change the time limit. The
TypeScript SDK page shows a whole transfer.
Go
import (
"context"
corelayer "github.com/Co-relayer/corelayer/packages/sdk-go"
)
// NewMainnetVerifier checks relayers on mainnet. contract is the CoRelayer contract of chain "1",
// from your own configuration. Create one and share it: it is safe for concurrent use and
// remembers the relayers it has confirmed.
func NewMainnetVerifier(contract string) *corelayer.RelayerVerifier {
return corelayer.NewRelayerVerifier(corelayer.RelayerVerifierOptions{
Gateway: "https://gateway.multiversx.com", // a gateway CoRelayer does not run
Contract: &contract,
ChainID: "1",
})
}
// RelayChecked runs RelayOnce with the relayer check turned on.
func RelayChecked(ctx context.Context, verifier *corelayer.RelayerVerifier,
options corelayer.RelayOnceOptions) (*corelayer.RelayOnceResult, error) {
options.VerifyRelayer = verifier.Hook(options.Sender)
return corelayer.RelayOnce(ctx, options)
}
RelayOnce returns the verifier's *RelayerVerificationError unchanged, so errors.As finds it.
Set Timeout in the options to change the time limit. The Go SDK page shows a whole
transfer.
Rust
use std::future::Future;
use corelayer::{
Assignment, BoxError, Client, Error, RelayOnceOptions, RelayOnceResult, RelayerVerifier, RelayerVerifierOptions,
SignOnceInput, TransactionPlain, relay_once,
};
/// Checks relayers on mainnet. `contract` is the CoRelayer contract of chain `1`, from your own
/// configuration. Create one and share it between tasks: it remembers the relayers it has confirmed.
pub fn mainnet_verifier(contract: &str) -> Result<RelayerVerifier, Error> {
RelayerVerifier::new(RelayerVerifierOptions {
gateway: "https://gateway.multiversx.com".into(), // a gateway CoRelayer does not run
contract: Some(contract.into()),
chain_id: "1".into(),
..RelayerVerifierOptions::default()
})
}
/// Runs `relay_once` with the relayer check turned on.
pub async fn relay_checked<B, S, F>(
client: &Client,
verifier: &RelayerVerifier,
sender: &str,
build: B,
sign: S,
) -> Result<RelayOnceResult, Error>
where
B: FnOnce(&Assignment) -> Result<TransactionPlain, BoxError>,
S: FnOnce(SignOnceInput) -> F,
F: Future<Output = Result<TransactionPlain, BoxError>>,
{
let hook = verifier.hook(sender);
let options = RelayOnceOptions {
sender: sender.to_owned(),
verify_relayer: Some(&hook),
..RelayOnceOptions::default()
};
relay_once(client, options, build, sign).await
}
A failed check arrives as Error::RelayerVerification. Set timeout in the options to change the
time limit. The Rust SDK page shows a whole transfer.
Python
from collections.abc import Callable
from corelayer import (
Assignment,
Client,
RelayerVerifier,
RelayOnceResult,
SignOnce,
UnsignedTransactionPlain,
relay_once,
)
def mainnet_verifier(contract: str) -> RelayerVerifier:
"""`contract` is the CoRelayer contract of chain "1", from your own configuration.
Create one verifier and share it between threads: it remembers the relayers it has
confirmed."""
return RelayerVerifier(
"https://gateway.multiversx.com", # a gateway CoRelayer does not run
contract,
"1",
)
def relay_checked(
client: Client,
verifier: RelayerVerifier,
sender: str,
build: Callable[[Assignment], UnsignedTransactionPlain],
sign: SignOnce,
) -> RelayOnceResult:
"""Runs `relay_once` with the relayer check turned on."""
return relay_once(
client,
sender=sender,
verify_relayer=verifier.hook(sender),
build_transaction=build,
sign_once=sign,
)
relay_once raises the verifier's RelayerVerificationError. Pass timeout_ms to change the time
limit. With asyncio, use AsyncRelayerVerifier and relay_once_async; they take the same
arguments. The Python SDK page shows a whole transfer.
Check it yourself
If you call the API without an SDK, run these steps before you sign. Step 4 also limits how long you trust a cached answer:
-
Get an assignment.
POST /v1/relay/assignreturns the relayer, a lease and the registry version the service built its view from. -
Compute the shard of the relayer and of the sender from the addresses. If they differ, refuse the relayer.
-
Through a gateway that CoRelayer does not run, call the view
getRelayerStateon your pinned contract:POST https://gateway.multiversx.com/vm-values/querycontent-type: application/json{ "scAddress": "<your pinned CoRelayer contract>","funcName": "getRelayerState","args": ["<the relayer's public key, hex>"] }The first element of
data.data.returnDatais the state as a base64 top-encoded integer. Look it up in the table under What the check does. -
Cache a positive answer under
(chainId, registryVersion, relayer)for at most 60,000 ms, the lifetime of a lease. A differentregistryVersionor a different relayer needs a new check. -
Sign once, and submit with the lease.
The module below does these steps. It is one of this site's runnable examples: the test suite type-checks it against the API types and runs it against a fixture gateway.
/**
* Checking a relayer before signing anything that names it.
*
* Under Relayed v3 the relayer address is part of the bytes you sign, so nobody can change it
* afterwards. An impostor could still hold on to a transaction you signed for it, and a signed
* MultiversX transaction does not expire. So before you sign, check that the address you were given
* is an active relayer in the CoRelayer registry, on the chain you are about to sign for:
*
* 1. it is in the sender's shard, computed from the two addresses with no network call;
* 2. `getRelayerState(relayer)` is 2 (Active) on the contract pinned in your own configuration,
* read through a gateway that CoRelayer does not run;
* 3. a positive answer is cached under `(chainId, registryVersion, relayer)` for at most
* 60,000 ms.
*
* `@corelayer/sdk` runs this check for you when you pass a verifier from `createRelayerVerifier` to
* `relayOnce` as `verifyRelayer`. This module writes the steps out, for a program that calls the
* API directly or wants to see how the check works. Its cache also lets a positive answer expire
* after 60,000 ms.
*/
import { Address, AddressComputer } from '@multiversx/sdk-core';
import { type Gateway, topDecodeUint } from './gateway.ts';
/** Registry states, as `getRelayerState` returns them. The discriminants are frozen. */
export const RelayerState = {
None: 0,
Registered: 1,
Active: 2,
Draining: 3,
Retired: 4,
} as const;
export type RelayerStateName = keyof typeof RelayerState;
export function stateName(value: number): RelayerStateName | `unknown (${number})` {
const entry = Object.entries(RelayerState).find(([, v]) => v === value);
return entry === undefined ? `unknown (${value})` : (entry[0] as RelayerStateName);
}
const shards = new AddressComputer(3);
/** The shard of an address, computed from the address alone. */
export function shardOf(address: string): number {
return shards.getShardOfAddress(Address.newFromBech32(address));
}
export class RelayerRejected extends Error {
readonly relayer: string;
readonly reason: 'shard' | 'state';
readonly state: number | undefined;
constructor(relayer: string, reason: 'shard' | 'state', detail: string, state?: number) {
super(`Refusing to sign for relayer ${relayer}: ${detail}`);
this.name = 'RelayerRejected';
this.relayer = relayer;
this.reason = reason;
this.state = state;
}
}
export interface RelayerCheckOptions {
/** The chain id you sign for: `1` (mainnet) or `D` (devnet). */
readonly chainId: string;
/**
* The CoRelayer contract of that chain, from your own configuration and never from an API
* response.
*/
readonly contract: string;
/** A gateway for that chain that is not operated by CoRelayer. */
readonly gateway: Gateway;
/** How long a positive answer is reused. Never more than the 60,000 ms lease lifetime. */
readonly cacheMs?: number;
readonly now?: () => number;
}
export class RelayerCheck {
readonly #options: RelayerCheckOptions;
readonly #cacheMs: number;
readonly #verified = new Map<string, number>();
constructor(options: RelayerCheckOptions) {
this.#options = options;
this.#cacheMs = Math.min(options.cacheMs ?? 60_000, 60_000);
}
/** The on-chain registry state of one address, read through the gateway. */
async state(relayer: string, signal?: AbortSignal): Promise<number> {
const { gateway, contract } = this.#options;
const [first] = await gateway.query(
contract,
'getRelayerState',
[Address.newFromBech32(relayer).toHex()],
signal,
);
return Number(topDecodeUint(first ?? new Uint8Array()));
}
/**
* Throws `RelayerRejected` unless `relayer` may be signed for by `sender`. `registryVersion` is
* the value the assignment carried; a different version is a different cache entry.
*/
async verify(
sender: string,
relayer: string,
registryVersion: number,
signal?: AbortSignal,
): Promise<void> {
if (shardOf(relayer) !== shardOf(sender)) {
throw new RelayerRejected(
relayer,
'shard',
`it is in shard ${shardOf(relayer)}, the sender is in shard ${shardOf(sender)}`,
);
}
const now = this.#options.now ?? Date.now;
const key = `${this.#options.chainId}:${registryVersion}:${relayer}`;
const until = this.#verified.get(key);
if (until !== undefined && until > now()) return;
const state = await this.state(relayer, signal);
if (state !== RelayerState.Active) {
// Draining: it finishes what it already accepted and takes nothing new, so ask for a new
// assignment. Any other state: not a CoRelayer relayer, or not any more. Treat the API as
// suspect.
throw new RelayerRejected(relayer, 'state', `registry state is ${stateName(state)}`, state);
}
this.#verified.set(key, now() + this.#cacheMs);
}
}
It reads the chain through a small gateway client:
/**
* Two reads from a MultiversX gateway that CoRelayer does not run.
*
* Before you sign, you need to know whether a relayer is really a CoRelayer relayer and which nonce
* your account is at. Both are chain state, so read them from the chain, through a node you chose.
* Asking the API those questions would mean trusting the party whose answer is being checked.
*
* The default public gateway of each network is `https://gateway.multiversx.com` (chain id `1`) and
* `https://devnet-gateway.multiversx.com` (chain id `D`). Any observer you trust works the same way.
*/
export interface GatewayOptions {
/** Base URL of the gateway, without a trailing slash. */
readonly url: string;
/** Injected for tests; defaults to the global `fetch`. */
readonly fetch?: typeof globalThis.fetch;
}
/** The gateway wraps every answer as `{ data: …, error: string, code: string }`. */
interface Envelope<T> {
readonly data?: T;
readonly error?: string;
readonly code?: string;
}
export class Gateway {
readonly #url: string;
readonly #fetch: typeof globalThis.fetch;
constructor(options: GatewayOptions) {
this.#url = options.url.replace(/\/$/, '');
this.#fetch = options.fetch ?? globalThis.fetch;
}
async #call<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await this.#fetch(`${this.#url}${path}`, init);
const body = (await response.json()) as Envelope<T>;
if (!response.ok || body.code !== 'successful' || body.data === undefined) {
throw new Error(
`Gateway ${path} failed: ${response.status} ${body.error ?? body.code ?? ''}`,
);
}
return body.data;
}
/** The account's current on-chain nonce: the nonce its next transaction must carry. */
async accountNonce(address: string, signal?: AbortSignal): Promise<number> {
const data = await this.#call<{ account: { nonce?: number } }>(
`/address/${encodeURIComponent(address)}`,
signal === undefined ? {} : { signal },
);
return data.account.nonce ?? 0;
}
/**
* Runs a read-only contract view. Arguments are hex; each returned part is the raw bytes of one
* top-encoded value.
*/
async query(
contract: string,
funcName: string,
args: readonly string[],
signal?: AbortSignal,
): Promise<Uint8Array[]> {
const data = await this.#call<{
data: { returnData?: (string | null)[] | null; returnCode?: string; returnMessage?: string };
}>('/vm-values/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ scAddress: contract, funcName, args }),
...(signal === undefined ? {} : { signal }),
});
const { returnData, returnCode, returnMessage } = data.data;
if (returnCode !== 'ok') {
throw new Error(
`${funcName} on ${contract}: ${returnCode ?? 'no return code'} ${returnMessage ?? ''}`,
);
}
return (returnData ?? []).map((part) => Uint8Array.from(Buffer.from(part ?? '', 'base64')));
}
}
/** A top-encoded unsigned integer: big-endian, no padding, zero is the empty byte string. */
export function topDecodeUint(bytes: Uint8Array): bigint {
let value = 0n;
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
return value;
}
It runs once per signature, inside the signer callback, so nothing is signed for a relayer that failed it:
// 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);
},
After the fact
Anyone can audit a relayed transaction that has already executed. Its relayer must have been
Active or Draining at the block's timestamp, according to the history of relayerStateChanged
events. CoRelayer's settlement reconciliation requires the same of every unit it bills: a billed
unit must map to a transaction hash whose relayer is in the registry.
The API's copy, and when to use it
GET /v1/relayers returns the registry as the service mirrors it:
{ contract, registryVersion, relayers: [{ address, shard, state, weight, operatorId, slaClass }] }.
It is useful for display, and the dashboard's relayer screen uses it. It does not replace the check
above. A client should never trust an API response about which address is a real relayer, because
that is exactly the claim an attacker would want to make.
The compatibility route GET /relayer/address/{userAddress} does no verification, because the
clients it exists for do not expect any. For a new integration, use the native API and the check on
this page.
Next
- How the relayer set is run, funded and rotated: Relayers
- Why the relayer must share your shard: Shards and routing
- The views, generated from the ABI:
getRelayerState,getActiveRelayers,getRegistryVersion