Transactions and fees
This page specifies the transaction format byte by byte, what is signed, how replay is prevented, how fees are computed, charged and refunded, the order in which a transaction is checked, every error it can produce, and every protocol limit. It is for engineers writing clients and auditors checking execution. The rules for delayed actions are in Vaults and recovery, and those for running code are in Contract model.
Encoding
Transactions use borsh. Integers are little-endian, an Option is one byte (0 or 1) then the value, a Vec or String is a u32 length then its items, and an enum is a one-byte tag then its fields in order. Field order is therefore part of the on-chain format. Keys and signatures are Vec<u8> of their tagged form, see Cryptography.
JSON uses the same structure with these differences: Hash is hex, Address is qb1 text, amounts and the fee are decimal strings, and byte fields are hex. An enum variant with fields is an object with one snake-case key, and one without fields is a snake-case string.
Transaction
pub struct Transaction {
pub genesis_hash: Hash,
pub sender: Address,
pub nonce: u64,
#[serde(with = "amount_serde")]
pub fee: Amount,
pub valid_until_ms: u64,
pub role: Role,
pub payload: Payload,
}
pub enum Role {
Owner,
Recovery,
}
| Offset | Size | Field | Type | Meaning |
|---|---|---|---|---|
| 0 | 32 | genesis_hash | Hash | The chain this transaction belongs to |
| 32 | 32 | sender | Address | The account that acts and pays |
| 64 | 8 | nonce | u64 | Must equal the account's nonce |
| 72 | 16 | fee | u128 | Base units burned from the sender |
| 88 | 8 | valid_until_ms | u64 | The last block time it may be included in |
| 96 | 1 | role | Role | 0 for Owner, 1 for Recovery |
| 97 | n | payload | Payload | Tag, then the variant's fields |
A complete 150-byte transfer, as encoded:
0..32 genesis_hash aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
32..64 sender 69b5f32ed0cb56494813a57cc14399d07bf6ea26acecf47bd21428488c474fba
64..72 nonce 7 0700000000000000
72..88 fee 00c06e31d91001000000000000000000
88..96 valid_until_ms 00505c18a3010000
96 role Owner 00
97 tag Transfer 00
98..130 to aaad775dbfdb48cc3bf1e4740c60bff70e03a92b1676f6b289ed8d2696a459ea
130..134 asset 0 00000000
134..150 amount 000064a7b3b6e00d0000000000000000
id a85c3fd1133bc2b51da43dc5c7737ebec7e7624c7bb031d4efd2da79c099194c
The fee is 300000000000000, valid_until_ms is 1800000000000 and the amount is 10^18.
SignedTransaction
pub struct SignedTransaction {
pub tx: Transaction,
pub reveal: Option<AuthPolicy>,
pub signatures: Vec<KeySignature>,
}
pub struct KeySignature {
pub index: u8,
pub signature: Signature,
}
The layout follows the fields:
tx borsh(Transaction)
reveal 0x00, or 0x01 followed by borsh(AuthPolicy)
signatures u32 count, then for each one:
index u8
signature u32 length, scheme byte, raw signature
The first transaction of the 1-of-1 ML-DSA-65 account above is 5432 bytes and has reveal from offset 150. A later one is 3470 bytes:
first: 0..150 tx | 150 01 | 151..2113 policy | 2113 01000000 | 2117 00 | 2118 ee0c0000 01 raw...
later: 0..150 tx | 150 00 | 151 01000000 | 155 00 | 156 ee0c0000 01 raw...
ee0c0000 is 3310, the tagged signature length. reveal is covered in Accounts and addresses, and signatures is checked as Accounts and addresses describes.
SignedTransaction::encode is the wire form. decode refuses input longer than MAX_TX_BYTES before parsing. Submission sends it hex-encoded: POST /v1/transactions with {"transaction": "<hex>"}.
Payload
pub enum Payload {
Transfer { to: Address, asset: AssetId, amount: Amount },
SetAuth { auth: AuthPolicy },
SetVault { vault: Option<VaultPolicy> },
CancelPending { id: u64 },
ExecutePending { account: Address, id: u64 },
SetValidators { validators: Vec<Validator> },
Deploy { code: Vec<u8>, args: Vec<u8>, gas_limit: u64 },
Call { contract: Address, function: String, args: Vec<u8>, amount: Amount, gas_limit: u64 },
ClaimRewards { payout: Address },
}
| Tag | Variant | JSON key | Encoded size in bytes |
|---|---|---|---|
| 0 | Transfer | transfer | 53 |
| 1 | SetAuth | set_auth | 1963 with a 1-of-1 ML-DSA-65 policy |
| 2 | SetVault | set_vault | 2 for None, 11 with no recovery, 69 with a 1-of-1 SLH-DSA recovery policy |
| 3 | CancelPending | cancel_pending | 9 |
| 4 | ExecutePending | execute_pending | 41 |
| 5 | SetValidators | set_validators | 1994 with one validator |
| 6 | Deploy | deploy | 17, plus code and args |
| 7 | Call | call | 65, plus function name and args |
| 8 | ClaimRewards | claim_rewards | 33 |
| Variant | Effect | Rules on top of the common checks |
|---|---|---|
Transfer | Moves amount of asset from sender to to, creating the account if needed | amount above 0, balance covers it, the credit doesn't overflow |
SetAuth | Replaces the sender's owner policy | auth passes validate |
SetVault | Makes the sender a vault, changes it, or ends it | The policy, if any, passes validate |
CancelPending | Removes one of the sender's pending actions | id exists on the sender |
ExecutePending | Runs a pending action of account, which may be any account | The action exists and is unlocked |
SetValidators | Starts the next validator epoch | Governance only, 1 to 50 distinct ML-DSA-65 keys, each with its own payout address |
Deploy | Stores code and runs its init with args | Governance only, within the size limits |
Call | Runs contract.function(args) with amount of the native token | Function is not init, contract exists, balance covers amount after the fee |
ClaimRewards | Pays out what payout has earned as a validator, to payout | There is something to claim. Any account can send it |
For an owner of a vault, Transfer, SetAuth, SetVault and Call queue instead of acting. Payload::gas_limit() returns the gas_limit of Deploy and Call, and 0 for the rest. Transaction has no gas field of its own.
A Transfer of an asset other than AssetId(0) fails, see Assets.
What is signed
The message is tx.signing_bytes(), which is borsh(Transaction). Every key in a multisig signs those same bytes. It is signed in Domain::Transaction, so with the context string qubits/tx/v1, and both schemes build the same input:
M' = 0x00 || 0x0c || "qubits/tx/v1" || borsh(Transaction)
| Field | Signed | Why |
|---|---|---|
genesis_hash, sender, nonce | Yes | Bind the transaction to one chain, account and position |
fee, valid_until_ms | Yes | Nobody can raise what the sender pays or keep the transaction alive |
role | Yes | A recovery signature can't pass as an owner's, or the reverse |
payload | Yes | The action itself |
reveal | No | The address commits to it, so it can't be swapped |
signatures | No | They are the signatures |
Transaction id
id = BLAKE3 derive_key("qubits.sh 2026-11 transaction id", borsh(Transaction))
The id covers the same bytes as the signatures and nothing else. Signing is hedged, so signing a transaction twice gives different signatures with the same id. A block's tx_root hashes the whole SignedTransaction::encode(), signatures included, so a block still commits to exact bytes.
Replay protection
Genesis hash
genesis_hash must equal the chain's Genesis::hash(), the digest of the borsh Genesis under qubits.sh 2026-11 genesis. The design doc explains the choice: the signature covers the genesis hash and not the chain's name, so a transaction can't be replayed on another network that uses the same name. Any change to the genesis file gives a different hash.
Nonce
nonce must equal account.nonce exactly, so there are no gaps and a used nonce can't come back. A valid transaction raises the account's nonce by 1, including a contract call that reverts. An invalid one changes nothing. Owner and recovery share one nonce, because it belongs to the account. Several transactions from one sender can sit in a block, nonces in order, because each sees the state the last one left.
valid_until_ms
A transaction is expired if the block's timestamp is above valid_until_ms, so the value itself is the last moment that works. The test a_signed_transaction_expires checks both sides. There is no upper limit, and u64::MAX is valid.
| Client | Default validity |
|---|---|
| CLI, direct | 1 hour, --valid-for sets seconds |
CLI, --unsigned-out and transaction files | 7 days |
| Browser wallet | 1 hour |
Block time is chosen by the proposer as the larger of its clock and the parent's timestamp plus 1. Validators refuse a block whose timestamp is not above its parent's, or is more than MAX_CLOCK_DRIFT_MS (10,000) ahead of their own clock.
Fees
Fee formula
pub fn required_fee(min_fee_per_byte: u64, size: usize, gas_limit: u64) -> Amount {
let weight = size as u128 + u128::from(gas_limit.div_ceil(crate::limits::GAS_PER_BYTE));
u128::from(min_fee_per_byte) * weight
}
weight = size + ceil(gas_limit / GAS_PER_BYTE)
required = min_fee_per_byte × weight
maximum = required × MAX_FEE_MULTIPLE, saturating
valid if required ≤ fee ≤ maximum
| Term | Value |
|---|---|
size | stx.encode().len(), the whole signed transaction, including reveal and every signature |
gas_limit | payload.gas_limit(), 0 for anything but Deploy and Call |
GAS_PER_BYTE | 16 |
MAX_FEE_MULTIPLE | 8 |
min_fee_per_byte | ChainParams::min_fee_per_byte, a u64 in base units |
min_fee_per_byte is fixed in the genesis file. No payload changes it. The node reports it in /v1/status. With 18 decimals a genesis sets something like 10000000000, which is 0.00000001 token a byte, and this value is used in the numbers below.
Dividing by 16 rounds up:
gas_limit | Added to weight |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 15 | 1 |
| 16 | 1 |
| 17 | 2 |
| 2,000,000 | 125,000 |
| 5,000,000 | 312,500 |
Why the fee has a fixed width
fee is a u128, which always takes 16 bytes in borsh, and each scheme's signature has one length. The size of a transaction, and so its minimum fee, can be computed before the fee is known and before anything is signed. build_transaction does this:
- Build the
Transactionwithfee0. - Build a draft with the
revealit will carry and one zero-filled signature of the key's scheme. - Set
feetorequired_feeof the draft's encoded size. - Sign.
The final size equals the draft's, so the fee is exactly the minimum. PartialTransaction::new does the same with threshold placeholders of the largest sizes in the policy, see Accounts and addresses.
Where the fee goes
The fee is debited from sender in the native token and goes to the pool at rewards_address(), split equally among the seats of the validator set. Unused gas comes back to the sender first, so only what the transaction cost is split. Validators claim it when they like. See Rewards.
No coin is made or burned, so the total supply stays what genesis set.
The sender pays even when acting on another account: ExecutePending is paid by whoever sends it, and a recovery transaction is paid by the vault, because the vault is its sender.
Why the fee has a cap
An owner's fee is the one thing that escapes a vault's delay. Without a cap, a stolen owner key could skip the delay by burning the whole balance as a fee. MAX_FEE_MULTIPLE limits that to 8 times the minimum, per transaction. Vaults and recovery has what this does and does not stop.
A fee above required buys nothing. ChainApp::propose takes candidates from the mempool in arrival order and never looks at the fee, and the excess is not returned.
Gas and refunds
Gas is paid inside the fee, and what a call doesn't use comes back:
fn gas_refund(params: &ChainParams, gas_left: u64) -> Amount {
Amount::from(params.min_fee_per_byte) * Amount::from(gas_left / GAS_PER_BYTE)
}
refund = min_fee_per_byte × floor(gas_left / GAS_PER_BYTE)
The fee rounds up and the refund rounds down, so a refund is never more than was paid for that gas. The rate is the chain's minimum, not the rate the sender paid: overpaying is not refunded.
| Situation | Refund |
|---|---|
Outcome::Done | For the gas left |
Outcome::Reverted | For the gas left. The contract's writes are undone, and the fee and nonce stand. |
Outcome::Invalid, no such function | For the gas left after loading the code |
Outcome::Invalid, unusable code | None |
Outcome::OutOfGas, Outcome::Trapped | None |
Vault Call executed through ExecutePending | None. Gas was paid when it was queued, and ExecutePending's sender pays only its own fee. |
Queued Call cancelled | None |
Separately, reserve_gas counts gas limits against MAX_BLOCK_GAS. It counts the limit, not the gas used, so a proposer can fill a block without running what doesn't fit.
Fees by transaction
Every size here comes from encoding real signed transactions, at min_fee_per_byte = 10000000000.
| Transaction | Size | Required fee | Maximum fee |
|---|---|---|---|
| Transfer, first, ML-DSA-65 | 5432 | 54320000000000 | 434560000000000 |
| Transfer, later, ML-DSA-65 | 3470 | 34700000000000 | 277600000000000 |
| Transfer, first, SLH-DSA-SHA2-192s | 16443 | 164430000000000 | 1315440000000000 |
| Transfer, later, SLH-DSA-SHA2-192s | 16385 | 163850000000000 | 1310800000000000 |
| Transfer, first, 2-of-3 ML-DSA-65 | 12661 | 126610000000000 | 1012880000000000 |
| Transfer, later, 2-of-3 ML-DSA-65 | 6785 | 67850000000000 | 542800000000000 |
| Transfer, later, 2-of-3 signed by one ML-DSA-65 and one SLH-DSA key | 19700 | 197000000000000 | 1576000000000000 |
| Transfer, first, 16-of-16 ML-DSA-65 | 84512 | 845120000000000 | 6760960000000000 |
CancelPending, later, ML-DSA-65 | 3426 | 34260000000000 | 274080000000000 |
CancelPending, SLH-DSA recovery | 16341 | 163410000000000 | 1307280000000000 |
SetAuth to one ML-DSA-65 key, SLH-DSA recovery | 18295 | 182950000000000 | 1463600000000000 |
Sizes are in bytes and fees in base units. The second of these is 0.0000347 token.
Worked examples
These ran through execute_block on a MemoryState, with the real token.wasm.
A first transfer of one token from a new ML-DSA-65 account:
min_fee_per_byte 10000000000
signed size 5432
gas limit 0
weight 5432
required fee 54320000000000
maximum fee 434560000000000
fee paid 54320000000000
amount sent 1000000000000000000
balance before 1000000000000000000000
balance after 998999945680000000000
token.transfer(to, 300) from the same account, with the CLI's default gas limit:
min_fee_per_byte 10000000000
Transaction bytes 218
signed size 3538
gas limit 2000000
ceil(gas / 16) 125000
weight 128538
required fee 1285380000000000
maximum fee 10283040000000000
outcome Done
gas used 190562
gas left 1809438
floor(left / 16) 113089
refund 1130890000000000
net cost 154490000000000
The net cost equals the rate times 15449, which is 3538 + 125000 - 113089. A sender therefore gets back almost all of a generous gas limit, but needs the full fee in its balance first.
The same call with the fee set to 3 times the minimum:
fee 3856140000000000
gas used 190678
refund 1130820000000000
net cost 2725320000000000
The refund is about the same as before, so the extra fee stayed burned. Deploying the token from the governance account:
code size 6677
signed size 10192
gas limit 2000000
weight 135192
required fee 1351920000000000
gas used 682141
refund 823660000000000
net cost 528260000000000
Execution order
Executor applies a transaction on a staged copy of what it touches, and merges it only if every step passes. An error at any step leaves no trace: no fee, no nonce, no reveal. A comment in the code gives the reason for this order: signatures go last because they are expensive and a failed transaction pays nothing.
pub struct BlockEnv {
pub genesis_hash: Hash,
pub height: u64,
pub timestamp_ms: u64,
pub params: ChainParams,
}
Here now is env.timestamp_ms.
- Encode the transaction. If
sizeis aboveMAX_TX_BYTES, fail withTooLarge. - If
genesis_hashdiffers, fail withWrongChain. - If
nowis abovevalid_until_ms, fail withExpired. - For
Deploy, checkcodeagainstMAX_CODE_BYTESandargsagainstMAX_ARGS_BYTES. ForCall, checkfunctionagainstMAX_FUNCTION_NAMEandargsagainstMAX_ARGS_BYTES. Over a limit, fail withTooLarge. - If
gas_limitis aboveMAX_TX_GAS, fail withGasTooHigh. - Compute
required. Iffeeis below it, fail withFeeTooLow. - If
feeis aboverequired × 8, fail withFeeTooHigh. - Load the sender. If it has no account, fail with
UnknownSender. - If
noncediffers, fail withBadNonce. - If the native balance is below
fee, fail withInsufficientBalance. - Work out the owner policy from
authandreveal, which can fail withRevealNotAllowed,RevealRequired,RevealMismatchorInvalidPolicy. - Choose the policy for the role. For
Recoveryit is the vault's recovery policy, or fail withNoRecoveryPolicy. - Check signatures, or fail with
Unauthorized. - Store the owner policy, raise the nonce and debit the fee.
- If the payload is
SetValidatorsorDeployand the sender isn't governance, fail withNotGovernance. If the sender is governance and the payload is anything else, fail withNotAllowedForGovernance. - If the role is
Recoveryand the payload isn'tCancelPendingorSetAuth, fail withNotAllowedForRecovery. - Set the delay: the vault's
delay_msif the role isOwnerand the account is a vault, and no delay otherwise. - Apply the payload.
Because the fee is debited at step 14, later checks see the balance after it. Sending the whole balance fails InsufficientBalance: the fee has to come out of it too.
execute_block first refuses a block with more than max_block_transactions, then prewarms the signature cache, then applies each transaction. Blocks contain only valid transactions, so one failure fails the whole block with BlockError::Transaction { index, id, error }.
A contract that fails is not a TxError. The transaction is valid, stays in the block, pays and keeps its nonce, and the failure goes in its Receipt:
Outcome | Meaning |
|---|---|
Done | Finished |
Reverted { reason } | The contract refused. Its work is undone. |
OutOfGas | Ran out of gas |
Trapped | The WebAssembly trapped |
Invalid | Not a usable contract, or no such function |
Every applied transaction yields a Receipt, and all of them go into receipt_root. A plain transaction has the default one. Executor::without_contracts() runs steps 1 to 18 but skips contract code, for admission to the mempool.
Errors
TxError
| Variant | Raised when |
|---|---|
WrongChain { expected } | genesis_hash is not this chain's |
Expired { valid_until_ms } | The block time is past valid_until_ms |
TooLarge | The transaction, code, args or function name is over its limit |
FeeTooLow { required } | fee is below the minimum |
FeeTooHigh { maximum } | fee is above 8 times the minimum |
UnknownSender | sender has no account |
BadNonce { expected } | nonce is not the account's |
RevealRequired | The account has no auth and reveal is absent |
RevealMismatch | reveal does not hash to sender |
RevealNotAllowed | The account has auth and reveal is present |
NoRecoveryPolicy | Role is Recovery and the account has no vault with a recovery policy |
NotAllowedForRecovery | Role is Recovery and the payload isn't CancelPending or SetAuth |
NotGovernance | SetValidators or Deploy from another account |
NotAllowedForGovernance | The governance account sent anything else |
Unauthorized(String) | Too few signatures, bad indices, or a signature fails |
InvalidPolicy(String) | A revealed, new, vault or validator policy fails validation |
InsufficientBalance { asset, needed, available } | The balance doesn't cover the fee, an amount, or a pending transfer at execution |
ZeroAmount | Transfer of 0 |
NothingToClaim | ClaimRewards for an address with nothing to claim |
Overflow | A credit overflows u128, or unlock_at_ms overflows u64 |
PendingNotFound | No pending action with that id, or no such account |
PendingLocked { unlock_at_ms } | The action is not unlocked yet |
TooManyPending | The vault already has 16 pending actions |
BlockGasFull | The gas limit doesn't fit in this block |
GasTooHigh | gas_limit is above 5,000,000 |
NotAContract | Call to an address with no contract |
NotCallable | Call of init |
Storage(String) | State could not be read or is corrupt |
While building a block, ChainApp treats these in three ways. BlockGasFull leaves the transaction in the mempool for a later block. Storage stops the block. Any other error drops the transaction from the mempool.
BlockError
| Variant | Raised when |
|---|---|
TooManyTransactions | More transactions than max_block_transactions |
Transaction { index, id, error } | Transaction index failed |
Limits
qubits_types::limits holds these constants. They are part of the on-chain format.
| Constant | Value | Enforced by | Meaning |
|---|---|---|---|
MAX_AUTH_KEYS | 16 | AuthPolicy::validate | Keys in one policy |
MAX_PENDING_ACTIONS | 16 | queue | Pending actions on one account |
MAX_VAULT_DELAY_MS | 31,536,000,000 | VaultPolicy::validate | 365 days |
MAX_TX_BYTES | 294,912 | Executor, SignedTransaction::decode | 288 KiB, one signed transaction |
MAX_FEE_MULTIPLE | 8 | Executor | Highest fee, as a multiple of the minimum |
MAX_CHAIN_ID_LEN | 64 | Genesis::validate | Characters of chain_id |
MAX_VALIDATORS | 50 | ValidatorSet::validate, Genesis::validate | Validators in a set |
MAX_BLOCK_BYTES | 8,388,608 | ChainApp::execute | 8 MiB, the sum of encoded transactions in a block |
MAX_CODE_BYTES | 163,840 | Executor | 160 KiB of contract code |
MAX_ARGS_BYTES | 16,384 | Executor, qubits-vm | 16 KiB of arguments, and of a contract's output |
MAX_FUNCTION_NAME | 64 | Executor, qubits-vm | Bytes of a function name |
MAX_CALL_DEPTH | 8 | Run::enter | Contracts on the call stack |
GAS_PER_BYTE | 16 | required_fee, gas_refund | Gas one byte's worth of fee buys |
MAX_TX_GAS | 5,000,000 | Executor | Gas limit of one transaction |
MAX_BLOCK_GAS | 10,000,000 | reserve_gas | Sum of gas limits in a block, so a block stays far below the block time |
MAX_CODE_BYTES is set to leave room for signatures: the governance account deploys, so the transaction carries a quorum of them. A deployment with the largest code and args and 34 ML-DSA-65 signatures, the quorum of 50 validators, encodes to 293,053 bytes.
Values outside limits that matter here:
| Name | Value | Part of consensus | Meaning |
|---|---|---|---|
ChainParams::min_fee_per_byte | Set at genesis | Yes | Base units per unit of weight |
ChainParams::max_block_transactions | Set at genesis, above 0 | Yes | Transactions in a block |
MAX_CLOCK_DRIFT_MS | 10,000 | Checked before voting | How far ahead of a validator's clock a block's timestamp may be |
MAX_MEMPOOL_TRANSACTIONS | 10,000 | No | Transactions a mempool holds |
MAX_PER_SENDER | 64 | No | Transactions one sender may have waiting |
Timing::tx_ttl_ms | 600,000 | No | How long a transaction waits in the mempool |
Admission to the mempool
Node::accept decides whether a submitted transaction is held for a block. Being accepted says nothing about what a contract would do.
- Refuse a transaction already in the mempool or a block as a duplicate, and refuse if the mempool or the sender's share of it is full.
- Build a
BlockEnvon the head of the chain with block timemax(now, head + 1). - Create an
Executorwithwithout_contracts(), so submitting can't run code for free. - Apply the sender's waiting transactions without checking their signatures again, so a run of nonces can queue.
- Apply the new transaction with its signatures.
POST /v1/transactions answers 202 with the id, 400 for an invalid transaction, 409 for a duplicate and 503 if the mempool is full. The request body may be up to twice MAX_TX_BYTES plus 1024 bytes, because the transaction is hex. A transaction leaves the mempool when it is included, fails while a block is built, passes valid_until_ms, or has waited tx_ttl_ms. Node operation covers gossip and forwarding.
Source
chain/crates/qubits-types/src/transaction.rschain/crates/qubits-types/src/client.rschain/crates/qubits-types/src/lib.rschain/crates/qubits-types/src/genesis.rschain/crates/qubits-types/src/contract.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-execution/src/contracts.rschain/crates/qubits-execution/src/rewards.rschain/crates/qubits-execution/tests/execution.rschain/crates/qubits-execution/tests/contracts.rschain/crates/qubits-node/src/lib.rschain/crates/qubits-node/src/app.rschain/crates/qubits-node/src/mempool.rschain/crates/qubits-node/src/rpc.rschain/crates/qubits-cli/src/main.rs