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:
| Offset | Size | Field | Encoding |
|---|---|---|---|
| 0 | 1 | threshold | u8 |
| 1 | 4 | Number of keys | u32 little-endian |
| 5 | 4 | Length of the first key | u32 little-endian, 1 + raw key length |
| 9 | 1 | Scheme byte of the first key | 0x01 or 0x02 |
| 10 | n | Raw first key | 1952 or 48 bytes |
| ... | Further keys | Same 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.
| Policy | Encoded size |
|---|---|
| 1-of-1, ML-DSA-65 | 1962 bytes |
| 1-of-1, SLH-DSA-SHA2-192s | 58 bytes |
| 2-of-3, two ML-DSA-65 and one SLH-DSA-SHA2-192s | 3972 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:
- The string must be valid bech32m. A bech32 checksum, a typo or mixed case fails here.
- The prefix must be
qb. - 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
| Kind | Input to the hash | Context | Who can spend |
|---|---|---|---|
| Key or multisig account | borsh AuthPolicy | qubits.sh 2026-11 address | The account's current auth |
| Governance | Empty | qubits.sh 2026-11 validator governance | A quorum of the current validators |
| Contract | Deployer, then nonce as u64 little-endian | qubits.sh 2026-11 contract address | Nobody. 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);
| Field | Default | Meaning |
|---|---|---|
nonce | 0 | Number of transactions this account has sent |
balances | Empty | Balance per asset, zero entries removed |
auth | None | The owner policy. None until the account's first transaction. |
vault | None | The vault policy, if this account is a vault |
pending | Empty | Actions waiting out the vault delay, at most 16 |
next_pending_id | 0 | The 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
| Account | Encoded size |
|---|---|
| Default, empty | 26 bytes |
| One balance | 46 bytes |
| One balance, 1-of-1 ML-DSA-65 policy revealed | 2008 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 field | Type |
|---|---|
address | Text address |
exists | Boolean |
nonce | Number |
auth | Policy or null |
vault | Vault policy or null |
balances | List of { asset, amount }, with amount as a decimal string |
pending | List 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.
| Rule | Error message |
|---|---|
| At least one key | no keys |
At most MAX_AUTH_KEYS, which is 16 | too many keys |
threshold from 1 to the number of keys | threshold must be between 1 and the number of keys |
| No key twice | duplicate key |
These are Error::InvalidAuthPolicy, which execution reports as TxError::InvalidPolicy.
Checking signatures
AuthPolicy::check(msg, signatures) runs over SignedTransaction::signatures:
- If there are fewer signatures than
threshold, fail withnot enough signatures. - For each signature in order:
- Its
indexmust be above the previous one, otherwise fail withsignature indices must be strictly increasing. keys[index]must exist, otherwise fail withsignature index out of range.- The signature must verify under that key in
Domain::Transaction.
- Its
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.auth | reveal | Result |
|---|---|---|
| Present | Absent | The stored policy applies |
| Present | Present | TxError::RevealNotAllowed |
| Absent | Absent | TxError::RevealRequired |
| Absent | Present, hash is not the sender | TxError::RevealMismatch |
| Absent | Present, fails validate | TxError::InvalidPolicy |
| Absent | Present and valid | The 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
validaterefuses the reveal withTxError::InvalidPolicy. Nothing on the chain can tell before the first attempt, since only the hash is known.qubits policy newvalidates 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.
| Sender | When it takes effect |
|---|---|
| Owner of a plain account | In this transaction |
| Owner of a vault | After the vault delay, once someone executes it |
| Recovery of a vault | In 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:
- Fail with
BuildError::NoAccountif the account does not exist. - Choose the policy. For the owner role, use the account's
auth. With noauth, use the policy given by--policyif its hash is the address, and setrevealto it. Otherwise fail withBuildError::NotController. For the recovery role, use the vault's recovery policy, or fail withBuildError::NoRecoveryPolicy. - Set the nonce from the account and the fee to 0.
- Sort the policy's signature sizes, largest first, and take
thresholdof them. Build placeholder signatures of those sizes. - Encode that draft and set
feetorequired_feeof its size and the payload's gas limit. - Leave
signaturesempty.
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
thresholdsignatures.qubits tx signaccepts more,finishpasses, and the chain refuses the larger transaction withTxError::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.
BuildError | Message |
|---|---|
NoAccount | this account does not exist yet; it needs funds to pay fees |
NotController | this key does not control the account |
NoRecoveryPolicy | the account has no recovery policy |
KeyNotInPolicy(role) | this key is not part of the account's owner policy, or recovery |
NeedsMoreSigners | the 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 n | Faulty f | Threshold n - f |
|---|---|---|
| 1 | 0 | 1 |
| 4 | 1 | 3 |
| 7 | 2 | 5 |
| 10 | 3 | 7 |
| 22 | 7 | 15 |
| 34 | 11 | 23 |
| 50 | 16 | 34 |
The account signs and pays like any other, with one restriction: it may send only SetValidators and Deploy, and only it may send those.
| Payload | From governance | From any other account |
|---|---|---|
SetValidators, Deploy | Allowed | TxError::NotGovernance |
| Anything else | TxError::NotAllowedForGovernance | Allowed |
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.rschain/crates/qubits-types/src/account.rschain/crates/qubits-types/src/transaction.rschain/crates/qubits-types/src/client.rschain/crates/qubits-types/src/rpc.rschain/crates/qubits-types/tests/types.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-execution/tests/execution.rschain/crates/qubits-cli/src/main.rschain/crates/qubits-node/src/rpc.rs