All pages

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 typeJSON formExample
Amount (u128)decimal string in base units, because values pass 2^53"1000000000000000000"
u64, u32, u16, usizeJSON number1794010632260
Hash64 hex characters"fabaed48...7ca72b"
Addressbech32m, prefix qb, 32 bytes"qb1fynt55...rvp4u"
PublicKey, Signaturehex of one scheme byte (01 ML-DSA-65, 02 SLH-DSA-SHA2-192s) and the raw bytes"0129f438..."
Byte stringshex, empty string when empty"7472616e73666572"
Optionthe value or nullnull
Enumssnake_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"}
StatusWhenBody
200SuccessThe route's JSON or bytes
202A transaction entered the mempoolSubmitResponse
400Bad address, hash, hex or transaction, or a transaction the rules refuseErrorResponse, or framework text
404Nothing at that height, id or address. Also an unknown pathErrorResponse, empty for an unknown path
405Wrong method for the pathEmpty
408The handler took over 30 sEmpty
409The transaction is already in the mempool or a blockErrorResponse
413The body is over the limitFramework text
415A POST without Content-Type: application/jsonFramework text
422The JSON is valid but has the wrong shapeFramework text
500A storage failure, logged as request failed; the message starts with storage:ErrorResponse
503The mempool is full, or there is no snapshot to serveErrorResponse

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

LimitValueConstant
Time until the response starts30 sREQUEST_TIMEOUT
Request body590,848 bytes2 * MAX_TX_BYTES + 1024 (hex doubles the size)
Submissions and views running at once32MAX_CONCURRENT_SUBMITS
One encoded transaction294,912 bytesMAX_TX_BYTES
View arguments16,384 bytesMAX_ARGS_BYTES
View function name64 bytesMAX_FUNCTION_NAME
Gas given to a view5,000,000MAX_TX_GAS
Transactions in the mempool10,000MAX_MEMPOOL_TRANSACTIONS
Transactions per sender in the mempool64MAX_PER_SENDER
WebSocket connections open at once1,024--ws-limit, default MAX_CONNECTIONS
Transactions watched per WebSocket connection64MAX_WATCHED
Blocks a WebSocket connection may fall behind64MAX_BEHIND
One message from a WebSocket client1,024 bytesMAX_REQUEST_BYTES
State chunk, default2,097,152 bytesDEFAULT_CHUNK_BYTES
State chunk, ceiling4,194,304 bytes, softMAX_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.

serve hands the bare listener to axum::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

MethodPathAnswers with
GET/healthText: ok and the height
GET/metricsPrometheus text
GET/v1/statusStatusResponse
GET/v1/accounts/{address}AccountResponse
GET/v1/blocks/latestBlockResponse
GET/v1/blocks/{height}BlockResponse
GET/v1/blocks/{height}/rawRawBlockResponse
GET/v1/validatorsValidatorsResponse
GET/v1/rewardsRewardsResponse
GET/v1/rewards/{address}RewardResponse
GET/v1/epochsEpochsResponse
POST/v1/transactionsSubmitResponse
GET/v1/transactions/{id}TransactionResponse
GET/v1/contractsContractsResponse
GET/v1/contracts/{address}ContractResponse
POST/v1/contracts/{address}/viewViewResponse
GET/v1/proofs/accounts/{address}AccountProofResponse
GET/v1/state/snapshotSnapshotResponse
GET/v1/state/rangesBorsh StateChunk
GET/v1/erasErasResponse
GET/v1/eras/{index}Era file, streamed
GET/v1/wsA 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,
}
FieldMeaning
chain_idName from the genesis file
genesis_hashThe chain's identity. Every transaction signs it
heightNewest committed block; 0 at genesis
latest_hashId of that block, or the genesis hash at height 0
latest_timestamp_msIts block time, or the genesis timestamp at height 0
state_rootState root at height
min_fee_per_byteFrom genesis, in base units
mempool_sizeTransactions waiting on this node
state_fromState below this height is gone: pruned, or never here after a snapshot
blocks_fromOldest 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}

ParameterInTypeMeaning
addresspathqb1...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:

VariantJSON
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}

ParameterInTypeMeaning
heightpathu64Block 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>,
}
FieldMeaning
hashThe block id: see the raw form
roundConsensus round. Above height once rounds have been skipped
proposerIndex into the epoch's validator set
tx_root, receipt_rootMerkle roots over the transactions and over their receipts
state_rootState after this block
votersValidators the proposer last heard a vote from. Only leader choice uses it, and nothing authenticates it
transaction_idsIds, 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:

MessageMeaning
no block at that heightThe height is above the head
history before block N is not kept on this nodeThe 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.

ParameterInTypeDefaultMeaning
heightpathu64Block height
provequerytrue or falsefalseBuild 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:

Casefinality
A proof is kept for exactly this height: multiples of 64 (PROOF_EVERY) and epoch ends, while history keeps themAlways present
No proof kept, prove absentnull
No proof kept, prove=trueBuilt from the next proof kept above, with the headers between in path
The height is above the last proof writtennull, 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:

  1. Decode the hex. Failure: 400, transaction is not hex.
  2. Decode the borsh, after a size check. Failure: 400, transaction is N bytes, larger than the 294912 byte limit or decoding failed: ....
  3. Wait for a submission permit.
  4. 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.
  5. Outside the lock, refuse an id already in a block (409, transaction already known).
  6. Build the environment of the next block: height head + 1, timestamp the later of the clock and head timestamp + 1.
  7. 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.
  8. Take the lock, repeat the checks of step 4, and insert.
  9. 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 202 from 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:

MessageCause
transaction is larger than 294912 bytesOver 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 Hgenesis_hash is another chain's
transaction expired at T msThe block time would pass valid_until_ms
gas limit is above 5000000Over MAX_TX_GAS
fee too low, at least N requiredBelow the minimum for its size and gas
fee too high, at most N allowedOver 8 times the minimum
sender account does not existNo such account
bad nonce, expected NNot the next nonce, counting transactions already waiting
insufficient balance of asset A: need N, have MCan't pay the fee, or the amount
auth policy already revealedreveal is set on an account that has a policy
first transaction from this address must reveal its auth policyreveal is missing
revealed auth policy does not match the addressThe policy hashes to another address
account has no recovery policyrole is recovery on an account without one
unauthorized: ...The signatures do not satisfy the policy
only the governance account can change validators or deploy contractsSetValidators or Deploy from another account
nothing to claim for that addressClaimRewards for an address with nothing to claim
the governance account can only change the validator set and deploy contractsAnything else from governance
recovery may only cancel pending actions or replace the owner policyAny 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 positiveA transfer of zero
no contract at that addressA call to an address with no contract
init runs once, at deploymentA call to init
no pending action with that idCancelPending or ExecutePending with an unknown id
pending action is locked until T msExecutePending before unlock_at_ms
vault already has 16 pending actionsThe queue is full

The full rules are in Transactions and Vaults. A storage failure during these checks is 500.

GET /v1/transactions/{id}

ParameterInTypeMeaning
idpath64 hex charactersTransaction 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>,
}
statusMeansblock_height, indexreceipt
pendingIn this node's mempoolnullnull
includedIn a final block, at position indexSetPresent 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.

included does not mean a call worked. A contract that fails is still included and pays. Read receipt.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 variantJSON
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 outcomeMeaning
"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,
}
FieldRequiredMeaning
functionYesExported function. At most 64 bytes, and not init
argsYesHex of the arguments, "" for none. At most 16,384 bytes
callerNoRun as this account. Default: the zero address, which is nobody
amountNoNative 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:

CaseStatusBody
The function ran, whatever came of it200ViewResponse
caller lacks amount200outcome reverted with insufficient balance, gas_used 0
amount set with no caller200The same: the zero address holds nothing
Function or args too long400the call is too large
function is init400init runs once, at deployment
No contract at the address404no 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.

ParameterInTypeDefaultMeaning
addresspathqb1...The account
heightqueryu64The headState 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..."
}
StatusMessageCause
400invalid address: ...Bad address
404that height is not committed yetAbove the head
404no block at that heightThe block expired from history
404state at that height was prunedThe 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:

  1. Fetch /v1/proofs/accounts/{address}, then /v1/blocks/{height}/raw?prove=true for the height it names.
  2. Decode block and finality.
  3. Check that the block id equals block_hash, and that block.payload.state_root equals state_root.
  4. Check finality.verify(block.round, &validators, &chain), with the validators of that block's epoch and chain the epoch's start. The set comes from genesis and /v1/epochs.
  5. Verify proof against state_root with Store::verify_proof(root, key, value, proof). key is the ASCII account/ followed by the 32 address bytes, and value is the decoded account bytes, 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.

ParameterInTypeDefaultMeaning
heightqueryu64RequiredState version. Missing is 400
fromquery64 hex characters32 zero bytesFirst key hash to look at. Anything else is 400, from must be 32 bytes of hex
bytesqueryusize2,097,152Size 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.

StatusMessageCause
404no state at version N yetheight is above the head
404state 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.

ParameterInTypeDefaultMeaning
indexpathu64Era number
toqueryu64The era's last blockEnd 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
CaseFile 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
StatusMessageCause
404that era is not complete or not kept on this nodeNo to, and the era isn't in /v1/eras
404that part of the era is not here, or its last block has no kept proofWith 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.

StatusWhen
101The connection is open
400The 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 sendsEffect
{"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);
};
typeBody
blockThe fields of BlockResponse without transactions, which can come to megabytes. GET /v1/blocks/{height} has them
transactionA TransactionResponse, always included, with its receipt if it has one
errorerror, 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 block message comes before the transaction messages 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:

CaseWhat the client sees
It fell more than 64 blocks behindClose code 1013
Nothing came back for a whole ping intervalThe connection drops
One message took more than 10 s to sendThe connection drops
It sent more than 1,024 bytes in one messageThe 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 limits
  • chain/crates/qubits-node/src/lib.rs: Node::accept, simulate, finality_proof, snapshot_height, eras, serve
  • chain/crates/qubits-node/src/ws.rs: /v1/ws
  • chain/crates/qubits-node/src/mempool.rs: mempool limits
  • chain/crates/qubits-node/src/metrics.rs: /metrics
  • chain/crates/qubits-node/src/era.rs: era file layout and names
  • chain/crates/qubits-types/src/rpc.rs: request and response types
  • chain/crates/qubits-types/src/account.rs, transaction.rs, contract.rs, block.rs: the types inside them
  • chain/crates/qubits-types/src/lib.rs: limits
  • chain/crates/qubits-execution/src/lib.rs: TxError, the transaction rules, simulate
  • chain/crates/qubits-state/src/store.rs: StateChunk, state_chunk, get_with_proof, verify_proof
  • chain/crates/qubits-consensus/src/types.rs: Block, Header, QuorumCert, FinalityProof
  • chain/crates/qubits-node/tests/sync.rs: proofs checked end to end
  • chain/crates/qubits-node/tests/node.rs: the WebSocket, against a validator