Relay client
RelayClient writes to a context through a relay — another node that runs
the method on your behalf, under a warrant you signed.
It is the path for a caller that cannot execute at all. sdk.rpc.execute asks a
node to run a method as itself: it needs a node, that node has to be a member,
and you need a credential on it. A browser tab, a phone, or an agent has none of
the three — the runtime cannot compile WASM against materialized state, never
received the scope key that seals the deltas, and has no account on anybody’s
node.
What makes the resulting write yours rather than the relay’s is the warrant: the delta carries your consent, so every peer verifies you asked rather than taking the relay’s word. It is attributed to your account and device — your replica slot, your membership, your name in the app. See Core → Delegated Authorship for the protocol.
import { RelayClient, createLocalStorageNonceSource,} from '@calimero-network/mero-js';
const relay = new RelayClient({ relayUrl: 'https://relay-01.tee.calimero.network', executorAccount, // optional; checked against what the relay answers authorAccount, authorProof, deviceSecret, nonces: createLocalStorageNonceSource(`warrant-nonce:${devicePublicKey}`),});
const { rootHash, returns } = await relay.execute(contextId, 'set', { key: 'k', value: 'v' });For the hosted tier, connectCloud builds this for
you from a cloud sign-in: it finds the relay and its executor account, and
picks a persisted nonce source.
Constructor
Section titled “Constructor”interface RelayClientConfig { relayUrl: string; // the node's origin, not a path executorAccount?: string; // hex; checked against the relay's answer authorAccount: string; // hex — whose consent the warrant carries authorProof: string; // hex borsh AccountProof<DeviceCert> deviceSecret?: string; // hex ed25519 seed; NEVER transmitted signer?: Signer; // or: a key that cannot be exported (exactly one) nonces: NonceSource; ttlSeconds?: number; // default 300 fetch?: typeof fetch; timeoutMs?: number; // default 10000}executorAccount is only a check; see what a warrant names.
deviceSecret signs in your process and only the signature leaves. That is the
whole reason a keyholder can author without a node.
A key that cannot be exported to hex (a non-extractable WebCrypto key, a
passkey, a hardware or remote signer) goes in signer instead: anything with a
publicKey (64 hex) and an async sign(payload) returning a 64-byte Ed25519
signature over the payload as given. signerFromCryptoKey(privateKey, publicKey)
wraps a WebCrypto key; signerFromSecret(hex) wraps a seed. signWarrant,
signCreationWarrant and signGovernanceWarrant take the same pair of options.
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify']);const relay = new RelayClient({ relayUrl, authorAccount, authorProof, nonces, signer: await signerFromCryptoKey(pair.privateKey, pair.publicKey),});What a warrant names
Section titled “What a warrant names”Every warrant names the relay twice: executor, its account, and
executor_key, the one signing key of that account that may spend it.
A relay refuses a warrant naming any other key, so one warrant is spent by one
relay node, once.
Only the relay can report its key, so the client asks it (describe,
describeCreation or describeGovernance), even when executorAccount is
configured, and refuses before taking a nonce when a configured account
disagrees or the relay names no key.
A relay that re-keys voids the warrants it has not spent yet.
An intent warrant also pins the release the context’s group names:
release_bytecode_id, the blob id of its bytecode, and release_version, its
semver (signed, never compared, at most 256 bytes of UTF-8).
The relay refuses a warrant pinning any other release, before it runs anything,
so a warrant signed against one build cannot be spent after an upgrade widens
what the method does.
describe reports both, and rejects with discovery’s 404 while the group names no release.
In all three warrants executor_key (32 bytes) comes right after executor,
in the signed preimage and in the borsh encoding alike:
intent: context ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ release_bytecode_id ‖ release_version ‖ method ‖ …creation: group ‖ seed ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ application_id ‖ …governance: scope ‖ kind ‖ author_account ‖ author_device_key ‖ executor ‖ executor_key ‖ op_hash ‖ …release_version is a borsh String in the encoding (a u32 little-endian
length, then the UTF-8) and its own length-prefixed part in the preimage.
signWarrant, signCreationWarrant and signGovernanceWarrant take the key as
executorKey (hex), signWarrant takes the release as releaseBytecodeId
(hex) and releaseVersion, and core pins the bytes in
crates/account/src/tests/{warrant,creation,governance}_wire_fixture.rs.
describe(contextId): Promise<RelayDescription>
Section titled “describe(contextId): Promise<RelayDescription>”What the relay can do here — read this before signing anything.
interface RelayDescription { executorAccount: string; // what the warrant's `executor` must name executorKey: string; // what its `executor_key` must name: the relay's signing key canAuthorOnBehalf: boolean; // does the relay hold the grant on the owning group? groupId: string; // whose admin grants it releaseBytecodeId: string; // the release the group names: what the warrant pins releaseVersion: string; // its semver, signed alongside it}canAuthorOnBehalf: false is an answer, not an error, and it is the default
state of every context — CAN_AUTHOR_ON_BEHALF is implied by neither
membership nor admin, and is not propagated by the subgroup cascade. Read it to
tell “ask an admin of groupId to grant it to executorAccount” from “retry
later”.
Checking first is not politeness: minting a warrant spends a nonce from a monotonic per-device sequence, and one naming the wrong executor is unspendable — the number is gone and the write never happened.
execute(contextId, method, argsJson?): Promise<IntentResult>
Section titled “execute(contextId, method, argsJson?): Promise<IntentResult>”Mint a warrant for method(argsJson) and present it.
interface IntentResult<T = unknown> { rootHash: string; // the context's scope root after the run — did this change anything? returns: T | null; // the method's own return value}One intent, once. The warrant authorizes exactly this method and these arguments in exactly this context, and its nonce is spent by the network on apply — nothing accumulates and nothing is reusable.
Every call runs describe first and signs what it answers, so a warrant pins
the release the group names now and follows a relay that re-keyed.
A group that names no release yet surfaces as discovery’s 404 HTTPError,
before a nonce is taken.
describeCreation(groupId, { author? }): Promise<CreationDescription>
Section titled “describeCreation(groupId, { author? }): Promise<CreationDescription>”What the relay can do about creating contexts in a group —
GET /admin-api/groups/{groupId}/context-intents[?author=].
interface CreationDescription { executorAccount: string; // what the creation warrant's `executor` must name executorKey: string; // and its `executor_key` groupId: string; canCreateOnBehalf: boolean; // relay standing: RelayTee role or CAN_AUTHOR_ON_BEHALF authorMayCreate?: boolean; // only with `author`: CAN_CREATE_CONTEXT or admin}createContext(input): Promise<CreatedContext>
Section titled “createContext(input): Promise<CreatedContext>”Create a context in a group without a node. You sign a creation warrant
locally; the relay publishes it, every peer checks your CAN_CREATE_CONTEXT
(the relay needs no create rights of its own), and the app’s init runs as your
account.
const { contextId, groupId, memberPublicKey } = await relay.createContext({ groupId, applicationId, initArgs: { name: 'general' }, // what init() receives; default {} name: 'general', // optional display name serviceName: undefined, // optional, for multi-service bundles seed: undefined, // optional hex 32; random when omitted});It always calls describeCreation (with your account) first and throws
IntentRefusedError before taking a nonce when the relay has no standing or
you may not create. The warrant’s nonce comes from the same NonceSource —
it is spent in the new context’s per-device ledger. Over a sealed fetch the
request is sealed like execute; a proxy-auth relay accepts it only once core
serves the route without a credential.
signCreationWarrant, parseCreationWarrant and creationInitHash are
exported for callers that mint the warrant themselves.
describeGovernance(groupId): Promise<GovernanceDescription>
Section titled “describeGovernance(groupId): Promise<GovernanceDescription>”Whether the relay may publish governance ops for members of a group:
GET /admin-api/groups/{groupId}/governance-intents.
interface GovernanceDescription { executorAccount: string; // what the governance warrant's `executor` must name executorKey: string; // and its `executor_key` groupId: string; canActOnBehalf: boolean; // relay standing: RelayTee role or CAN_AUTHOR_ON_BEHALF}govern(input): Promise<GovernResult>
Section titled “govern(input): Promise<GovernResult>”Publish one governance op without a node: add someone to a group, change a
role, create a subgroup. You sign a governance warrant over the op’s exact
borsh bytes; the relay wraps it and publishes it, and every peer applies the op
as you, so your rights decide (MANAGE_MEMBERS to add a member, admin to
add an admin, CAN_CREATE_SUBGROUP to create a subgroup). The relay needs only
standing to act for members.
import { memberAddedOp, subgroupCreation } from '@calimero-network/mero-js';
// A group op, published on the group itself.await relay.govern({ groupId, op: memberAddedOp(otherAccount, 'Member') });
// A root op, published on the NAMESPACE. A subgroup's id is derived from its// create (admin, parent, visibility and a random salt), so derive it first.const { groupId: channel, op } = await subgroupCreation({ parentId: namespaceId, restricted: true, admin: myAccount,});await relay.govern({ groupId: namespaceId, op });The op encoders produce each op in its delegable form: memberRemovedOp,
memberLeftOp and groupDeletedOp leave the post-state hashes and the cascade
lists empty, for the relay to fill in from its own view. The others are
memberAddedOp, memberRoleSetOp, groupCreatedOp and groupReparentedOp.
The warrant’s scope is groupId, so it cannot be spent in another group, and
the op’s plane (group or root) is signed over with it.
Like createContext, it calls describeGovernance first and throws
IntentRefusedError before taking a nonce when the relay has no standing.
An op your rights do not cover comes back from the relay as an
IntentRefusedError with status 403.
signGovernanceWarrant, parseGovernanceWarrant and governanceOpHash are
exported for callers that mint the warrant themselves.
foundNamespace(input?): Promise<FoundedNamespace>
Section titled “foundNamespace(input?): Promise<FoundedNamespace>”Found a namespace without a node, with you as its founder, owner and admin.
You sign a governance warrant (kind root) over the exact genesis,
namespaceCreatedOp({ founder, credential, salt }), scoped to the namespace id
foundedNamespaceId(founder, salt). The id is derived from your account and the
salt, so the relay can neither pick another id nor found it for anyone else. It
posts to POST /admin-api/groups/{namespaceId}/governance-intents.
import { CAPABILITY_PRESETS } from '@calimero-network/mero-js';
const { namespaceId, salt, teeEnabled, defaultCapabilitiesSet } = await relay.foundNamespace({ // The namespace does not exist yet, so there is nothing to describe: the key // is the relay's node key, pinned or from `resolveRelayNodeKey(relayUrl)`. executor: { executorAccount: relayAccount, executorKey: relayKey }, // optional: your app's default member mask (COLLABORATOR = 231; see Capabilities) defaultCapabilities: CAPABILITY_PRESETS.COLLABORATOR,});interface FoundNamespaceInput { salt?: string; // 32 bytes hex; random when absent executor: { executorAccount: string; executorKey: string }; // the relay's node key, never a discovery answer defaultCapabilities?: number; // MemberCapabilities bits, set right after founding}interface FoundedNamespace { namespaceId: string; salt: string; // keep it: (founder, salt) proves who founded the namespace teeEnabled: boolean; // the relay admitted itself as the namespace's first TEE teeError?: string; // why not, when it tried and failed defaultCapabilitiesSet?: boolean; // only when defaultCapabilities was given defaultCapabilitiesError?: string;}The relay is seated in the new namespace as its founding relay (a Member
holding CAN_AUTHOR_ON_BEHALF), so govern, createContext and execute
work through it straight away. A TEE relay then admits itself as the
namespace’s first TEE (GroupOp::FoundingRelayAttested), which also sets a
default relay-mode admission policy; teeEnabled says whether it did. The
namespace is founded either way.
A namespace is founded with core’s minimal default mask
(CAN_JOIN_OPEN_SUBGROUPS). With defaultCapabilities, you then set your own
through the same relay: a delegated DefaultCapabilitiesSet
(defaultCapabilitiesSetOp(mask)) on the namespace root, under a second
warrant and nonce, applied as you, the admin. It may not include
CAN_AUTHOR_ON_BEHALF (bit 9), which is refused before anything is signed. A
failure there does not throw, since the namespace already exists:
defaultCapabilitiesSet is false and defaultCapabilitiesError says why.
There is no describe step: without executor it throws before taking a
nonce. A salt that names an existing
namespace comes back as an HTTPError with status 409.
IntentRefusedError
Section titled “IntentRefusedError”A refusal (400 or 403) surfaces as IntentRefusedError rather than a bare
HTTPError, carrying the relay’s own explanation:
class IntentRefusedError extends Error { reason: string; // the relay's explanation, verbatim retryable: boolean; // true only when a FRESH warrant may work (spent nonce) status: number; // 400 or 403}The distinction is the whole reason the type exists. A 403 from this endpoint
means one of three unrelated things and they send you somewhere completely
different:
| Reason | What to do |
|---|---|
the relay holds no CAN_AUTHOR_ON_BEHALF grant |
ask an admin of the group — retryable: false |
| the author’s account is not a member of the group | get an invitation — retryable: false |
| the warrant’s nonce is already spent | re-present under a fresh warrant — retryable: true |
A 500 stays an HTTPError: that is the node’s problem, not a precondition of
yours. A transport failure is an HTTPError with status: 0, as everywhere
else in this SDK. Its message carries what the injected fetch threw, so a
sealing failure from createAttestedSealedFetch reads as, say,
HTTP 0 Error: The node refused to attest: HTTP 404.
Nonce sources
Section titled “Nonce sources”A warrant’s nonce is not a local detail. It is spent, once, per
(context, author device) on every node that applies the delta, and the client
is the only party that can keep the sequence.
The receiving ledger is a sliding window 64 wide, not a high-water mark, which makes the two failure modes very different:
- Skipping numbers is free. Gossip gives no ordering, so the window exists precisely to accept whatever unseen nonce arrives.
- Restarting the sequence is fatal. A tab that reloads and resumes at
1re-presents nonces the network has already spent, and every one of those writes is refused as a replay — indistinguishable, to the user, from the app silently not saving.
createLocalStorageNonceSource(key, storage?) // persisted; survives a reloadcreateMemoryNonceSource(start = 1) // for a process that owns its whole sequenceKey it per author device, not per account: two devices of one account are independent replicas with independent sequences, and sharing a counter would have them refusing each other’s warrants.
Implement NonceSource yourself for anything else — it is one method,
next(): Promise<bigint>, and must never go backwards.
Reaching a relay you have no account on
Section titled “Reaching a relay you have no account on”POST /admin-api/contexts/:id/intents normally sits behind the node’s auth
guard. A node run as a relay serves that route and its GET twin without a
node credential, because they carry their own: the warrant commits to this
context, method and arguments, is single-use, and is refused before execution
unless the relay holds the authorship grant.
On the node that is merod init --delegated-access (or
server.admin.delegated_access; older nodes call it --public-intents /
public_intents, and current ones still accept that spelling), and for a node behind a reverse proxy — which is
every hosted one — the proxy has to exempt the same path, since it is the layer
actually enforcing auth. Hosted TEE fleet nodes are configured that way, from a
single setting that drives both. A self-hosted node is not, by default: a member
with their own node authors their own writes and does not need any of this.
So a 401/403 from the proxy rather than an IntentRefusedError from the
node means you are pointed at a node that is not serving as a relay — not that
your warrant is wrong.
Sealed to a TEE relay
Section titled “Sealed to a TEE relay”Give the client a sealed fetch and the warrant and arguments reach only the relay’s attested TD:
import { RelayClient, createAttestedSealedFetch, createQuoteVerifier, trustedMeasurementsFromReleases,} from '@calimero-network/mero-js';// The node release's published-mrtds.json, shipped with the app.import release from './trusted/mero-tee-v2.3.78.published-mrtds.json';import { verify as dcapVerify } from '@phala/dcap-qvl';
const relay = new RelayClient({ relayUrl, authorAccount, authorProof, deviceSecret, fetch: createAttestedSealedFetch({ baseUrl: relayUrl, verify: createQuoteVerifier({ dcapVerify, ...trustedMeasurementsFromReleases([release], { profile: 'locked-read-only' }), }), }),});createAttestedSealedFetch attests the relay (POST /admin-api/tee/attest, one
of the calls that is never sealed), verifies the quote, and seals both
describe and execute. connectCloud({ seal }) does the same for the relay it
discovers. On a relay that leaves auth to a proxy, which every hosted one does,
only describe/execute and the public probes can be sealed: a sealed request
for anything else comes back 403 sealed_route_unguarded. See what can be
sealed, and where.
Trusting the relay’s signed release
Section titled “Trusting the relay’s signed release”Shipping published-mrtds.json pins the app to the releases it was built with.
createSignedReleaseSealedFetch instead reads the release the relay runs from
its GET /admin-api/tee/info, fetches that release’s file and bundle from the
cloud’s mirror (https://cloud.calimero.network/api/tee/node-releases/{version},
answering {"data": {"version", "publishedMrtds", "bundle"}}), verifies its
Sigstore signature in the client, and trusts that release’s measurements only:
import { RelayClient, createSignedReleaseSealedFetch } from '@calimero-network/mero-js';import { verify as dcapVerify } from '@phala/dcap-qvl';
const relay = new RelayClient({ relayUrl, authorAccount, authorProof, deviceSecret, fetch: createSignedReleaseSealedFetch({ baseUrl: relayUrl, dcapVerify, profile: 'locked-read-only', minReleaseVersion: '2.3.87', }),});The relay is not trusted to say which image it runs. Its release must be signed
by mero-tee’s node release workflow (checked against the Sigstore trust root
embedded in mero-js), be no older than minReleaseVersion, and its quote must
then match that release exactly. So the relay chooses only which signed release,
at or above your floor, it presents. The floor is required because old releases
stay validly signed forever. The mirror is not trusted either: releaseSource
names another mirror’s base URL, or a function returning the release, such as a
file bundled with the app, and whatever it returns is verified the same way. See
trusting a signed release at run time.
Bundling the release file, as above, still works for offline use.