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
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 calling | Option | Sent as |
|---|---|---|
| Your server paying for your users (a sponsor key), or an agent with an API key | api_key | X-Api-Key header, on the relay and the account reads only |
| An app acting for a signed-in wallet | native_auth_token | Authorization: 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_idandserver_time_msfromget_networkand signsassign_proof_message(chain_id, sender, server_time_ms). That costs one extra request. - For the renewal of an expired lease, it takes
chain_idfrom the assignment. It estimates the server's time as the assignment'sserver_time_msplus 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>.
| Error | When | What to do |
|---|---|---|
Error::Api | The 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::Transport | No 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::Decode | A 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::RelayOnce | relay_once refused its options. | Fix the call. Nothing was signed. |
Error::SignedNonceMismatch | The 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::RelayerVerification | The 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::InvalidInput | An 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::Callback | A 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(¶ms);
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(¶ms).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
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
| Option | Default | What it does |
|---|---|---|
base_url | required | The API origin: https://api.co-relayer.com, or https://devnet-api.co-relayer.com for devnet. |
direct_hosts | none | Regional hosts to fail over to, in order. Take them from get_network. |
failover_after | 1,500 ms | How long the main host may take before the next host is tried. Used only with direct_hosts. |
timeout | 15 s | The time limit of one attempt on a direct host, or on the main host when there are none. |
max_response_bytes | 32 MiB | The largest answer body read into memory. |
api_key | none | Sent 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_token | none | Async function returning the token, sent as Authorization: Bearer. |
headers | none | Headers added to every request. |
user_agent | corelayer-sdk-rust/<version> | The User-Agent header. |
http_client | reqwest over rustls | Your 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_intentorrelay_oncestops it between its steps too. - Times are Unix milliseconds and durations are milliseconds (the
…_msfields). - The crate talks only to the CoRelayer API.
RelayerVerifieris the exception: it calls the gateway you give it, with a 5 s limit unless you set itstimeout.