Skip to main content

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​

  1. 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.
  2. It calls the view getRelayerState on your pinned contract, through a MultiversX gateway that CoRelayer does not run, and accepts only 2 (Active).
  3. It caches a positive answer, keyed by chain id, the registryVersion of the assignment and the relayer. The registry version changes whenever the registry does, and a new version needs a new check.
getRelayerStateAs returnData[0]StateWhat to do
2"Ag=="ActiveSign.
3"Aw=="DrainingDo not sign a new transaction. Ask for a new assignment.
0, 1, 4empty, "AQ==", "BA=="Not registered, registered but not active, retiredRefuse, 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:

ReasonWhenWhat to do
shard-mismatchThe relayer is in a different shard from the sender.Do not sign. Ask for a new assignment.
not-activeThe 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.
unreachableThe 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-contractThe verifier has no contract address.Set the contract address of this chain id in your configuration.
malformedThe 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:

  1. Get an assignment. POST /v1/relay/assign returns the relayer, a lease and the registry version the service built its view from.

  2. Compute the shard of the relayer and of the sender from the addresses. If they differ, refuse the relayer.

  3. Through a gateway that CoRelayer does not run, call the view getRelayerState on your pinned contract:

    POST https://gateway.multiversx.com/vm-values/query
    content-type: application/json

    { "scAddress": "<your pinned CoRelayer contract>",
    "funcName": "getRelayerState",
    "args": ["<the relayer's public key, hex>"] }

    The first element of data.data.returnData is the state as a base64 top-encoded integer. Look it up in the table under What the check does.

  4. Cache a positive answer under (chainId, registryVersion, relayer) for at most 60,000 ms, the lifetime of a lease. A different registryVersion or a different relayer needs a new check.

  5. 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.

verify-relayer.ts
/**
* 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:

gateway.ts
/**
* 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​