Skip to main content

Rust

corelayer-sdk is the Rust client for the CoRelayer API. You import it as corelayer. It is async on tokio, needs Rust 1.87 or later, and sends requests with reqwest over rustls by default. Every API operation is a typed method, and helpers cover relaying with one signature, checking the relayer on chain, native auth, event streams, webhooks and x402.

Install​

Not on crates.io yet

Once the crate is published, add it with cargo add corelayer-sdk, next to tokio = { version = "1", features = ["macros", "rt-multi-thread"] }. Until then, every call it makes is an ordinary HTTPS request you can send yourself: the API is described in /openapi.yaml, and Pay for your users shows a whole relay with no SDK.

Quick start​

use corelayer::{Client, ClientOptions, Error};

#[tokio::main]
async fn main() -> Result<(), Error> {
let client = Client::new(ClientOptions::new("https://api.co-relayer.com"))?;
let network = client.get_network().await?.data;
println!("chain {}, {} ms rounds", network.chain_id, network.round_duration_ms);
Ok(())
}

Every method returns a Response<T>: the decoded answer in data, plus status, headers, request_id and rate_limit_remaining. For devnet, use https://devnet-api.co-relayer.com. A Client is Send + Sync and cheap to clone, and clones share one connection pool.

Authentication​

You pass a key, or an async function the client calls before each request, and the client adds the right header. Both are set on ClientOptions.

Who is callingOptionSent as
Your server paying for your users (a sponsor key), or an agent with an API keyapi_keyX-Api-Key header, on the relay and the account reads only
An app acting for a signed-in walletnative_auth_tokenAuthorization: Bearer <token>

Public routes such as get_network need none of them, and the docs of each method say which credentials it accepts. Keep an API key on your server, and never ship it in a browser or mobile app. native_auth_token is called before every request, so a token you refresh is used on the next call.

use std::sync::{Arc, RwLock};

use corelayer::{BoxError, Client, ClientOptions, Error};

pub fn session_client(current: Arc<RwLock<Option<String>>>) -> Result<Client, Error> {
Client::new(ClientOptions::new("https://api.co-relayer.com").native_auth_token(move || {
let token = current.read().map(|token| token.clone()).map_err(|_| BoxError::from("token lock poisoned"));
async move { token }
}))
}

An agent can build its own native-auth token. The crate never signs anything: encode_native_auth_body gives you the body, native_auth_sign_payload the message your key or wallet signs, and compose_native_auth_token the token. decode_native_auth_token takes a token apart, and check_native_auth_token checks the rules that can be checked locally: origin, lifetime, formats, extraInfo, address and expiry. It cannot verify the signature or see which shard the block came from, so the API can still refuse a token that passes. Authentication describes each method in full.

Relaying a transaction​

relay_once runs the whole flow for one user action. It asks the API for a relayer, checks it with your RelayerVerifier, calls your builder and then your signer, checks that the wallet signed the nonce you built, and submits the transaction. The signer is an FnOnce, so the compiler lets it run at most once. For a reusable signer kept in a struct, guard_sign_once refuses a second call at run time.

use std::future::Future;
use std::time::Duration;

use corelayer::{
BoxError, Client, Error, RelayOnceOptions, RelayOnceResult, RelayerVerifier, RelayerVerifierOptions,
TransactionPlain, WaitOptions, relay_once,
};

/// Checks relayers on a MultiversX gateway that CoRelayer does not run. Take the registry contract
/// address from your own configuration, never from an API answer.
pub fn mainnet_verifier(registry_contract: &str) -> Result<RelayerVerifier, Error> {
RelayerVerifier::new(RelayerVerifierOptions {
gateway: "https://gateway.multiversx.com".into(),
contract: Some(registry_contract.into()),
chain_id: "1".into(),
..RelayerVerifierOptions::default()
})
}

/// Sends `value` (in the smallest EGLD unit) from `sender` to `receiver`. CoRelayer pays the gas.
/// `nonce` is the sender's next account nonce; `sign` asks the user's wallet for a signature.
pub async fn send_egld<S, F>(
client: &Client,
verifier: &RelayerVerifier,
sender: &str,
receiver: &str,
value: &str,
nonce: i64,
sign: S,
) -> Result<(), Error>
where
S: FnOnce(TransactionPlain) -> F,
F: Future<Output = Result<TransactionPlain, BoxError>>,
{
let hook = verifier.hook(sender);
let options = RelayOnceOptions {
sender: sender.to_owned(),
intent_key: Some(format!("send-{sender}-{nonce}")), // one key per user action
verify_relayer: Some(&hook),
..RelayOnceOptions::default()
};
let result = relay_once(
client,
options,
|assignment| {
Ok(TransactionPlain {
nonce,
value: value.to_owned(),
sender: sender.to_owned(),
receiver: receiver.to_owned(),
gas_price: assignment.min_gas_price,
gas_limit: 50_000 + assignment.extra_gas_relayed,
chain_id: assignment.chain_id.clone(),
version: 2,
relayer: assignment.relayer.clone(),
..TransactionPlain::default()
})
},
|input| sign(input.transaction),
)
.await?;

match result {
RelayOnceResult::Relayed(relayed) => {
let options = WaitOptions {
// The API has the transaction now: a failed read does not mean it failed.
on_error: Some(Box::new(|_error: &Error, failures: u32| {
(failures < 5).then_some(Duration::from_secs(2))
})),
..WaitOptions::default()
};
let intent = client.wait_for_intent(sender, relayed.signed.nonce, options).await?;
println!("{} is {}", intent.intent_id, intent.state);
}
RelayOnceResult::ResignRequired(resign) => {
println!("The relayer changed. Ask the user to sign nonce {} again.", resign.pinned_nonce);
}
}
Ok(())
}

The verifier asks a MultiversX gateway for the relayer's state in the CoRelayer contract, refuses unless it is Active, and checks that the relayer is in the sender's shard. Use a gateway CoRelayer does not run, and pin the contract address and the chain ID in your own configuration. Answers are cached per chain ID, registry version and relayer, and one RelayerVerifier can be shared between tasks. See Verify a relayer.

Proving you control the sender​

Before it assigns a relayer, the API needs proof that you control the sender. A native-auth token for the sender on the client is enough. Without one, give relay_once a sign_proof function. It receives the message to sign and returns the sender key's raw Ed25519 signature over the message's UTF-8 bytes, as hex. Sign the bytes themselves, not through a wallet's signMessage: that adds the MultiversX message prefix, and the API answers ASSIGN_PROOF_INVALID. relay_once then signs a fresh proof for every assign call it makes:

  • For the first assign, it reads chain_id and server_time_ms from get_network and signs assign_proof_message(chain_id, sender, server_time_ms). That costs one extra request.
  • For the renewal of an expired lease, it takes chain_id from the assignment. It estimates the server's time as the assignment's server_time_ms plus the milliseconds that have passed since the assignment arrived, measured on a monotonic clock.

The proofs carry the kind key by default. In sponsor mode, set proof_kind: Some(AssignRequestProofKind::Sponsor): the sender's key still signs the proof, and the sponsor key goes on the relay call only.

sign_proof takes any Fn(String) closure that returns a Send future of Result<String, BoxError>. The signer can be sync or async, and its future may borrow anything that outlives the options, such as your key. A signer that has the signature at once returns std::future::ready(...). When the closure owns the key, for example in an Arc, clone it into each future. To keep the key in a type of your own, implement SignProof<'a> for that type, where 'a is the lifetime of the options. Its future may then borrow self.

use corelayer::{
Assignment, BoxError, BoxFuture, Client, Error, RelayOnceOptions, RelayOnceResult, TransactionPlain, relay_once,
};

/// A key your program holds, for example in a key service. Both methods return hex signatures and
/// may wait for the service to answer.
pub trait AgentKey: Send + Sync {
fn address(&self) -> &str;
/// Raw Ed25519 over the UTF-8 bytes of `message`, with no MultiversX message prefix.
fn sign_proof_message(&self, message: String) -> BoxFuture<'_, Result<String, BoxError>>;
fn sign_transaction(&self, transaction: TransactionPlain) -> BoxFuture<'_, Result<TransactionPlain, BoxError>>;
}

/// Relays one transaction for a program that holds the sender key and has no native-auth token.
pub async fn relay_as_agent(
client: &Client,
key: &dyn AgentKey,
build: impl FnOnce(&Assignment) -> TransactionPlain,
intent_key: &str,
) -> Result<RelayOnceResult, Error> {
// The proof future borrows `key`, which outlives `options`.
let sign_proof = |message: String| key.sign_proof_message(message);
let options = RelayOnceOptions {
sender: key.address().to_owned(),
intent_key: Some(intent_key.to_owned()),
sign_proof: Some(&sign_proof),
..RelayOnceOptions::default()
};
relay_once(client, options, |assignment| Ok(build(assignment)), |input| key.sign_transaction(input.transaction))
.await
}

The API accepts each proof once, and only within 30,000 ms of its own clock. A lease lasts 60,000 ms. You can also pass a proof you signed yourself as proof, but it covers the first assign only. relay_once never sends it a second time, so when the lease expires before the submit it returns the LEASE_EXPIRED error as the API sent it. Pass sign_proof to have the lease renewed for you. relay_once refuses proof and sign_proof together, before it sends anything, with Error::RelayOnce and the code RelayOnceErrorCode::ProofWithSigner. An error from sign_proof ends the flow as Error::Callback.

Paying for your users (sponsor mode)​

From Builder up, a sponsor key on your server pays for any sender, within the contracts and daily limits the key allows. Create the client with the key through ClientOptions::api_key, and submit each transaction your user signed with relay. The client sends the key as X-Api-Key on the relay and on the account reads that accept it, never on the assign call or the network read, and your plan pays the network fee:

// On your server. The sponsor key never reaches a browser.
let key = std::env::var("CORELAYER_API_KEY")?;
let options = ClientOptions::new("https://api.co-relayer.com");
let client = Client::new(options.api_key(key))?;

// Your user signed tx. Your plan pays the network fee.
let req = RelayRequest { tx, lease, ..Default::default() };
let res = client.relay(&req, Some(action_id.as_str())).await?;

res.data.account is your account, and res.data.billing carries the auth mode api_key. This covers senders whose key your server holds or reaches, such as embedded or custodial wallets, game servers, bots and agent fleets. When the same server holds the sender's key, pass the sponsor client to relay_once, with proof_kind: Some(AssignRequestProofKind::Sponsor): every step runs as above, and the relay is billed to your plan. The presence proofs are marked kind sponsor and are still signed with the sender's key. Pay for your users covers creating the key and the answers a key can refuse with.

When the lease expires​

When the lease has expired and the API says it can be renewed, relay_once renews it for the same relayer and submits the same signed bytes again. It never asks for a second signature by itself. The renewal carries a fresh proof from sign_proof, or no proof when the client has a native-auth token for the sender.

When the API asks for a new signature​

RelayOnceResult::ResignRequired means the relayer became unavailable before it co-signed. Nothing was sent. Ask the user. If they agree, call relay_once again with assignment: resign.next_assignment, min_gas_price: resign.min_gas_price, and a transaction built for resign.pinned_nonce. relay_once refuses before asking the wallet if the nonce or the gas price would not fit. Handle a re-sign request explains when this happens. relay_once never signs again by itself.

With sign_proof, a renewal of that lease estimates the server's time from the moment you call relay_once, because that is when it received the lease. Call it soon after the user agrees: the API refuses a proof more than 30,000 ms away from its clock with 401 ASSIGN_PROOF_INVALID.

When the wallet signs a different nonce than the one you built, the result is Error::SignedNonceMismatch and nothing is sent.

Waiting for the result​

wait_for_intent reads the intent once a second (interval) and stops at EXECUTED_OK, EXECUTED_FAIL, DEAD or REJECTED. After two minutes (timeout) it stops anyway and returns the last state it read, so check state. If no read has succeeded by then, it returns the last read's error.

Before that time, a failed read ends the wait with its error unless you set on_error. It receives the error and the number of failures in a row, and returns how long to wait before the next read (at least interval), or None to return the error. The time limit also ends a run of failed reads, so an on_error that always returns a duration cannot keep the wait going forever. Dropping the future stops the wait at once, also while it waits between reads. stream_relay gives the same updates as events. See Watch an intent.

Handling errors​

Every call returns Result<_, corelayer::Error>.

ErrorWhenWhat to do
Error::ApiThe API answered with a status that is not 2xx, a 3xx included.Branch on code(). For a code you do not know, go by status() and retryable().
Error::TransportNo host answered: a network failure, a timeout, a 2xx that was not JSON, or an answer larger than max_response_bytes, on every host tried.The request may or may not have arrived. After a relay, read the intent with get_intent, or send the same signed bytes again. Never sign again because of it.
Error::DecodeA 2xx whose JSON does not fit the expected type.Do not retry: the API did answer. Update the crate if the API has changed.
Error::RelayOncerelay_once refused its options.Fix the call. Nothing was signed.
Error::SignedNonceMismatchThe wallet signed a different nonce than the one you built. Nothing was sent.Read the intent for the nonce you built with get_intent before you try again.
Error::RelayerVerificationThe relayer is not Active, is in another shard, or could not be checked. Nothing was signed.reason says which. If the gateway could not be reached, try again later. Otherwise do not sign for this relayer.
Error::InvalidInputAn argument cannot be sent, such as an empty required query or header parameter. Path parameters are not checked.Fix the call. Nothing was sent.
Error::CallbackA function you passed in failed.Its error is inside, unchanged.
use corelayer::{Client, Error, ends_slot, needs_payment};

pub async fn show_intent(client: &Client, sender: &str, nonce: i64) -> Result<(), Error> {
match client.get_intent(sender, nonce).await {
Ok(answer) => println!("{}", answer.data.state),
Err(Error::Api(error)) if needs_payment(&error) => println!("The account needs a plan or credits."),
Err(Error::Api(error)) if ends_slot(&error) => println!("This nonce is already used."),
Err(Error::Api(error)) if error.retryable() => {
println!("Try again in {:?} (request {:?}).", error.retry_after(), error.request_id());
}
Err(error) => return Err(error),
}
Ok(())
}

The API can add error codes at any time, so always keep a fallback. A code this version does not know is ErrorCode::Other. can_resubmit_same_bytes() says when sending the same signed bytes again is safe, and needs_new_signature() when the user must sign again. An error answer without a problem document, for example a proxy's HTML page, becomes an Error::Api with code UPSTREAM_UNAVAILABLE and the answer's status, and its retryable() is true only for 408, 425, 429 and 5xx. Every code has a page in the error catalogue, and Errors and retries covers when to retry.

Other endpoints​

Every API operation is a method on Client, named after the operation in snake case: get_account, list_usage, create_webhook and so on. Path parameters and the request body are arguments, and query and header parameters go in a …Params struct. A paged list also has an …_all method that returns a Paginator, and an exportable list a …_csv method that streams the CSV. An export has no cursor and at most 1,000,000 rows. A cell that starts with =, +, - or @ is prefixed with an apostrophe, so spreadsheet apps do not run it as a formula. Event streams return an EventStream.

use corelayer::{Client, Error, ListUsageParams, StreamRelayParams};

pub async fn usage(client: &Client, account: &str) -> Result<Vec<u8>, Error> {
let params = ListUsageParams { account: account.to_owned(), ..ListUsageParams::default() };

// Every row, one page at a time.
let mut rows = client.list_usage_all(&params);
while let Some(row) = rows.next().await? {
println!("{} {} RU", row.tx_hash, row.ru);
}

// The same rows as CSV. The body is streamed, so read it chunk by chunk.
let mut export = client.list_usage_csv(&params).await?;
let mut csv = Vec::new();
while let Some(chunk) = export.next_chunk().await? {
csv.extend_from_slice(&chunk);
}
Ok(csv)
}

pub async fn follow(client: &Client, intent_id: &str) -> Result<(), Error> {
let mut stream = client.stream_relay(intent_id, &StreamRelayParams::default()).await?;
while let Some(event) = stream.next().await? {
println!("{}: {}", event.event_type, event.data);
}
// To resume after a disconnect, pass stream.last_event_id() as `last_event_id`.
Ok(())
}

An event stream does not reconnect by itself. To resume, open a new one with last_event_id set to stream.last_event_id(), after waiting stream.retry() milliseconds when the server set it. The account stream replays up to 300,000 ms or 1,000 events. For an older ID, an ID from the other API host or one from before a restart, the server sends a reset event first: reload your data then.

Decoding is strict: a missing required member is an Error::Decode, never a default value. String enums are open, so a value this version does not list decodes as Other(String). A request member where "leave it as it is" and "clear it" differ is a Nullable<T>.

Webhooks​

Not delivered yet

The CoRelayer service does not send webhooks yet: registering an endpoint answers 503 with reason: WEBHOOK_DELIVERY_NOT_AVAILABLE. Until it does, follow your account with the notice feed or the account stream (Do not poll the cap). The verifier below is for the deliveries that feature will send.

use corelayer::http::HeaderMap;
use corelayer::{VerifyWebhookOptions, WebhookError, WebhookEvent, verify_webhook};

/// Answer 2xx when this returns `Ok`, and 400 when it returns `Err`.
pub fn read_delivery(headers: &HeaderMap, body: &[u8], secrets: &[String]) -> Result<WebhookEvent, WebhookError> {
let options = VerifyWebhookOptions { secrets: secrets.to_vec(), ..VerifyWebhookOptions::default() };
verify_webhook(headers, body, &options)
}

verify_webhook checks the CoRelayer-Webhook-Signature header, the hex HMAC-SHA256 of <id>.<timestamp>.<raw body>, and refuses a timestamp more than 300,000 ms from now. Pass the body bytes as received, before any parsing. After a secret rotation, pass both secrets for 24 hours. A failed delivery is retried with growing delays, 10 attempts in all over about 23 hours, and an endpoint that keeps failing for 604,800,000 ms (7 days) is disabled. A delivery can arrive more than once, so ignore an event id you have already handled.

x402 payments​

For x402 purchases, x402_topup and x402_purchase first answer 402. Its body is the payment challenge rather than a problem document, so the ApiError has the code UPSTREAM_UNAVAILABLE. Recognise it by status() 402 and a result from payment_required_of, which reads what to pay from the error. encode_payment_signature builds the PAYMENT-SIGNATURE header for the paid request (the payment_signature parameter), and decode_payment_response reads the settlement receipt. Check the requirement's pay_to, asset and amount against your own configuration before you sign.

Configuration​

OptionDefaultWhat it does
base_urlrequiredThe API origin: https://api.co-relayer.com, or https://devnet-api.co-relayer.com for devnet.
direct_hostsnoneRegional hosts to fail over to, in order. Take them from get_network.
failover_after1,500 msHow long the main host may take before the next host is tried. Used only with direct_hosts.
timeout15 sThe time limit of one attempt on a direct host, or on the main host when there are none.
max_response_bytes32 MiBThe largest answer body read into memory.
api_keynoneSent as X-Api-Key on the operations that accept it: the relay and the account reads, never the assign call or GET /v1/network. A sponsor key pays for your users' transactions.
native_auth_tokennoneAsync function returning the token, sent as Authorization: Bearer.
headersnoneHeaders added to every request.
user_agentcorelayer-sdk-rust/<version>The User-Agent header.
http_clientreqwest over rustlsYour own HttpClient. Build without default features to drop reqwest.

ClientOptions::new(base_url) sets these defaults, and builder methods change them. To turn on failover, read the host list once and create the client with it:

use corelayer::{Client, ClientOptions, Error};

pub async fn client_with_failover() -> Result<Client, Error> {
let bootstrap = Client::new(ClientOptions::new("https://api.co-relayer.com"))?;
let hosts = bootstrap.get_network().await?.data.direct_hosts.unwrap_or_default();
Client::new(ClientOptions::new("https://api.co-relayer.com").direct_hosts(hosts))
}

How requests behave​

  • A request body is serialised once, and every attempt sends the same bytes.
  • When direct hosts are set and the main host does not answer within failover_after (1,500 ms by default), fails to connect, answers with a 5xx, or answers 2xx with a body that is not JSON, the same bytes go to the next host. A 4xx is an answer, so it is returned at once and never tried on another host.
  • Apart from failover, nothing is retried for you.
  • For a normal call the time limit covers the whole answer, body included. For event streams and CSV exports only the wait for the headers is timed.
  • Redirects are not followed. A 3xx comes back as Error::Api, so a signed body and your API key never go to a location you did not configure.
  • A buffered answer body is limited to max_response_bytes, 32 MiB by default. A larger one counts as a failed attempt, like a network failure, and is never truncated. Streams and CSV exports are read chunk by chunk, so that limit does not apply to them, and one server-sent event may be up to 8 MiB.
  • Every request carries User-Agent: corelayer-sdk-rust/<version> unless you set your own.
  • Dropping a future cancels the call, and it is never continued on another host. Dropping the future of wait_for_intent or relay_once stops it between its steps too.
  • Times are Unix milliseconds and durations are milliseconds (the …_ms fields).
  • The crate talks only to the CoRelayer API. RelayerVerifier is the exception: it calls the gateway you give it, with a 5 s limit unless you set its timeout.