Signed calls
A call to POST /v1/execute can carry an auth object: a signature by the caller over the call. Every
validator checks the signature itself before running the module, then hands the signer to the module as
ctx.caller(). The Hub cannot forge or alter it, so a module can rely on the caller without a backend
vouching for it, and a web page can call a module with the user’s own wallet.
Unsigned calls stay allowed. They run exactly as before, and ctx.caller() is empty ("").
The envelope
Section titled “The envelope”{ "module": "0x3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", "function": "place_bid", "input": "{\"amount\":1200,\"lot\":7}", "gas_limit": 2000000, "auth": { "scheme": "eip191", "signer": "0x4a62316623ad457f02cdc5d997ded67a383ec569", "nonce": 12, "issued_at": 1760000000, "expires_at": 1760000300, "signature": "0xb5fd5cffec00c9cadce9ebb9660effe641e6a0fc4f644650a35164cbd49da9cf3a6d05767e0a82afc55ed46157734a59ee436206ffcf20bbd9ab81c7a5e390591c" }}| Field | Rule |
|---|---|
scheme |
"ed25519" or "eip191". "eip712" is reserved and refused (planned for a later revision). |
signer |
The signing identity as a principal: ed25519: + 64 lowercase hex (the public key) for ed25519; 0x + 40 lowercase hex (the address) for eip191. |
nonce |
Integer, 0 ≤ nonce < 2^53. Absent if and only if read_only is true. |
read_only |
Optional. true makes a read-only call with no nonce. false is the same as absent. |
issued_at, expires_at |
Unix seconds, issued_at < expires_at ≤ issued_at + 600. |
signature |
ed25519: 0x + 128 hex (64 bytes). eip191: 0x + 130 hex (r ‖ s ‖ v, 65 bytes). |
Unknown keys in auth are rejected. input follows the usual rule: a string is passed byte for byte, any
other JSON value as canonical JSON. Sending the input as the exact string you signed avoids any doubt about
its bytes. gas_limit is signed too, so a relay cannot lower it to make the call fail after its nonce is spent.
The signed text
Section titled “The signed text”Both schemes sign the same UTF-8 text T: ten lines joined by \n, no trailing newline, integers in decimal.
For the envelope above:
necter-call-v1network: necter-testnetmodule: 0x3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3ffunction: place_bidinput: {"amount":1200,"lot":7}gas_limit: 2000000nonce: 12issued_at: 1760000000expires_at: 1760000300signer: 0x4a62316623ad457f02cdc5d997ded67a383ec569-
networkis the network’s id (necter-testneton the testnet, reported byGET /nodeasnetwork_id). Validators insert their own value, never the request’s, so a signature for one network cannot be replayed on another. -
Input line. If the input is shorter than 256 bytes and every byte is printable ASCII (
0x20–0x7e), the line isinput: <the input>, so the wallet shows it. Otherwise it isinput_hash: 0x<keccak256 of the input bytes>:input_hash: 0x956875d0d3af4718863b89e475911881cebd1cd08cfe3c2fcd0890d29def1e37(that is the hash of 300 ×
x). The two labels differ, so one can never be read as the other. -
Nonce line of a read-only call:
nonce: read-only.
Signing T:
| Scheme | Signature | Check by validators |
|---|---|---|
ed25519 |
Ed25519.sign(secret key, T) |
strict verification against the public key in signer |
eip191 |
personal_sign(T): secp256k1 over keccak256("\x19Ethereum Signed Message:\n" ‖ len(T) ‖ T) |
the recovered address must equal signer; low s only (wallets produce it) |
The call’s identity is call_hash = keccak256(canonical_json(call)), where call holds v: 1, scheme,
network, signer, nonce (or read_only: true), issued_at, expires_at, module, function,
input_hash and gas_limit. It excludes the signature. For the call above it is
0xcd40d2a49ce0100cc3516c655537fa8a425693f73a4e5ddf7871e58305ae44b6. The module reads it as
ctx.call_id(), which stays the same across retries of the same signed call: use it as an idempotency key.
Validity window
Section titled “Validity window”Validators check the envelope against the round’s time t (the issued_at of the round, chosen by the Hub
and within 300 s of every validator’s clock), not against their own clocks:
t + 300 ≥ auth.issued_at: an envelope may come from up to 5 minutes in the “future”, to absorb clock skew.t < auth.expires_at: the call must reach a round before it expires.expires_at − issued_at ≤ 600: an envelope lives at most 10 minutes.
Use issued_at = now and expires_at = now + 300 from your own clock. A device whose clock is more than
5 minutes off gets its calls rejected as expired or from the future; a backend can take now from the Hub’s
Date response header instead.
Nonces
Section titled “Nonces”Signed calls are protected from replay by a sequential nonce per (module, signer), starting at 0:
- The runtime keeps it in the called module’s own state, under a reserved key (prefix
0xFF "ndsr") that modules can read but not write. - A call is accepted only with the next expected nonce. Once a signed call reaches execution its nonce is
consumed whether the call succeeds or fails (trap, abort, error, out of gas). Without that, a call that
failed could be replayed later and succeed in different state. A failed signed call therefore commits its
nonce write, and the module’s state
seqadvances. - Nonces are per module: calls to different modules never wait for each other. Calls from one signer to one module are sequential; send them one at a time.
- Nested calls (
hive.call) do not use nonces: only the top-level module’s nonce is checked.
Look up the next nonce:
curl -s https://testnet-rpc.necter.network/v1/nonce/0x3f3f…3f3f/0x4a62316623ad457f02cdc5d997ded67a383ec569{ "module": "0x3f3f…3f3f", "signer": "0x4a62316623ad457f02cdc5d997ded67a383ec569", "next_nonce": 12, "seq": 41, "root": "0x…" }The Hub answers from finalized rounds, so treat it as a hint: the round decides. A validator node serves the
same from its committed state at GET /state/nonce/{module}/{signer}. A wrong nonce fails with
nonce mismatch: expected n; take n from the error and sign again.
Read-only calls
Section titled “Read-only calls”A signed call can prove who is asking without spending a nonce: set "read_only": true and omit nonce.
The text says nonce: read-only.
- The runtime enforces it: any
storage.setorstorage.del, in any frame including modules reached throughhive.call, fails the whole execution withstorage write in a read-only call. Reads, events, logs and cross-module calls are allowed. - Nothing is written: no nonce, no state change. The call can be replayed until it expires, which is harmless because it cannot change state.
- It costs only the signature verification (no nonce charge), and the receipt still records the caller.
Use it for queries that depend on who asks, such as “my orders” or “my balance”, where the result must be agreed by validators.
Caller and origin
Section titled “Caller and origin”Inside the module, ctx.caller() is the immediate caller: the signer at the top level, the calling module’s
address inside hive.call. ctx.origin() is always the signer of the top-level call.
Errors
Section titled “Errors”| Case | Response |
|---|---|
Malformed auth, unknown key, signer of the wrong kind for scheme |
400; nothing executed, no receipt, nonce not consumed |
Signature invalid for T |
400; same |
Expired (t ≥ expires_at), from the future (issued_at > t + 300), or window over 600 s |
400; same |
gas_limit below the signed-call charge (246,000 for ed25519, 796,000 for eip191) |
400; same |
scheme: "eip712" |
400 scheme eip712 is not supported in execution revision 3 |
auth sent before revision 3 is active |
refused by the Hub |
| Wrong nonce | 200, "success": false, error: "nonce mismatch: expected n", gas_used = the signed-call charge, nothing written |
| Module fails after the nonce check | 200, "success": false; the nonce is consumed |
| Write in a read-only call | 200, "success": false, error: "storage write in a read-only call", nothing written |
| Too many calls | 429; the Hub rate-limits per signer and per IP |
The 400 checks depend only on the request, the round and the network id, so every honest validator decides
them the same way. The Hub runs them too before creating a round, but validators never rely on it.
A signed call is charged once, before the module starts: 240,000 gas for ed25519, 790,000 for
eip191, plus 6,000 for the nonce (not for read-only calls). An eip191 call therefore uses at least
796,000 gas. Gas and limits
The response
Section titled “The response”The response carries a context next to the receipt, and the receipt is v: 2 with a context_hash:
{ "success": true, "output": "{\"accepted\":true,\"high_bid\":1200}", "gas_used": 1014512, "round_id": "exec:0x23f8fc2177f677d687f137d29bece3ede1d7371374b7558c3153d253527afcc7", "context": { "v": 1, "network": "necter-testnet", "kind": 1, "time": 1760000004, "round_id": "exec:0x23f8fc2177f677d687f137d29bece3ede1d7371374b7558c3153d253527afcc7", "origin": "0x4a62316623ad457f02cdc5d997ded67a383ec569", "call_hash": "0xcd40d2a49ce0100cc3516c655537fa8a425693f73a4e5ddf7871e58305ae44b6" }, "receipt": { "v": 2, "context_hash": "0x652590fcb914f59f51d0c54faddfe965cf5eeff2d592e0dd299f6fdda9a687e2", "…": "…" }}context.origin is the signer, context.time the round’s time. Verify context_hash and the receipt
yourself: Receipts.
From a browser with MetaMask
Section titled “From a browser with MetaMask”A web page can call the Hub directly with the user’s wallet: no API key, no cookie, no backend.
- Ask the wallet for the account and fetch the next nonce:
GET /v1/nonce/{module}/{address}. - Build
Tand ask the wallet to sign it withpersonal_sign. The wallet shows the text, including the input when it is short. POST /v1/executewithauth.- Read the result;
context.originis the wallet address.
<script type="module">
const HUB = 'https://testnet-rpc.necter.network' const NETWORK = 'necter-testnet' const MODULE = '0x…' // your module's address const GAS_LIMIT = 2000000
function callText({ fn, input, nonce, issuedAt, expiresAt, signer }) { const bytes = toBytes(input) const printable = bytes.length < 256 && bytes.every((b) => b >= 0x20 && b <= 0x7e) return [ 'necter-call-v1', `network: ${NETWORK}`, `module: ${MODULE}`, `function: ${fn}`, printable ? `input: ${input}` : `input_hash: ${keccak256(bytes)}`, `gas_limit: ${GAS_LIMIT}`, `nonce: ${nonce ?? 'read-only'}`, `issued_at: ${issuedAt}`, `expires_at: ${expiresAt}`, `signer: ${signer}`, ].join('\n') }
async function signedCall(fn, inputObject) { const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' }) const signer = account.toLowerCase() const { next_nonce: nonce } = await (await fetch(`${HUB}/v1/nonce/${MODULE}/${signer}`)).json() const issuedAt = Math.floor(Date.now() / 1000) const expiresAt = issuedAt + 300 // Keys in sorted order, no spaces: the same bytes the module receives. const input = JSON.stringify(inputObject, Object.keys(inputObject).sort()) const text = callText({ fn, input, nonce, issuedAt, expiresAt, signer }) const signature = await window.ethereum.request({ method: 'personal_sign', params: [stringToHex(text), account] })
const res = await fetch(`${HUB}/v1/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ module: MODULE, function: fn, input, gas_limit: GAS_LIMIT, auth: { scheme: 'eip191', signer, nonce, issued_at: issuedAt, expires_at: expiresAt, signature }, }), }) const out = await res.json() if (!res.ok) throw new Error(out.message ?? out.error) return out // out.success, out.output, out.context.origin === signer }
const out = await signedCall('place_bid', { lot: 7, amount: 1200 }) console.log(out.success ? JSON.parse(out.output) : out.error, 'signed by', out.context.origin)</script>The JSON.stringify replacer above sorts only top-level keys; for nested objects, build the canonical JSON
yourself or pass the input as a string you control. A complete page with connect, nonce lookup, signing and
receipt display: Browser example: signed calls with MetaMask.
@necter/hivejs gains the same flow as a signer option (an EIP-1193 provider such as window.ethereum, or
an ed25519 key): it fetches the nonce, builds T, signs, posts, retries once on nonce mismatch and exposes
the receipt’s context, with buildCallText and callHash helpers. It ships with the HiveKit release for
revision 3.
From a backend
Section titled “From a backend”Backends usually sign with an ed25519 key (cheaper: 246,000 gas against 796,000) or with a secp256k1
key when the module expects an EVM address. The HiveKit clients provide Ed25519Signer(seed) and
Secp256k1Signer(secret) (Python, Go, Rust) and hivejs’s signer option, with a per-(module, signer) nonce
cursor that serializes concurrent calls from one process. The flow by hand, in Python:
import json, time, requests # pip install requests cryptography eth-accountfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKeyfrom eth_account import Accountfrom eth_account.messages import encode_defunctfrom eth_utils import keccak
HUB = "https://testnet-rpc.necter.network"NETWORK = "necter-testnet"
def canonical(v) -> bytes: return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def input_bytes(inp) -> bytes: return inp.encode() if isinstance(inp, str) else canonical(inp)
def call_text(module, function, inp, gas_limit, nonce, issued_at, expires_at, signer) -> str: b = input_bytes(inp) if len(b) < 256 and all(0x20 <= c <= 0x7E for c in b): input_line = "input: " + b.decode() else: input_line = "input_hash: 0x" + keccak(b).hex() return "\n".join([ "necter-call-v1", f"network: {NETWORK}", f"module: {module}", f"function: {function}", input_line, f"gas_limit: {gas_limit}", f"nonce: {'read-only' if nonce is None else nonce}", f"issued_at: {issued_at}", f"expires_at: {expires_at}", f"signer: {signer}", ])
class Ed25519Key: scheme = "ed25519" def __init__(self, seed: bytes): self.sk = Ed25519PrivateKey.from_private_bytes(seed) self.signer = "ed25519:" + self.sk.public_key().public_bytes_raw().hex() def sign(self, text: str) -> str: return "0x" + self.sk.sign(text.encode()).hex()
class Secp256k1Key: scheme = "eip191" def __init__(self, secret: bytes): self.acct = Account.from_key(secret) self.signer = self.acct.address.lower() def sign(self, text: str) -> str: return "0x" + bytes(self.acct.sign_message(encode_defunct(text=text)).signature).hex()
def signed_call(key, module, function, inp, gas_limit=2_000_000, read_only=False): nonce = None if not read_only: nonce = requests.get(f"{HUB}/v1/nonce/{module}/{key.signer}").json()["next_nonce"] issued_at = int(time.time()) expires_at = issued_at + 300 text = call_text(module, function, inp, gas_limit, nonce, issued_at, expires_at, key.signer) auth = {"scheme": key.scheme, "signer": key.signer, "issued_at": issued_at, "expires_at": expires_at, "signature": key.sign(text)} if read_only: auth["read_only"] = True else: auth["nonce"] = nonce r = requests.post(f"{HUB}/v1/execute", json={ "module": module, "function": function, "input": inp, "gas_limit": gas_limit, "auth": auth}) r.raise_for_status() return r.json()
backend = Ed25519Key(bytes.fromhex(open("backend.key").read().strip())) # 32-byte seed, kept secretout = signed_call(backend, "0x…", "place_bid", {"lot": 7, "amount": 1200})print(out["success"], out["output"], out["context"]["origin"])With the vector keys (Ed25519Key(bytes([9] * 32)), Secp256k1Key(bytes([7] * 32))), module 0x3f…3f,
nonce 12 and window 1760000000–1760000300, call_text and sign reproduce the signed_text and
signature values of rev3-vectors.json exactly.