HTTP API
Every route a qubits-node serves: method, path, parameters, request and response JSON, status codes, limits and binary formats. It is a reference for wallet and explorer authors, node operators and anyone checking a node's answers instead of trusting them.
Conventions
The API listens on --rpc-addr, default 127.0.0.1:8545. It has no authentication. Chain data comes from committed state: a block is written only once it is final, so nothing here is speculative. Only pending transactions come from the node's own mempool.
Examples are real replies from a local single-validator chain whose chain_id is qubits-demo. It is not a public network. Hex strings longer than 64 characters are cut with ...; everything else is as sent.
| Rust type | JSON form | Example |
|---|---|---|
Amount (u128) | decimal string in base units, because values pass 2^53 | "1000000000000000000" |
u64, u32, u16, usize | JSON number | 1794010632260 |
Hash | 64 hex characters | "fabaed48...7ca72b" |
Address | bech32m, prefix qb, 32 bytes | "qb1fynt55...rvp4u" |
PublicKey, Signature | hex of one scheme byte (01 ML-DSA-65, 02 SLH-DSA-SHA2-192s) and the raw bytes | "0129f438..." |
| Byte strings | hex, empty string when empty | "7472616e73666572" |
Option | the value or null | null |
| Enums | snake_case, externally tagged: a unit variant is a string, any other is an object with one key | "done", {"reverted":{"reason":"..."}} |
The native token has 18 decimals, but the API deals only in base units. See Accounts.
Errors
Handlers answer an error with this body:
pub struct ErrorResponse {
pub error: String,
}
{"error":"no block at that height"}
| Status | When | Body |
|---|---|---|
| 200 | Success | The route's JSON or bytes |
| 202 | A transaction entered the mempool | SubmitResponse |
| 400 | Bad address, hash, hex or transaction, or a transaction the rules refuse | ErrorResponse, or framework text |
| 404 | Nothing at that height, id or address. Also an unknown path | ErrorResponse, empty for an unknown path |
| 405 | Wrong method for the path | Empty |
| 408 | The handler took over 30 s | Empty |
| 409 | The transaction is already in the mempool or a block | ErrorResponse |
| 413 | The body is over the limit | Framework text |
| 415 | A POST without Content-Type: application/json | Framework text |
| 422 | The JSON is valid but has the wrong shape | Framework text |
| 500 | A storage failure, logged as request failed; the message starts with storage: | ErrorResponse |
| 503 | The mempool is full, or there is no snapshot to serve | ErrorResponse |
Errors the framework raises before a handler runs are
text/plain, not JSON. Read the body as text and parse it second, as the CLI and the wallet both do.
Real framework bodies:
400 GET /v1/blocks/abc Invalid URL: Cannot parse `abc` to a `u64`
400 GET /v1/blocks/15/raw?prove=1 Failed to deserialize query string: prove: provided string was not `true` or `false`
400 GET /v1/state/ranges Failed to deserialize query string: missing field `height`
400 POST with cut-off JSON Failed to parse the request body as JSON: function: EOF while parsing a value at line 1 column 12
413 POST over the body limit Failed to buffer the request body: length limit exceeded
415 POST without the content type Expected request with `Content-Type: application/json`
422 POST with "args":"zz" Failed to deserialize the JSON body into the target type: args: Invalid character 'z' at position 0 at line 1 column 28
422 POST with no "args" Failed to deserialize the JSON body into the target type: missing field `args` at line 1 column 16
Limits
| Limit | Value | Constant |
|---|---|---|
| Time until the response starts | 30 s | REQUEST_TIMEOUT |
| Request body | 590,848 bytes | 2 * MAX_TX_BYTES + 1024 (hex doubles the size) |
| Submissions and views running at once | 32 | MAX_CONCURRENT_SUBMITS |
| One encoded transaction | 294,912 bytes | MAX_TX_BYTES |
| View arguments | 16,384 bytes | MAX_ARGS_BYTES |
| View function name | 64 bytes | MAX_FUNCTION_NAME |
| Gas given to a view | 5,000,000 | MAX_TX_GAS |
| Transactions in the mempool | 10,000 | MAX_MEMPOOL_TRANSACTIONS |
| Transactions per sender in the mempool | 64 | MAX_PER_SENDER |
| WebSocket connections open at once | 1,024 | --ws-limit, default MAX_CONNECTIONS |
| Transactions watched per WebSocket connection | 64 | MAX_WATCHED |
| Blocks a WebSocket connection may fall behind | 64 | MAX_BEHIND |
| One message from a WebSocket client | 1,024 bytes | MAX_REQUEST_BYTES |
| State chunk, default | 2,097,152 bytes | DEFAULT_CHUNK_BYTES |
| State chunk, ceiling | 4,194,304 bytes, soft | MAX_CHUNK_BYTES |
POST /v1/transactions and POST /v1/contracts/{address}/view share one semaphore. A request takes a permit only after its body has arrived, so a slow client can't sit on one, and then runs on the blocking pool while it holds the permit. A request that finds all 32 taken waits in line; nothing refuses it early.
The timeout ends the HTTP exchange, not the work. Work already on the blocking pool finishes, so a 408 on a submission does not prove the transaction was refused. The timeout runs until the response head is ready, so the streamed body of /v1/eras/{index} can take longer than 30 s.
servehands the bare listener toaxum::serve. Apart from WebSocket connections there is no connection limit, and there is no header read timeout. Run public API nodes behind a reverse proxy, as Node operation describes.
CORS
The outermost layer is CorsLayer::permissive(): any origin, any method, any request header, every response header exposed, no credentials. The browser wallet depends on this. Its manifest has no host permissions, so it reaches the node with plain cross-origin fetch.
A real preflight for POST /v1/transactions:
HTTP/1.1 200 OK
vary: origin, access-control-request-method, access-control-request-headers
access-control-allow-methods: *
access-control-allow-headers: *
access-control-allow-origin: *
allow: POST
content-length: 0
Routes
| Method | Path | Answers with |
|---|---|---|
| GET | /health | Text: ok and the height |
| GET | /metrics | Prometheus text |
| GET | /v1/status | StatusResponse |
| GET | /v1/accounts/{address} | AccountResponse |
| GET | /v1/blocks/latest | BlockResponse |
| GET | /v1/blocks/{height} | BlockResponse |
| GET | /v1/blocks/{height}/raw | RawBlockResponse |
| GET | /v1/validators | ValidatorsResponse |
| GET | /v1/rewards | RewardsResponse |
| GET | /v1/rewards/{address} | RewardResponse |
| GET | /v1/epochs | EpochsResponse |
| POST | /v1/transactions | SubmitResponse |
| GET | /v1/transactions/{id} | TransactionResponse |
| GET | /v1/contracts | ContractsResponse |
| GET | /v1/contracts/{address} | ContractResponse |
| POST | /v1/contracts/{address}/view | ViewResponse |
| GET | /v1/proofs/accounts/{address} | AccountProofResponse |
| GET | /v1/state/snapshot | SnapshotResponse |
| GET | /v1/state/ranges | Borsh StateChunk |
| GET | /v1/eras | ErasResponse |
| GET | /v1/eras/{index} | Era file, streamed |
| GET | /v1/ws | A WebSocket. See WebSocket |
Health and metrics
GET /health answers 200 with text/plain once the node can read its head. It says the database opens, not that the chain moves.
ok 19
GET /metrics answers Prometheus text with 14 metrics, from qubits_height to qubits_history_pruned_total. See Node operation.
Status
GET /v1/status
No parameters. Always 200, or 500 on a storage failure.
pub struct StatusResponse {
pub chain_id: String,
pub genesis_hash: Hash,
pub height: u64,
pub latest_hash: Hash,
pub latest_timestamp_ms: u64,
pub state_root: Hash,
pub min_fee_per_byte: u64,
pub mempool_size: usize,
pub state_from: u64,
pub blocks_from: u64,
}
| Field | Meaning |
|---|---|
chain_id | Name from the genesis file |
genesis_hash | The chain's identity. Every transaction signs it |
height | Newest committed block; 0 at genesis |
latest_hash | Id of that block, or the genesis hash at height 0 |
latest_timestamp_ms | Its block time, or the genesis timestamp at height 0 |
state_root | State root at height |
min_fee_per_byte | From genesis, in base units |
mempool_size | Transactions waiting on this node |
state_from | State below this height is gone: pruned, or never here after a snapshot |
blocks_from | Oldest block held in full. Below it only checkpoints and epoch ends remain, if anything |
{
"chain_id": "qubits-demo",
"genesis_hash": "fabaed48ca4ce5fadd5bfb84829428cdb7ae53eac4dc451a5228e8686c7ca72b",
"height": 19,
"latest_hash": "a283f6e8e3356f306f44fc8ee86c50d8710de25409875b6701970695720caa9e",
"latest_timestamp_ms": 1794010632260,
"state_root": "ee77641ae57fe4405bee6081c6f86b3d7c63daef13510cf5bf45de13dbd4a090",
"min_fee_per_byte": 10000000000,
"mempool_size": 0,
"state_from": 0,
"blocks_from": 1
}
Accounts
GET /v1/accounts/{address}
| Parameter | In | Type | Meaning |
|---|---|---|---|
address | path | qb1... | The account |
An address nothing has touched is not an error: the reply is 200 with exists: false, nonce 0 and empty fields. A malformed address is 400, for example invalid address: invalid checksum.
pub struct AccountResponse {
pub address: Address,
pub exists: bool,
pub nonce: u64,
pub auth: Option<AuthPolicy>,
pub vault: Option<VaultPolicy>,
pub balances: Vec<BalanceView>,
pub pending: Vec<PendingAction>,
}
pub struct BalanceView {
pub asset: AssetId, // u32; 0 is the native token
#[serde(with = "amount_serde")]
pub amount: Amount,
}
pub struct AuthPolicy {
pub threshold: u8,
pub keys: Vec<PublicKey>,
}
pub struct VaultPolicy {
pub delay_ms: u64,
pub recovery: Option<AuthPolicy>,
}
pub struct PendingAction {
pub id: u64,
pub unlock_at_ms: u64,
pub action: DelayedAction,
}
auth is null until the account's first transaction reveals the policy its address hashes. balances is sorted by asset and leaves out zero balances. Tokens that are contracts do not appear here: ask the token with a view. next_pending_id, which the state holds, is not shown.
DelayedAction has four forms in pending:
| Variant | JSON |
|---|---|
Transfer | {"transfer":{"to":"qb1...","asset":0,"amount":"1"}} |
SetAuth | {"set_auth":{"threshold":1,"keys":["01..."]}} |
SetVault | {"set_vault":null} or {"set_vault":{"delay_ms":5000,"recovery":null}} |
Call | {"call":{"contract":"qb1...","function":"transfer","args":"...","amount":"0","gas_limit":500000}} |
{
"address": "qb1fynt55uaa8mhev04jp6awkj28dpty82tsgsy8hsxvdnql4pqth2q2rvp4u",
"exists": true,
"nonce": 7,
"auth": {
"threshold": 1,
"keys": ["016824fb1b0d0dbb8f49..."]
},
"vault": null,
"balances": [
{"asset": 0, "amount": "899994998388820000000000"}
],
"pending": []
}
Blocks
GET /v1/blocks/latest
The block at the head. 404 with no blocks yet at height 0, since genesis is not a block.
GET /v1/blocks/{height}
| Parameter | In | Type | Meaning |
|---|---|---|---|
height | path | u64 | Block height |
pub struct BlockResponse {
pub hash: Hash,
pub height: u64,
pub round: u64,
pub parent: Hash,
pub proposer: u16,
pub timestamp_ms: u64,
pub tx_root: Hash,
pub state_root: Hash,
pub receipt_root: Hash,
pub voters: Vec<u16>,
pub transaction_ids: Vec<Hash>,
pub transactions: Vec<SignedTransaction>,
}
| Field | Meaning |
|---|---|
hash | The block id: see the raw form |
round | Consensus round. Above height once rounds have been skipped |
proposer | Index into the epoch's validator set |
tx_root, receipt_root | Merkle roots over the transactions and over their receipts |
state_root | State after this block |
voters | Validators the proposer last heard a vote from. Only leader choice uses it, and nothing authenticates it |
transaction_ids | Ids, in block order, computed here and not stored in the block |
{
"hash": "734292c7a7d8e57fa32da34ef0ad68fcaa248567bd3b66f702dbb8f1fa1b7460",
"height": 15,
"round": 15,
"parent": "0e4a2bf9b1b6a64cee091aa3d20564be0589f098ff760bb3575392b47b32e56b",
"proposer": 0,
"timestamp_ms": 1794009971686,
"tx_root": "51167d339d24a6d2dc432d1317544f279fe218373886f360fdae9f7040108b4b",
"state_root": "78ed0c5c41e7b12639b0c230c768e3a10c43815a41a856185d57ad401a5ece37",
"receipt_root": "239c2494335d923b036cbe632d97cefc364d367f93bcf91f095920ee9b276df3",
"voters": [0],
"transaction_ids": ["baba717b238240eea2d1b793ac76a2bf1a89d55d9ad5b58bb640a0a27d084350"],
"transactions": [
{
"tx": {
"genesis_hash": "fabaed48ca4ce5fadd5bfb84829428cdb7ae53eac4dc451a5228e8686c7ca72b",
"sender": "qb1fynt55uaa8mhev04jp6awkj28dpty82tsgsy8hsxvdnql4pqth2q2rvp4u",
"nonce": 4,
"fee": "34700000000000",
"valid_until_ms": 1794013571585,
"role": "owner",
"payload": {
"transfer": {
"to": "qb16hv42z7uvk5fa0eytdvnyclua7pljza6mp9azav8dw0maj9havxs8gnc3t",
"asset": 0,
"amount": "1000000000000000000"
}
}
},
"reveal": null,
"signatures": [
{"index": 0, "signature": "0129f438d2825d25dc8c..."}
]
}
]
}
A missing block is 404 with one of two messages:
| Message | Meaning |
|---|---|
no block at that height | The height is above the head |
history before block N is not kept on this node | The height is below blocks_from and the block was not kept as a checkpoint or epoch end. Height 0 lands here on every node, because the first block is 1 |
GET /v1/blocks/{height}/raw
The block as stored, with a finality proof when one can be had. Followers use this route: see Sync.
| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
height | path | u64 | Block height | |
prove | query | true or false | false | Build a proof when none is kept for this height. Any other value is 400 |
pub struct RawBlockResponse {
pub height: u64,
pub block: String, // hex of borsh ChainBlock
pub finality: Option<String>, // hex of borsh FinalityProof
}
finality depends on the height and on prove:
| Case | finality |
|---|---|
A proof is kept for exactly this height: multiples of 64 (PROOF_EVERY) and epoch ends, while history keeps them | Always present |
No proof kept, prove absent | null |
No proof kept, prove=true | Built from the next proof kept above, with the headers between in path |
| The height is above the last proof written | null, even with prove=true |
{
"height": 15,
"block": "0f000000000000000f000000000000000e4a2bf9...",
"finality": null
}
The same block with ?prove=true; the proof starts with the 32-byte id of the block:
{
"height": 15,
"block": "0f000000000000000f000000000000000e4a2bf9...",
"finality": "734292c7a7d8e57fa32da34ef0ad68fcaa248567..."
}
Both fields are borsh, so field order is the layout:
pub type ChainBlock = Block<BlockPayload>;
pub struct Block<P> {
pub round: u64,
pub height: u64,
pub parent: Hash,
pub proposer: u16,
pub voters: Vec<u16>,
pub payload: P,
}
pub struct BlockPayload {
pub timestamp_ms: u64,
pub transactions: Vec<SignedTransaction>,
pub tx_root: Hash,
pub state_root: Hash,
pub receipt_root: Hash,
}
pub struct Header {
pub round: u64,
pub height: u64,
pub parent: Hash,
pub proposer: u16,
pub voters: Vec<u16>,
pub payload: Hash,
}
pub struct FinalityProof {
pub tip: Hash,
pub path: Vec<Header>,
pub qc: QuorumCert,
}
pub struct QuorumCert {
pub block: Hash,
pub round: u64,
pub kind: VoteKind, // Optimistic, Normal, Fallback
pub votes: Vec<(u16, Signature)>,
}
The block id is a hash of the header, and the header holds a hash of the payload:
payload hash = BLAKE3 derive_key("qubits.sh 2026-11 consensus payload", borsh(payload))
block id = BLAKE3 derive_key("qubits.sh 2026-11 consensus block", borsh(header))
What a proof means and how to check one is in Consensus.
Validators and epochs
GET /v1/validators
The set in force at the head of this node.
pub struct ValidatorsResponse {
pub epoch: u64,
pub epoch_start: Hash,
pub epoch_start_height: u64,
pub validators: Vec<Validator>, // key and payout
pub quorum: usize,
pub governance: Address,
}
epoch_start is the genesis hash in epoch 0, and otherwise the id of the block that started the epoch. quorum is n minus f. governance is the same address on every chain. See Validators and epochs.
{
"epoch": 0,
"epoch_start": "345ea7613d7bbb3cc4ac311e7dfe18e44068fe07615212d6d5421882791cf878",
"epoch_start_height": 0,
"validators": [
{
"key": "01f3607e216def7ef59b747f1aad43019e5a5598...",
"payout": "qb1vealhtjgvexg3cwye4vfj79ks33gj2f03kau8dne6uqwy5hur6esqyse9d"
}
],
"quorum": 1,
"governance": "qb157cmgrg86jcm5t46njx2l85ly6tx3udv84mwm4gqjsl92hmqguzszhfztx"
}
GET /v1/rewards
The fee pool and what each current validator can claim. See Rewards.
{
"pool": "qb1vkjxacqwl4rl2n9ewj7a2m5lemmlg44x9mh08cur5u82gwk60stq607mhy",
"balance": "2478330000000000",
"per_validator": "2478330000000000",
"carry": "0",
"validators": [
{
"payout": "qb1vealhtjgvexg3cwye4vfj79ks33gj2f03kau8dne6uqwy5hur6esqyse9d",
"seated": true,
"claimable": "2478330000000000"
}
]
}
balance is what the pool holds, per_validator what one seat has earned since genesis, and carry what didn't divide evenly yet.
GET /v1/rewards/{address}
What one payout address can claim, whether or not it still has a seat. An address that never had one answers 0.
{
"payout": "qb1vealhtjgvexg3cwye4vfj79ks33gj2f03kau8dne6uqwy5hur6esqyse9d",
"seated": true,
"claimable": "2478330000000000"
}
GET /v1/epochs
Every epoch so far, oldest first. A joining node walks this list to prove each validator set from the one before it.
pub struct EpochSummary {
pub epoch: u64,
pub start: Hash,
pub start_height: u64,
}
pub struct EpochsResponse {
pub epochs: Vec<EpochSummary>,
}
{"epochs":[{"epoch":0,"start":"fabaed48ca4ce5fadd5bfb84829428cdb7ae53eac4dc451a5228e8686c7ca72b","start_height":0}]}
Transactions
POST /v1/transactions
pub struct SubmitRequest {
pub transaction: String, // hex of borsh SignedTransaction
}
pub struct SubmitResponse {
pub id: Hash,
}
The body is hex, not JSON of the transaction, so what is submitted is what was signed. Surrounding whitespace is ignored. The layout is in Transactions.
{"transaction":"..."}
{"id":"3cadd79f9fd2c764651a2d9472daec9c022af439fa51c8aaed041ece436ef639"}
202 means the transaction passed the checks below and sits in this node's mempool. It says nothing about a block. The node takes these steps:
- Decode the hex. Failure:
400,transaction is not hex. - Decode the borsh, after a size check. Failure:
400,transaction is N bytes, larger than the 294912 byte limitordecoding failed: .... - Wait for a submission permit.
- Under the mempool lock, refuse an id already waiting (
409), a full mempool or a sender with 64 waiting (503,mempool is full, try again later), and copy the sender's waiting transactions. - Outside the lock, refuse an id already in a block (
409,transaction already known). - Build the environment of the next block: height
head + 1, timestamp the later of the clock andhead timestamp + 1. - Replay the sender's waiting transactions, ignoring their failures, then apply this one with
without_contracts(). Every rule is checked and no contract code runs, so submitting can't run code for free. A contract that would fail is not found out here. - Take the lock, repeat the checks of step 4, and insert.
- Gossip to validators. On an API node started with
--follow, queue the transaction for forwarding too.
Steps 5 to 7 run outside the lock because consensus needs the mempool and signatures are slow to check.
Forwarding from an API node is best effort. The queue holds 1,024 transactions, each HTTP attempt has 5 s, and a failure is only logged. A
202from an API node means that node accepted it, not that a validator did.
Refusals from step 7 are 400 with these messages, in the order the rules run:
| Message | Cause |
|---|---|
transaction is larger than 294912 bytes | Over MAX_TX_BYTES, or code over 163,840 bytes, or args over 16,384, or a function name over 64 |
wrong chain, expected genesis hash H | genesis_hash is another chain's |
transaction expired at T ms | The block time would pass valid_until_ms |
gas limit is above 5000000 | Over MAX_TX_GAS |
fee too low, at least N required | Below the minimum for its size and gas |
fee too high, at most N allowed | Over 8 times the minimum |
sender account does not exist | No such account |
bad nonce, expected N | Not the next nonce, counting transactions already waiting |
insufficient balance of asset A: need N, have M | Can't pay the fee, or the amount |
auth policy already revealed | reveal is set on an account that has a policy |
first transaction from this address must reveal its auth policy | reveal is missing |
revealed auth policy does not match the address | The policy hashes to another address |
account has no recovery policy | role is recovery on an account without one |
unauthorized: ... | The signatures do not satisfy the policy |
only the governance account can change validators or deploy contracts | SetValidators or Deploy from another account |
nothing to claim for that address | ClaimRewards for an address with nothing to claim |
the governance account can only change the validator set and deploy contracts | Anything else from governance |
recovery may only cancel pending actions or replace the owner policy | Any other payload with role recovery |
invalid auth policy: ..., invalid vault policy: ... | A policy in SetAuth, SetVault or SetValidators breaks the rules of Accounts |
amount must be positive | A transfer of zero |
no contract at that address | A call to an address with no contract |
init runs once, at deployment | A call to init |
no pending action with that id | CancelPending or ExecutePending with an unknown id |
pending action is locked until T ms | ExecutePending before unlock_at_ms |
vault already has 16 pending actions | The queue is full |
The full rules are in Transactions and Vaults. A storage failure during these checks is 500.
GET /v1/transactions/{id}
| Parameter | In | Type | Meaning |
|---|---|---|---|
id | path | 64 hex characters | Transaction id. Anything else is 400, invalid hash: expected 64 hex characters |
#[serde(rename_all = "snake_case")]
pub enum TxStatus {
Pending,
Included,
}
pub struct TransactionResponse {
pub id: Hash,
pub status: TxStatus,
pub block_height: Option<u64>,
pub index: Option<u32>,
pub transaction: SignedTransaction,
pub receipt: Option<Receipt>,
}
status | Means | block_height, index | receipt |
|---|---|---|---|
pending | In this node's mempool | null | null |
included | In a final block, at position index | Set | Present if the transaction ran contract code |
404 with unknown transaction covers an id never seen, a transaction that left the mempool (10 minutes by default, or past valid_until_ms), and one whose block expired from history.
includeddoes not mean a call worked. A contract that fails is still included and pays. Readreceipt.outcome.
A plain transfer has receipt: null. transaction holds the same SignedTransaction JSON a block does, and is left out here:
{
"id": "baba717b238240eea2d1b793ac76a2bf1a89d55d9ad5b58bb640a0a27d084350",
"status": "included",
"block_height": 15,
"index": 0,
"receipt": null
}
While it waits, a transaction reads "status": "pending", with block_height, index and receipt all null.
A contract call, here a swap:
{
"status": "included",
"block_height": 19,
"index": 0,
"receipt": {
"outcome": "done",
"gas_used": 519033,
"output": "986c1e00000000000000000000000000",
"events": [
{"contract": "qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8", "topic": "7472616e73666572", "data": "8dbda1aa7186d3b7b537..."},
{"contract": "qb13k76r2n3smfm0dfhljzue68mlnl9ywfypstlnwhptjze4h5sxdlsdr820r", "topic": "73776170", "data": "01d64602a5d00921c3d0..."}
],
"contract": null
}
}
topic is hex of the name the contract gave: 7472616e73666572 is transfer, 73776170 is swap.
JSON form of a transaction
pub struct SignedTransaction {
pub tx: Transaction,
pub reveal: Option<AuthPolicy>,
pub signatures: Vec<KeySignature>,
}
pub struct KeySignature {
pub index: u8,
pub signature: Signature,
}
pub struct Transaction {
pub genesis_hash: Hash,
pub sender: Address,
pub nonce: u64,
pub fee: Amount, // decimal string
pub valid_until_ms: u64,
pub role: Role, // "owner" or "recovery"
pub payload: Payload,
}
Payload variant | JSON |
|---|---|
Transfer | {"transfer":{"to":"qb1...","asset":0,"amount":"1"}} |
SetAuth | {"set_auth":{"auth":{"threshold":1,"keys":["01..."]}}} |
SetVault | {"set_vault":{"vault":null}} or with a VaultPolicy |
CancelPending | {"cancel_pending":{"id":0}} |
ExecutePending | {"execute_pending":{"account":"qb1...","id":0}} |
SetValidators | {"set_validators":{"validators":[{"key":"01...","payout":"qb1..."}]}} |
Deploy | {"deploy":{"code":"0061736d...","args":"","gas_limit":5000000}} |
Call | {"call":{"contract":"qb1...","function":"swap","args":"...","amount":"0","gas_limit":668792}} |
ClaimRewards | {"claim_rewards":{"payout":"qb1..."}} |
Receipts
pub struct Receipt {
pub outcome: Outcome,
pub gas_used: u64,
#[serde(with = "hex_serde")]
pub output: Vec<u8>,
pub events: Vec<Event>,
pub contract: Option<Address>, // the address a deployment created
}
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Done,
Reverted {
#[serde(with = "hex_serde")]
reason: Vec<u8>,
},
OutOfGas,
Trapped,
Invalid, // no such function, or not a usable contract
}
pub struct Event {
pub contract: Address,
#[serde(with = "hex_serde")]
pub topic: Vec<u8>,
#[serde(with = "hex_serde")]
pub data: Vec<u8>,
}
JSON outcome | Meaning |
|---|---|
"done" | The call finished; its changes stand |
{"reverted":{"reason":"62616c616e636520746f6f206c6f77"}} | The contract reverted. reason is hex of its text, here balance too low |
"out_of_gas" | The gas limit ran out |
"trapped" | The contract crashed |
"invalid" | No such function, or the code is not a usable contract |
Anything but done undid the contract's work, and the fee stays paid. A node keeps a receipt only when it differs from the empty default, so a plain transfer has none and neither does a vault's call when it enters the queue; the receipt comes with the ExecutePending that runs it. Receipts go when their block expires from history.
Contracts
GET /v1/contracts
Every deployed contract, oldest first. The list is state, so a node that joined from a snapshot has it too.
pub struct ContractsResponse {
pub contracts: Vec<Address>,
}
{"contracts":["qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8","qb13k76r2n3smfm0dfhljzue68mlnl9ywfypstlnwhptjze4h5sxdlsdr820r","qb1xvyknc2e6z0t3jr5p65ve0w4jlljlqhp7n6c3tcdy696chwz8teqj2jhrq"]}
GET /v1/contracts/{address}
404 with no contract at that address if there is none.
pub struct ContractResponse {
pub address: Address,
pub code: Hash, // hash of the WebAssembly module
pub code_size: usize, // its size in bytes
#[serde(with = "amount_serde")]
pub balance: Amount, // native balance, a decimal string
}
{"address":"qb13k76r2n3smfm0dfhljzue68mlnl9ywfypstlnwhptjze4h5sxdlsdr820r","code":"12d180af9b431aaeefbf817ddfee5d52b975c8b6d446d416a4847d20ec310185","code_size":14282,"balance":"100004000000000000000000"}
The route gives the hash and size of the code, not the code.
POST /v1/contracts/{address}/view
Runs a contract function against the latest state, for free, and keeps nothing. With caller and amount it shows what that account's call would do, which is how the wallet checks a site's request. See window.qubits.
pub struct ViewRequest {
pub function: String,
#[serde(with = "hex_serde")]
pub args: Vec<u8>,
#[serde(default)]
pub caller: Option<Address>,
#[serde(default, with = "amount_serde")]
pub amount: Amount,
}
pub struct ViewResponse {
#[serde(flatten)]
pub receipt: Receipt,
#[serde(with = "amount_serde")]
pub balance_after: Amount,
}
| Field | Required | Meaning |
|---|---|---|
function | Yes | Exported function. At most 64 bytes, and not init |
args | Yes | Hex of the arguments, "" for none. At most 16,384 bytes |
caller | No | Run as this account. Default: the zero address, which is nobody |
amount | No | Native tokens going along from the caller, as a decimal string. Default "0" |
The run gets 5,000,000 gas, in the environment of the next block: height head + 1, timestamp the later of the clock and head timestamp + 1. The reply is a Receipt flattened with one more field, balance_after: the caller's native balance after the call, before any fee. It is "0" for an account that doesn't exist.
No signature is asked for. State is public, so acting as anyone reveals nothing.
A failing function is a 200. Read outcome:
| Case | Status | Body |
|---|---|---|
| The function ran, whatever came of it | 200 | ViewResponse |
caller lacks amount | 200 | outcome reverted with insufficient balance, gas_used 0 |
amount set with no caller | 200 | The same: the zero address holds nothing |
| Function or args too long | 400 | the call is too large |
function is init | 400 | init runs once, at deployment |
| No contract at the address | 404 | no contract at that address |
This request asks the AMM what 1 native token buys. The arguments are the native token's 32 zero bytes, the 32 bytes of the qUSD address, and the amount as a little-endian u128:
curl -s -X POST http://127.0.0.1:8545/v1/contracts/qb13k76r2n3smfm0dfhljzue68mlnl9ywfypstlnwhptjze4h5sxdlsdr820r/view \
-H 'content-type: application/json' \
-d '{"function":"quote","args":"00000000000000000000000000000000000000000000000000000000000000007454bfc5d18af1838667454ce0e5b809183bb0507d5f8702052a89e0dd534f1f000064a7b3b6e00d0000000000000000"}'
{"outcome":"done","gas_used":256560,"output":"2e360f00000000000000000000000000","events":[],"contract":null,"balance_after":"0"}
output is a little-endian u128: 996,910 base units of qUSD, which has 6 decimals.
The same swap simulated as a real account, with the native token sent along:
{"function":"swap","args":"0000...","caller":"qb1fynt55uaa8mhev04jp6awkj28dpty82tsgsy8hsxvdnql4pqth2q2rvp4u","amount":"1000000000000000000"}
{
"outcome": "done",
"gas_used": 519033,
"output": "2e360f00000000000000000000000000",
"events": [
{"contract": "qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8", "topic": "7472616e73666572", "data": "8dbda1aa7186d3b7b537..."},
{"contract": "qb13k76r2n3smfm0dfhljzue68mlnl9ywfypstlnwhptjze4h5sxdlsdr820r", "topic": "73776170", "data": "01d64602a5d00921c3d0..."}
],
"contract": null,
"balance_after": "899993998388820000000000"
}
Without amount, the same call reverts inside the contract:
{"outcome":{"reverted":{"reason":"73656e6420746865206e617469766520616d6f756e742077697468207468652063616c6c"}},"gas_used":233658,"output":"","events":[],"contract":null,"balance_after":"899994998388820000000000"}
The reason reads send the native amount with the call.
Proofs
GET /v1/proofs/accounts/{address}
A Merkle proof of an account against a state root, so a client can check a balance without trusting the node.
| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
address | path | qb1... | The account | |
height | query | u64 | The head | State to prove against. 0 is genesis |
pub struct AccountProofResponse {
pub height: u64,
pub block_hash: Hash, // the genesis hash at height 0
pub state_root: Hash,
pub account: Option<String>, // hex of borsh Account; null if the account doesn't exist
pub proof: String, // hex of borsh SparseMerkleProof<Blake3Hasher>
}
{
"height": 15,
"block_hash": "734292c7a7d8e57fa32da34ef0ad68fcaa248567bd3b66f702dbb8f1fa1b7460",
"state_root": "78ed0c5c41e7b12639b0c230c768e3a10c43815a41a856185d57ad401a5ece37",
"account": "05000000000000000100...",
"proof": "01c038502ab551d09a39..."
}
| Status | Message | Cause |
|---|---|---|
| 400 | invalid address: ... | Bad address |
| 404 | that height is not committed yet | Above the head |
| 404 | no block at that height | The block expired from history |
| 404 | state at that height was pruned | The state is below state_from |
account is the state's own value, which has one field the JSON form of an account hides:
pub struct Account {
pub nonce: u64,
pub balances: BTreeMap<AssetId, Amount>,
pub auth: Option<AuthPolicy>,
pub vault: Option<VaultPolicy>,
pub pending: Vec<PendingAction>,
pub next_pending_id: u64,
}
To check a reply without trusting the node, as qubits-node/tests/sync.rs does:
- Fetch
/v1/proofs/accounts/{address}, then/v1/blocks/{height}/raw?prove=truefor the height it names. - Decode
blockandfinality. - Check that the block id equals
block_hash, and thatblock.payload.state_rootequalsstate_root. - Check
finality.verify(block.round, &validators, &chain), with the validators of that block's epoch andchainthe epoch's start. The set comes from genesis and/v1/epochs. - Verify
proofagainststate_rootwithStore::verify_proof(root, key, value, proof).keyis the ASCIIaccount/followed by the 32 address bytes, andvalueis the decodedaccountbytes, or none to prove absence.
The tree hasher is BLAKE3 in derive-key mode with the context qubits.sh 2026-11 state tree. See State.
State sync
These routes serve a node joining from a snapshot. The steps it takes are in Sync.
GET /v1/state/snapshot
The newest state this node can hand over, with the block that certifies its root. No parameters.
pub struct SnapshotResponse {
pub height: u64,
pub state_root: Hash,
pub block: String, // hex of borsh ChainBlock
pub finality: String, // hex of borsh FinalityProof
}
The candidate is the higher of the newest multiple of 64 and the start of the current epoch. The node answers 503 with no snapshot to serve when the candidate is 0, when its state is below state_from, or when its block or proof is gone. So a chain under 64 blocks has nothing to offer, like the demo chain at height 19, and neither does a node pruned to fewer than 64 versions.
From another local chain at height 69:
{
"height": 64,
"state_root": "1b38d732246e1af1d2a983406e22462b758746ae4c1d8902d006fabb5c10f85d",
"block": "41000000000000004000...",
"finality": "26b5a8d1f2c0addf78c6..."
}
GET /v1/state/ranges
Leaves of the state at a height, in key-hash order, from a starting hash on. The reply is application/octet-stream, borsh, not JSON. Errors are JSON.
| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
height | query | u64 | Required | State version. Missing is 400 |
from | query | 64 hex characters | 32 zero bytes | First key hash to look at. Anything else is 400, from must be 32 bytes of hex |
bytes | query | usize | 2,097,152 | Size to stop at, capped at 4,194,304 |
pub struct StateChunk {
pub leaves: Vec<([u8; 32], Vec<u8>)>, // key hash, value
pub proof: Option<StateRangeProof>, // SparseMerkleRangeProof<Blake3Hasher>
pub done: bool,
}
bytes is a soft limit. Each leaf counts 32 bytes plus its value, and the leaf that crosses the limit is still sent, so a chunk runs slightly over. With a bytes of 1 the reply has one leaf; with 0 it has none. The next from is the last key hash plus one, as StateChunk::next computes it, until done is true. proof shows nothing is missing up to the last leaf, and is null only when a chunk has no leaves.
| Status | Message | Cause |
|---|---|---|
| 404 | no state at version N yet | height is above the head |
| 404 | state at version N was pruned (kept from M) | height is below state_from |
On a local test chain at height 19, the whole state came to 33,643 bytes in one chunk.
Eras
An era file is a block range that anyone can check against the genesis file alone. See Sync for how they are verified and imported.
GET /v1/eras
pub struct ErasResponse {
pub length: u64, // blocks per era
pub eras: Vec<u64>, // complete eras this node can hand out
}
{"length":8192,"eras":[]}
Era index holds heights index * length + 1 to (index + 1) * length. An era is listed when it is complete, every block in it is held, and the proof at its last block is kept. The current era is never listed.
GET /v1/eras/{index}
Streams one era as it reads it, so a file of any size costs the node little memory.
| Parameter | In | Type | Default | Meaning |
|---|---|---|---|---|
index | path | u64 | Era number | |
to | query | u64 | The era's last block | End the file at this height: the part of an era that a node started from a snapshot needs |
Headers on success, from a local chain run with a short era length:
HTTP/1.1 200 OK
content-type: application/octet-stream
content-disposition: attachment; filename="qubits-local-33328caf-00000.era"
transfer-encoding: chunked
| Case | File name |
|---|---|
| Whole era | {chain_id}-{first 8 hex of genesis hash}-{index:05}.era |
With to | {chain_id}-{first 8 hex of genesis hash}-{index:05}-to-{to}.era |
| Status | Message | Cause |
|---|---|---|
| 404 | that era is not complete or not kept on this node | No to, and the era isn't in /v1/eras |
| 404 | that part of the era is not here, or its last block has no kept proof | With to: the height is outside the era or above the head, the era's first block is below blocks_from, or no proof is kept at to |
So to works only at a height with a kept proof, which in practice means a multiple of 64 or an epoch end.
The file is borsh, written in this order:
Header { magic "QERA", version 1, genesis_hash, first_height: u64, count: u32 }
Vec<EraEpoch> the epoch-ending blocks before the era
count times:
ChainBlock
Option<FinalityProof>
pub struct Header {
pub magic: [u8; 4],
pub version: u8,
pub genesis_hash: Hash,
pub first_height: u64,
pub count: u32,
}
pub struct EraEpoch {
pub epoch: u64,
pub block: ChainBlock,
pub proof: FinalityProof,
}
A 200 is sent before the first block is read. If reading fails midway the node logs could not stream an era and the body stops short, so check a downloaded file with qubits-node era verify before relying on it.
WebSocket
GET /v1/ws
Opens a WebSocket. The node pushes each block as it is stored and reports a transaction once it is in a block, so a client doesn't have to keep asking. It carries the same committed data as the routes above, and transactions are still submitted with POST /v1/transactions.
The node speaks plain ws://. On a public node, wss:// comes from the reverse proxy in front of it.
| Status | When |
|---|---|
101 | The connection is open |
400 | The request has no WebSocket upgrade headers |
503 | --ws-limit connections are already open |
Every message is one JSON text frame. Unlike the enums under Conventions, these name their kind in a field: op in what a client sends, type in what the node sends.
| Client sends | Effect |
|---|---|
{"op":"subscribe_blocks"} | Every block stored from now on |
{"op":"unsubscribe_blocks"} | No more blocks |
{"op":"watch_transaction","id":"<hash>"} | One report when the transaction is in a block, at once if it already is |
{"op":"unwatch_transaction","id":"<hash>"} | Stops waiting for it |
const socket = new WebSocket("ws://127.0.0.1:8545/v1/ws");
socket.onopen = () => {
socket.send(JSON.stringify({ op: "subscribe_blocks" }));
socket.send(JSON.stringify({ op: "watch_transaction", id }));
};
socket.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.type === "block") console.log(message.height, message.hash);
if (message.type === "transaction") console.log(message.block_height, message.receipt);
};
type | Body |
|---|---|
block | The fields of BlockResponse without transactions, which can come to megabytes. GET /v1/blocks/{height} has them |
transaction | A TransactionResponse, always included, with its receipt if it has one |
error | error, a message. The connection stays open |
{
"type": "block",
"hash": "024c79231a0eb0188c08853473629301f36ede9952c76693e93c867cec250c77",
"height": 1,
"round": 1,
"parent": "bbf93cd49b22cb82ad2e798299530d19cb4f36265179f7a1cc6ad2baa354df75",
"proposer": 0,
"timestamp_ms": 1794026648236,
"tx_root": "55c7432f15af00897b2817cc7186adf84aeec745811f842d7f5dfcf0d94d1fec",
"state_root": "9f95759d5970828cb6b81b1918981d09f23b612942ad84696a0520679499b1b8",
"receipt_root": "239c2494335d923b036cbe632d97cefc364d367f93bcf91f095920ee9b276df3",
"voters": [],
"transaction_ids": [
"df159bd39a2d5f51b49856cebd21eece0554261bb11d42dd57f90762dc407648"
]
}
- While a connection stays open, blocks arrive in height order with no gaps.
- A block's
blockmessage comes before thetransactionmessages it causes. - A watch lasts until the transaction is in a block or the connection ends. Nothing is sent for one that never lands, so stop waiting at its
valid_until_ms. - The 30 s request timeout ends when the upgrade is answered, so it doesn't limit a connection.
- There is no origin check, as with CORS: the data is public.
The node sends a ping every 30 s. Browsers and client libraries answer on their own, and any message from the client counts as an answer. A connection ends in these cases:
| Case | What the client sees |
|---|---|
| It fell more than 64 blocks behind | Close code 1013 |
| Nothing came back for a whole ping interval | The connection drops |
| One message took more than 10 s to send | The connection drops |
| It sent more than 1,024 bytes in one message | The connection drops |
To recover, connect again, subscribe again, and read what was missed with GET /v1/blocks/{height}. Watch the transactions again too: those already in a block are reported at once. A node catching up on the chain can move faster than its clients read, so expect 1013 while it does.
Source
chain/crates/qubits-node/src/rpc.rs: the router, handlers and limitschain/crates/qubits-node/src/lib.rs:Node::accept,simulate,finality_proof,snapshot_height,eras,servechain/crates/qubits-node/src/ws.rs:/v1/wschain/crates/qubits-node/src/mempool.rs: mempool limitschain/crates/qubits-node/src/metrics.rs:/metricschain/crates/qubits-node/src/era.rs: era file layout and nameschain/crates/qubits-types/src/rpc.rs: request and response typeschain/crates/qubits-types/src/account.rs,transaction.rs,contract.rs,block.rs: the types inside themchain/crates/qubits-types/src/lib.rs:limitschain/crates/qubits-execution/src/lib.rs:TxError, the transaction rules,simulatechain/crates/qubits-state/src/store.rs:StateChunk,state_chunk,get_with_proof,verify_proofchain/crates/qubits-consensus/src/types.rs:Block,Header,QuorumCert,FinalityProofchain/crates/qubits-node/tests/sync.rs: proofs checked end to endchain/crates/qubits-node/tests/node.rs: the WebSocket, against a validator