All pages

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,
}
OffsetSizeFieldTypeMeaning
032genesis_hashHashThe chain this transaction belongs to
3232senderAddressThe account that acts and pays
648nonceu64Must equal the account's nonce
7216feeu128Base units burned from the sender
888valid_until_msu64The last block time it may be included in
961roleRole0 for Owner, 1 for Recovery
97npayloadPayloadTag, 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 },
}
TagVariantJSON keyEncoded size in bytes
0Transfertransfer53
1SetAuthset_auth1963 with a 1-of-1 ML-DSA-65 policy
2SetVaultset_vault2 for None, 11 with no recovery, 69 with a 1-of-1 SLH-DSA recovery policy
3CancelPendingcancel_pending9
4ExecutePendingexecute_pending41
5SetValidatorsset_validators1994 with one validator
6Deploydeploy17, plus code and args
7Callcall65, plus function name and args
8ClaimRewardsclaim_rewards33
VariantEffectRules on top of the common checks
TransferMoves amount of asset from sender to to, creating the account if neededamount above 0, balance covers it, the credit doesn't overflow
SetAuthReplaces the sender's owner policyauth passes validate
SetVaultMakes the sender a vault, changes it, or ends itThe policy, if any, passes validate
CancelPendingRemoves one of the sender's pending actionsid exists on the sender
ExecutePendingRuns a pending action of account, which may be any accountThe action exists and is unlocked
SetValidatorsStarts the next validator epochGovernance only, 1 to 50 distinct ML-DSA-65 keys, each with its own payout address
DeployStores code and runs its init with argsGovernance only, within the size limits
CallRuns contract.function(args) with amount of the native tokenFunction is not init, contract exists, balance covers amount after the fee
ClaimRewardsPays out what payout has earned as a validator, to payoutThere 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)
FieldSignedWhy
genesis_hash, sender, nonceYesBind the transaction to one chain, account and position
fee, valid_until_msYesNobody can raise what the sender pays or keep the transaction alive
roleYesA recovery signature can't pass as an owner's, or the reverse
payloadYesThe action itself
revealNoThe address commits to it, so it can't be swapped
signaturesNoThey 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.

ClientDefault validity
CLI, direct1 hour, --valid-for sets seconds
CLI, --unsigned-out and transaction files7 days
Browser wallet1 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
TermValue
sizestx.encode().len(), the whole signed transaction, including reveal and every signature
gas_limitpayload.gas_limit(), 0 for anything but Deploy and Call
GAS_PER_BYTE16
MAX_FEE_MULTIPLE8
min_fee_per_byteChainParams::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_limitAdded to weight
00
11
151
161
172
2,000,000125,000
5,000,000312,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:

  1. Build the Transaction with fee 0.
  2. Build a draft with the reveal it will carry and one zero-filled signature of the key's scheme.
  3. Set fee to required_fee of the draft's encoded size.
  4. 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.

SituationRefund
Outcome::DoneFor the gas left
Outcome::RevertedFor the gas left. The contract's writes are undone, and the fee and nonce stand.
Outcome::Invalid, no such functionFor the gas left after loading the code
Outcome::Invalid, unusable codeNone
Outcome::OutOfGas, Outcome::TrappedNone
Vault Call executed through ExecutePendingNone. Gas was paid when it was queued, and ExecutePending's sender pays only its own fee.
Queued Call cancelledNone

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.

TransactionSizeRequired feeMaximum fee
Transfer, first, ML-DSA-65543254320000000000434560000000000
Transfer, later, ML-DSA-65347034700000000000277600000000000
Transfer, first, SLH-DSA-SHA2-192s164431644300000000001315440000000000
Transfer, later, SLH-DSA-SHA2-192s163851638500000000001310800000000000
Transfer, first, 2-of-3 ML-DSA-65126611266100000000001012880000000000
Transfer, later, 2-of-3 ML-DSA-65678567850000000000542800000000000
Transfer, later, 2-of-3 signed by one ML-DSA-65 and one SLH-DSA key197001970000000000001576000000000000
Transfer, first, 16-of-16 ML-DSA-65845128451200000000006760960000000000
CancelPending, later, ML-DSA-65342634260000000000274080000000000
CancelPending, SLH-DSA recovery163411634100000000001307280000000000
SetAuth to one ML-DSA-65 key, SLH-DSA recovery182951829500000000001463600000000000

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.

  1. Encode the transaction. If size is above MAX_TX_BYTES, fail with TooLarge.
  2. If genesis_hash differs, fail with WrongChain.
  3. If now is above valid_until_ms, fail with Expired.
  4. For Deploy, check code against MAX_CODE_BYTES and args against MAX_ARGS_BYTES. For Call, check function against MAX_FUNCTION_NAME and args against MAX_ARGS_BYTES. Over a limit, fail with TooLarge.
  5. If gas_limit is above MAX_TX_GAS, fail with GasTooHigh.
  6. Compute required. If fee is below it, fail with FeeTooLow.
  7. If fee is above required × 8, fail with FeeTooHigh.
  8. Load the sender. If it has no account, fail with UnknownSender.
  9. If nonce differs, fail with BadNonce.
  10. If the native balance is below fee, fail with InsufficientBalance.
  11. Work out the owner policy from auth and reveal, which can fail with RevealNotAllowed, RevealRequired, RevealMismatch or InvalidPolicy.
  12. Choose the policy for the role. For Recovery it is the vault's recovery policy, or fail with NoRecoveryPolicy.
  13. Check signatures, or fail with Unauthorized.
  14. Store the owner policy, raise the nonce and debit the fee.
  15. If the payload is SetValidators or Deploy and the sender isn't governance, fail with NotGovernance. If the sender is governance and the payload is anything else, fail with NotAllowedForGovernance.
  16. If the role is Recovery and the payload isn't CancelPending or SetAuth, fail with NotAllowedForRecovery.
  17. Set the delay: the vault's delay_ms if the role is Owner and the account is a vault, and no delay otherwise.
  18. 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:

OutcomeMeaning
DoneFinished
Reverted { reason }The contract refused. Its work is undone.
OutOfGasRan out of gas
TrappedThe WebAssembly trapped
InvalidNot 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

VariantRaised when
WrongChain { expected }genesis_hash is not this chain's
Expired { valid_until_ms }The block time is past valid_until_ms
TooLargeThe 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
UnknownSendersender has no account
BadNonce { expected }nonce is not the account's
RevealRequiredThe account has no auth and reveal is absent
RevealMismatchreveal does not hash to sender
RevealNotAllowedThe account has auth and reveal is present
NoRecoveryPolicyRole is Recovery and the account has no vault with a recovery policy
NotAllowedForRecoveryRole is Recovery and the payload isn't CancelPending or SetAuth
NotGovernanceSetValidators or Deploy from another account
NotAllowedForGovernanceThe 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
ZeroAmountTransfer of 0
NothingToClaimClaimRewards for an address with nothing to claim
OverflowA credit overflows u128, or unlock_at_ms overflows u64
PendingNotFoundNo pending action with that id, or no such account
PendingLocked { unlock_at_ms }The action is not unlocked yet
TooManyPendingThe vault already has 16 pending actions
BlockGasFullThe gas limit doesn't fit in this block
GasTooHighgas_limit is above 5,000,000
NotAContractCall to an address with no contract
NotCallableCall 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

VariantRaised when
TooManyTransactionsMore 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.

ConstantValueEnforced byMeaning
MAX_AUTH_KEYS16AuthPolicy::validateKeys in one policy
MAX_PENDING_ACTIONS16queuePending actions on one account
MAX_VAULT_DELAY_MS31,536,000,000VaultPolicy::validate365 days
MAX_TX_BYTES294,912Executor, SignedTransaction::decode288 KiB, one signed transaction
MAX_FEE_MULTIPLE8ExecutorHighest fee, as a multiple of the minimum
MAX_CHAIN_ID_LEN64Genesis::validateCharacters of chain_id
MAX_VALIDATORS50ValidatorSet::validate, Genesis::validateValidators in a set
MAX_BLOCK_BYTES8,388,608ChainApp::execute8 MiB, the sum of encoded transactions in a block
MAX_CODE_BYTES163,840Executor160 KiB of contract code
MAX_ARGS_BYTES16,384Executor, qubits-vm16 KiB of arguments, and of a contract's output
MAX_FUNCTION_NAME64Executor, qubits-vmBytes of a function name
MAX_CALL_DEPTH8Run::enterContracts on the call stack
GAS_PER_BYTE16required_fee, gas_refundGas one byte's worth of fee buys
MAX_TX_GAS5,000,000ExecutorGas limit of one transaction
MAX_BLOCK_GAS10,000,000reserve_gasSum 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:

NameValuePart of consensusMeaning
ChainParams::min_fee_per_byteSet at genesisYesBase units per unit of weight
ChainParams::max_block_transactionsSet at genesis, above 0YesTransactions in a block
MAX_CLOCK_DRIFT_MS10,000Checked before votingHow far ahead of a validator's clock a block's timestamp may be
MAX_MEMPOOL_TRANSACTIONS10,000NoTransactions a mempool holds
MAX_PER_SENDER64NoTransactions one sender may have waiting
Timing::tx_ttl_ms600,000NoHow 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.

  1. 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.
  2. Build a BlockEnv on the head of the chain with block time max(now, head + 1).
  3. Create an Executor with without_contracts(), so submitting can't run code for free.
  4. Apply the sender's waiting transactions without checking their signatures again, so a run of nonces can queue.
  5. 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.rs
  • chain/crates/qubits-types/src/client.rs
  • chain/crates/qubits-types/src/lib.rs
  • chain/crates/qubits-types/src/genesis.rs
  • chain/crates/qubits-types/src/contract.rs
  • chain/crates/qubits-execution/src/lib.rs
  • chain/crates/qubits-execution/src/contracts.rs
  • chain/crates/qubits-execution/src/rewards.rs
  • chain/crates/qubits-execution/tests/execution.rs
  • chain/crates/qubits-execution/tests/contracts.rs
  • chain/crates/qubits-node/src/lib.rs
  • chain/crates/qubits-node/src/app.rs
  • chain/crates/qubits-node/src/mempool.rs
  • chain/crates/qubits-node/src/rpc.rs
  • chain/crates/qubits-cli/src/main.rs