All pages

Accounts and addresses

This page specifies how addresses are derived and written, what an account holds, how AuthPolicy decides who may sign, how a policy is revealed and rotated, how multisig transactions are assembled, and what makes the governance account different. It is for engineers building clients and auditors checking authorization rules. Vault behaviour has its own page, Vaults and recovery.

Address

pub struct Address(pub [u8; 32]);

An address is 32 bytes. For an account controlled by keys, it is the BLAKE3 hash of the account's initial AuthPolicy. Keys and even schemes can change later, and the address stays the same. The hash also means no public key appears on the chain until the account first sends something.

Derivation

input   = borsh(AuthPolicy)
address = BLAKE3 derive_key("qubits.sh 2026-11 address", input), 32 bytes
text    = bech32m("qb", address)

The borsh encoding of a policy, field by field:

OffsetSizeFieldEncoding
01thresholdu8
14Number of keysu32 little-endian
54Length of the first keyu32 little-endian, 1 + raw key length
91Scheme byte of the first key0x01 or 0x02
10nRaw first key1952 or 48 bytes
...Further keysSame as the first

Everything in the policy counts: the threshold, the keys and their order. Two policies that differ only in key order have different addresses, and the test address_depends_on_the_whole_policy checks this.

PolicyEncoded size
1-of-1, ML-DSA-651962 bytes
1-of-1, SLH-DSA-SHA2-192s58 bytes
2-of-3, two ML-DSA-65 and one SLH-DSA-SHA2-192s3972 bytes

Example

The test address_format_is_pinned fixes one address. It uses the ML-DSA-65 key from the seed 0x01 repeated 32 times, in a 1-of-1 policy:

input (1962 bytes)  01 01000000 a1070000 01 c4e999a2...
                    |  |        |        |  raw public key
                    |  |        |        scheme byte
                    |  |        key length, 1953
                    |  number of keys
                    threshold

address (hex)       69b5f32ed0cb56494813a57cc14399d07bf6ea26acecf47bd21428488c474fba
address (text)      qb1dx6lxtksedtyjjqn547vzsue6pald63x4nk0g77jzs5y3rz8f7aq2d9vg6

Text form

An address is written in bech32m with the human-readable prefix qb, so it starts with qb1, and it is 61 characters long. Parsing runs these checks, and each failure is Error::InvalidAddress:

  1. The string must be valid bech32m. A bech32 checksum, a typo or mixed case fails here.
  2. The prefix must be qb.
  3. The payload must be exactly 32 bytes.

An all-uppercase address parses to the same bytes, because bech32m defines that form. Display always writes lowercase. In borsh an address is its 32 raw bytes, and in JSON it is the text form.

Addresses not derived from a policy

KindInput to the hashContextWho can spend
Key or multisig accountborsh AuthPolicyqubits.sh 2026-11 addressThe account's current auth
GovernanceEmptyqubits.sh 2026-11 validator governanceA quorum of the current validators
ContractDeployer, then nonce as u64 little-endianqubits.sh 2026-11 contract addressNobody. A contract moves its own balance through its code, see Contract model.

Separate contexts mean no policy can hash to a governance or contract address short of a preimage attack. An address nobody has a policy for, such as a contract's, has no auth and can never pass the reveal check.

Account

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

pub type Amount = u128;
pub struct AssetId(pub u32);
FieldDefaultMeaning
nonce0Number of transactions this account has sent
balancesEmptyBalance per asset, zero entries removed
authNoneThe owner policy. None until the account's first transaction.
vaultNoneThe vault policy, if this account is a vault
pendingEmptyActions waiting out the vault delay, at most 16
next_pending_id0The id the next queued action gets

An account is stored in state under the key account/ followed by the 32 address bytes, as borsh of this struct. Field order is part of the on-chain format.

The bytes of a funded account that has never sent anything, holding 5 base units of the native token (46 bytes):

0000000000000000                  nonce
01000000                          one entry in balances
00000000                          AssetId 0
05000000000000000000000000000000  amount, u128 little-endian
00                                auth, None
00                                vault, None
00000000                          pending, empty
0000000000000000                  next_pending_id
AccountEncoded size
Default, empty26 bytes
One balance46 bytes
One balance, 1-of-1 ML-DSA-65 policy revealed2008 bytes

Creation

An account record appears the first time an address is credited, by genesis, by a transfer or by a contract sending value. Creating it needs no signature and no reveal: auth stays None. A transaction from an address with no record fails with TxError::UnknownSender, so an account must be funded before it can send.

GET /v1/accounts/{address} answers for any valid address. For one without a record it returns exists: false and defaults.

JSON fieldType
addressText address
existsBoolean
nonceNumber
authPolicy or null
vaultVault policy or null
balancesList of { asset, amount }, with amount as a decimal string
pendingList of pending actions

Vaults and recovery has a full response for an account with pending actions.

Assets

AssetId::NATIVE is AssetId(0). The native token has 18 decimals, and NATIVE_DECIMALS is for display only: the chain deals in base units, as u128, and JSON carries amounts as decimal strings.

balances is keyed by asset, and Payload::Transfer names one, but only asset 0 has any balance. Genesis funds only the native token and no payload creates another, so a transfer of any other asset fails with TxError::InsufficientBalance. Tokens are contracts and live in contract storage, see Token, AMM and escrow.

Authorization policy

pub struct AuthPolicy {
    pub threshold: u8,
    pub keys: Vec<PublicKey>,
}

pub struct KeySignature {
    pub index: u8,
    pub signature: Signature,
}

A policy is M-of-N: threshold signatures from keys. Keys may use different schemes. The design doc gives the example of an ML-DSA hot key next to an SLH-DSA cold key in one policy. AuthPolicy::single(key) builds a 1-of-1 policy.

Validation

AuthPolicy::validate applies to a revealed policy, to a new policy in SetAuth, and to a recovery policy.

RuleError message
At least one keyno keys
At most MAX_AUTH_KEYS, which is 16too many keys
threshold from 1 to the number of keysthreshold must be between 1 and the number of keys
No key twiceduplicate key

These are Error::InvalidAuthPolicy, which execution reports as TxError::InvalidPolicy.

Checking signatures

AuthPolicy::check(msg, signatures) runs over SignedTransaction::signatures:

  1. If there are fewer signatures than threshold, fail with not enough signatures.
  2. For each signature in order:
    1. Its index must be above the previous one, otherwise fail with signature indices must be strictly increasing.
    2. keys[index] must exist, otherwise fail with signature index out of range.
    3. The signature must verify under that key in Domain::Transaction.

Execution reports any of these as TxError::Unauthorized.

Strictly increasing indices mean one key can't count twice. There is no upper limit below the number of keys: a transaction may carry more signatures than threshold, and every one of them must verify. A 2-of-3 account accepted a transaction with three signatures when the fee covered its size. The index of a signature must match the position of its key in the policy, and AuthPolicy::sign sorts by index for that reason.

Revealing a policy

A new account has auth: None. Its first transaction must carry the policy in SignedTransaction::reveal, so the chain can see the keys whose hash is the address. After that, auth is the source of truth and reveal must be absent.

account.authrevealResult
PresentAbsentThe stored policy applies
PresentPresentTxError::RevealNotAllowed
AbsentAbsentTxError::RevealRequired
AbsentPresent, hash is not the senderTxError::RevealMismatch
AbsentPresent, fails validateTxError::InvalidPolicy
AbsentPresent and validThe revealed policy applies and is stored

The policy is stored in the same step as the first nonce increment and fee, and only if the transaction passes. A failed first transaction changes nothing.

reveal is not signed. It doesn't need to be: the address commits to it, and anything else fails the hash check. It does add to the transaction's size and therefore its fee. A first transfer with a 1-of-1 ML-DSA-65 policy is 5432 bytes, and a later one is 3470 bytes.

An address made from an invalid policy, such as one with threshold 0, can receive funds and can never spend them, because validate refuses the reveal with TxError::InvalidPolicy. Nothing on the chain can tell before the first attempt, since only the hash is known. qubits policy new validates before printing an address.

Key rotation

Payload::SetAuth { auth } replaces the owner policy. The address, balances and nonce stay, and the new policy may use a different scheme or threshold. The test rotating_keys_and_changing_scheme_keeps_the_address moves an account from ML-DSA-65 to SLH-DSA-SHA2-192s and sends from it.

SenderWhen it takes effect
Owner of a plain accountIn this transaction
Owner of a vaultAfter the vault delay, once someone executes it
Recovery of a vaultIn this transaction

From the moment it takes effect the old keys fail with TxError::Unauthorized. The new policy is checked with validate, otherwise TxError::InvalidPolicy.

qubits key new --out new.json
qubits key rotate --new-key new.json --key old.json
qubits transfer --key new.json --account qb1... --to qb1... --amount 5

After a rotation the key no longer hashes to the address, so commands need --account. key rotate reads only the public key of --new-key and always builds a 1-of-1 policy. A rotation to an M-of-N policy is valid on the chain, but the CLI has no command for it.

Multisig

A multisig account is one whose policy has a threshold above 1. It is created like any other, by funding the hash of its policy.

qubits policy new --threshold 2 --key a.json --key b.json --key c.json --out policy.json
qubits transfer --account qb1... --policy policy.json --to qb1... --amount 5 --unsigned-out tx.json
qubits tx show --file tx.json
qubits tx sign --file tx.json --key a.json
qubits tx sign --file tx.json --key b.json
qubits tx submit --file tx.json --wait

policy new also takes --public-key with tagged hex. --policy is only needed for the account's first transaction, to reveal the policy. Any command that sends a transaction takes --unsigned-out.

Transaction files

A transaction file is a PartialTransaction in JSON. It is a client format, with no borsh encoding and no meaning to the chain.

pub struct PartialTransaction {
    pub tx: Transaction,
    pub policy: AuthPolicy,
    pub reveal: Option<AuthPolicy>,
    pub signatures: Vec<KeySignature>,
}

A file with both signatures, keys and signatures shortened:

{
  "tx": {
    "genesis_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "sender": "qb13szupdzjtn972l8eqm82a9w0555f78j0vjahzah070mm9vwny9gs64fhk9",
    "nonce": 0,
    "fee": "236720000000000",
    "valid_until_ms": 1800000000000,
    "role": "owner",
    "payload": {
      "transfer": {
        "to": "qb142khwhdlmdyvcwl3u36qcc9l7u8q82ftzem0dv5fakxjd94yt84qlnehmw",
        "asset": 0,
        "amount": "5"
      }
    }
  },
  "policy": {
    "threshold": 2,
    "keys": ["0182bbe8b3c66eb4...", "0193cc29bfe3d20e...", "020d0d0d0d0d0d0d..."]
  },
  "reveal": {
    "threshold": 2,
    "keys": ["0182bbe8b3c66eb4...", "0193cc29bfe3d20e...", "020d0d0d0d0d0d0d..."]
  },
  "signatures": [
    { "index": 0, "signature": "012a6b9e793b43c6..." },
    { "index": 1, "signature": "01057fd8be16004b..." }
  ]
}

PartialTransaction::new runs these steps:

  1. Fail with BuildError::NoAccount if the account does not exist.
  2. Choose the policy. For the owner role, use the account's auth. With no auth, use the policy given by --policy if its hash is the address, and set reveal to it. Otherwise fail with BuildError::NotController. For the recovery role, use the vault's recovery policy, or fail with BuildError::NoRecoveryPolicy.
  3. Set the nonce from the account and the fee to 0.
  4. Sort the policy's signature sizes, largest first, and take threshold of them. Build placeholder signatures of those sizes.
  5. Encode that draft and set fee to required_fee of its size and the payload's gas limit.
  6. Leave signatures empty.

sign finds the key's position in the policy, signs tx.signing_bytes() in Domain::Transaction, and keeps the signatures sorted by index. It fails with BuildError::KeyNotInPolicy for a key outside the policy and with this key already signed for a repeat. missing says how many are still needed. finish requires threshold signatures, runs policy.check locally, and returns the SignedTransaction.

Because the file's fee assumes the largest signatures, a smaller set of signers pays more than the minimum. For a 2-of-3 policy of two ML-DSA-65 keys and one SLH-DSA-SHA2-192s key, signed by the two ML-DSA-65 keys, the fee was 2.2 times the minimum of the final transaction. The excess is not returned. A 1-of-2 policy of one key of each scheme, signed with the ML-DSA-65 key, paid 3.4 times the minimum on its first transaction, and a later transfer would pay 4.7 times. The difference between the two signature sizes keeps this under the fee cap of 8 times the minimum.

Stop at the threshold. The fee covers exactly threshold signatures. qubits tx sign accepts more, finish passes, and the chain refuses the larger transaction with TxError::FeeTooLow. In a run with three signatures on a 2-of-3 ML-DSA-65 file, that is what came back.

A file fixes the nonce when it is written, so any other transaction from the account makes it fail with TxError::BadNonce. It also fixes valid_until_ms. With --unsigned-out this defaults to a week instead of an hour, to leave signers time, and --valid-for sets it in seconds.

Single-key signing

build_transaction is the one-step path that the CLI and the browser wallet share. It does the same as PartialTransaction::new and sign for a policy with threshold 1, except that the fee assumes the signing key's own signature size, so it is exactly the minimum. Without a stored auth it assumes a 1-of-1 policy of the signing key, reveals it, and checks that it hashes to the address.

BuildErrorMessage
NoAccountthis account does not exist yet; it needs funds to pay fees
NotControllerthis key does not control the account
NoRecoveryPolicythe account has no recovery policy
KeyNotInPolicy(role)this key is not part of the account's owner policy, or recovery
NeedsMoreSignersthe policy needs several signatures; multisig signing is not supported here yet
Signing(String)signing failed: ...

Governance account

governance_address() is the digest of nothing under its own context, so it is a constant on every chain:

qb157cmgrg86jcm5t46njx2l85ly6tx3udv84mwm4gqjsl92hmqguzszhfztx
a7b1b40d07d4b1ba2eba9c8caf9e9f269668f1ac3d76edd500943e555f604705

No key hashes to it, so it has no reveal. genesis_writes stores auth for it directly, as ValidatorSet::council(), and every SetValidators replaces that with the council of the next set.

impl ValidatorSet {
    pub fn max_faulty(&self) -> usize { self.keys.len().saturating_sub(1) / 3 }
    pub fn quorum(&self) -> usize { self.keys.len() - self.max_faulty() }
    pub fn council(&self) -> AuthPolicy {
        AuthPolicy { threshold: self.quorum() as u8, keys: self.keys.clone() }
    }
}
Validators nFaulty fThreshold n - f
101
413
725
1037
22715
341123
501634

The account signs and pays like any other, with one restriction: it may send only SetValidators and Deploy, and only it may send those.

PayloadFrom governanceFrom any other account
SetValidators, DeployAllowedTxError::NotGovernance
Anything elseTxError::NotAllowedForGovernanceAllowed

Fees are therefore the only thing that leaves the account. It can receive transfers, which is how it gets funded. It has no vault, because SetVault is one of the refused payloads. Council signing uses the same transaction files as multisig, through qubits validators propose and qubits contract deploy. Validators and epochs covers what a change does.

Source

  • chain/crates/qubits-types/src/address.rs
  • chain/crates/qubits-types/src/account.rs
  • chain/crates/qubits-types/src/transaction.rs
  • chain/crates/qubits-types/src/client.rs
  • chain/crates/qubits-types/src/rpc.rs
  • chain/crates/qubits-types/tests/types.rs
  • chain/crates/qubits-execution/src/lib.rs
  • chain/crates/qubits-execution/tests/execution.rs
  • chain/crates/qubits-cli/src/main.rs
  • chain/crates/qubits-node/src/rpc.rs