Vaults and recovery
This page specifies the vault rules: what VaultPolicy holds, how the delay queue works, what is delayed and what is not, how actions are cancelled and executed, what the recovery role can do, and what a thief with the owner key can and cannot achieve. It is for auditors checking the protocol's central promise and for anyone setting up a vault. The transaction format is in Transactions and fees, and policies are in Accounts and addresses.
Model
A vault is an ordinary account with its vault field set. While it is set, the owner's outgoing actions do not happen when signed. They go into a queue on the account and wait out a fixed delay, after which anyone may execute them. During the delay, a separate recovery policy can cancel them and replace the owner at once. The chain never has to know a key was stolen: the delay gives the real owner time to notice, and recovery gives them a way to act.
block time T T + delay_ms
| |
v v
owner signs ---- locked ------ unlocked ---- anyone sends ExecutePending
| | |
fee and nonce | |
charged now +--------------+
owner or recovery may CancelPending
The design doc's recommended setup is an ML-DSA owner key for daily use and an offline SLH-DSA recovery key.
VaultPolicy
pub struct VaultPolicy {
pub delay_ms: u64,
pub recovery: Option<AuthPolicy>,
}
Account::vault holds it. VaultPolicy::validate applies whenever one is set:
| Rule | Error |
|---|---|
delay_ms above 0 | delay must be positive |
delay_ms at most MAX_VAULT_DELAY_MS, 31,536,000,000 ms or 365 days | delay is longer than 365 days |
recovery, if present, passes AuthPolicy::validate | See Validation |
Execution reports these as TxError::InvalidPolicy. A recovery policy has the same form as an owner policy, so it can be M-of-N with mixed schemes. It can also be absent, which gives a delay with no recovery. What can go wrong covers what that loses.
An account becomes a vault with Payload::SetVault { vault: Some(policy) }. On a plain account this takes effect in the same transaction, like any owner action there. As a first transaction with a 1-of-1 ML-DSA-65 owner and a 1-of-1 SLH-DSA-SHA2-192s recovery key it is 5448 bytes.
The delay queue
pub struct PendingAction {
pub id: u64,
pub unlock_at_ms: u64,
pub action: DelayedAction,
}
pub enum DelayedAction {
Transfer { to: Address, asset: AssetId, amount: Amount },
SetAuth(AuthPolicy),
SetVault(Option<VaultPolicy>),
Call { contract: Address, function: String, args: Vec<u8>, amount: Amount, gas_limit: u64 },
}
| Tag | Variant | JSON key | Queued by | At execution |
|---|---|---|---|---|
| 0 | Transfer | transfer | Payload::Transfer | Debits the vault, credits to |
| 1 | SetAuth | set_auth | Payload::SetAuth | Replaces auth |
| 2 | SetVault | set_vault | Payload::SetVault | Replaces vault |
| 3 | Call | call | Payload::Call | Runs the call as the vault |
The queue is Account::pending, stored in the account in queue order, so it is covered by the state root and by account proofs. Its borsh form is a Vec of PendingAction, and a pending native transfer is 69 bytes.
Queuing runs these steps:
- If the account already has
MAX_PENDING_ACTIONS(16) entries, fail withTxError::TooManyPending. - Set
unlock_at_msto the block's timestamp plusdelay_ms. On overflow, fail withTxError::Overflow. - Take
next_pending_idas the id and raise it by 1. - Append the entry.
Ids start at 0, count per account and are never reused, so a cancelled id stays gone. The test pending_queue_is_bounded cancels id 3 from a full queue and sees the next entry get id 16.
What is delayed
The delay applies when the role is Owner and the account has vault set. Fee and nonce are charged at once in every case, because the transaction itself is applied. It is the action inside that waits.
| Payload | Owner, plain account | Owner, vault | Recovery |
|---|---|---|---|
Transfer | Immediate | Queued | NotAllowedForRecovery |
SetAuth | Immediate | Queued | Immediate |
SetVault | Immediate | Queued | NotAllowedForRecovery |
Call | Immediate | Queued | NotAllowedForRecovery |
CancelPending | Immediate | Immediate | Immediate |
ExecutePending | Immediate | Immediate | NotAllowedForRecovery |
SetValidators, Deploy | NotGovernance | NotGovernance | NotGovernance |
A recovery transaction on an account with no recovery policy fails earlier, with NoRecoveryPolicy.
The fee is the one amount that escapes the delay, which is why it has a cap. As a protocol rule, anything new that moves value from an account must go through the queue when the account is a vault.
Checks when queuing
An action is checked when it is queued, so a bad one costs nothing and takes no slot.
| Action | Checked at queue time |
|---|---|
Transfer | amount above 0, and the asset's balance covers amount after the fee |
SetAuth | auth passes validate |
SetVault | The new policy, if any, passes validate |
Call | Size and gas limits, function is not init, the contract exists, native balance covers amount after the fee |
Funds are not set aside. Two transfers of half the balance can both queue, and the test delayed_transfer_rechecks_balance_on_execution shows the second then failing at execution. A transfer of the entire native balance can't queue at all, since the fee has to come out of it first.
Cancelling
Payload::CancelPending { id } removes an entry from the sender's own queue. It can be signed by the owner or by recovery, and it is never delayed. A wrong id fails with TxError::PendingNotFound. Nothing queued is refunded, and that includes the gas paid for a queued Call.
Cancelling works on any account with entries, whether or not it is still a vault, and it works on locked and unlocked entries alike.
Executing
Payload::ExecutePending { account, id } runs a queued action of account. The sender may be anyone with a funded account, and is usually not the vault: the sender pays its own fee and uses its own nonce. The design doc's rule is that anyone may do this once the delay is over. Only the recovery role and the governance account are refused.
- Load
account. If it has no record, fail withPendingNotFound. - Find
idin its queue, or fail withPendingNotFound. - If
now < unlock_at_ms, fail withTxError::PendingLocked { unlock_at_ms }. - Remove the entry.
- Perform the action.
SetAuthandSetVaultwrite the stored value without validating it again.
| Action | If it can't complete |
|---|---|
Transfer | InsufficientBalance or Overflow. The whole transaction is invalid, so the entry stays and can be tried again or cancelled. |
Call, block gas exhausted | BlockGasFull. The transaction is invalid and the entry stays. |
Call, contract fails | The transaction is valid. The entry is gone, and the failure is in the receipt. |
Unlocked entries can run in any order: the test owner_policy_changes_on_a_vault_are_delayed executes id 1 before id 0.
A pending action does not expire. Nothing makes anyone execute it, and nothing removes it but execution or cancellation, so an unlocked entry stays executable by anyone for as long as it sits in the queue. An owner who changes their mind must cancel.
Changing or removing a vault
SetVault from the owner of a vault queues like anything else, so a vault can't be weakened quickly.
| Payload | Effect once executed |
|---|---|
SetVault { vault: None } | The account is plain again, and recovery is gone with it |
SetVault { vault: Some(policy) } | The new delay and recovery apply from then on |
Entries already in the queue keep their unlock_at_ms. In a run where a vault with a 60-second delay queued a change to 600 seconds and then a transfer, the transfer's unlock time stayed 60 seconds after queuing, even once the change had executed.
Entries also survive the end of the vault. In another run the vault was removed with a transfer still queued. A later transfer from the now plain account went through at once, and the old one could still be executed. The owner can still cancel such an entry, but recovery can't, because NoRecoveryPolicy applies.
Recovery
A recovery transaction is sent from the vault with Role::Recovery:
senderis the vault's address, and the vault's own nonce applies.- Signatures are checked against
vault.recovery, notauth. revealmust be absent. A vault's owner policy is always revealed already.roleis signed, so a signature made for one role does not pass for the other.- The vault pays the fee, from its native balance.
The design doc states the power of the role: it can cancel pending actions and change the owner policy at once, and nothing else. It can't move funds, execute anything, change the vault, or replace itself. The test recovery_powers_are_limited checks the refusals. Only the owner can change the recovery policy, through a delayed SetVault.
SetAuth signed by recovery bypasses the queue. The vault stays a vault and the new owner is subject to the same delay, which the test stolen_owner_key_is_stopped_by_recovery checks at its end.
| Recovery transaction | Size in bytes | Required fee at a rate of 10000000000 |
|---|---|---|
CancelPending, 1-of-1 SLH-DSA | 16341 | 163410000000000 |
SetAuth to one ML-DSA-65 key, 1-of-1 SLH-DSA | 18295 | 182950000000000 |
A stolen owner key
A thief with the owner key has every power the owner has, and the delay holds those powers back.
| The thief can, at once | Limit |
|---|---|
| Queue actions | 16 at a time, each waiting the full delay |
| Cancel any pending action, including the real owner's | One fee each |
| Execute unlocked actions | Anyone can |
| Burn native tokens as fees | The cap per transaction, see below |
| The thief can't, before the delay ends | Because |
|---|---|
| Move native tokens | Transfer queues |
| Move what the vault holds in contracts | Call queues |
| Lock out the owner key | SetAuth queues |
| Remove the vault or the recovery policy | SetVault queues |
Responding
- Read
pendingfrom the account. Every action the thief queued is listed there with itsunlock_at_ms. - Send
SetAuthwith a new owner policy, signed by recovery. It is immediate, and every later transaction signed by the stolen key fails withTxError::Unauthorized. - Send one
CancelPendingsigned by recovery for each action, before itsunlock_at_ms.
Rotating first stops the thief queuing more. The test does it the other way round, and both orders are valid as long as every action is cancelled in time.
What can go wrong
These all came out of running the scenarios on the real execution rules.
Recovery's SetAuth does not touch the queue. Each queued action must be cancelled on its own. Left in place, after the delay:
| Left in the queue | What happened |
|---|---|
The thief's Transfer | It executed and the funds moved, after the owner had been rotated |
The thief's SetAuth | It executed and replaced the new owner, undoing the rotation |
The thief's SetVault(None) | It executed, the vault ended, and later recovery attempts failed with NoRecoveryPolicy |
The vault has to pay. Recovery's fee comes from the vault's native balance, and there is no field for anyone else to pay it. A vault with no native token can't act: in a run with a balance of 0, recovery's CancelPending failed with InsufficientBalance. A vault that holds only contract tokens needs some native token too.
The nonce moves. Owner and recovery share a nonce, so a recovery transaction signed for nonce N fails with TxError::BadNonce if the thief lands anything first. A signature can't be prepared far in advance, and an offline signer has to work from the current nonce.
The fee cap is per transaction. It stops one transaction from burning the balance, as the test a_stolen_owner_key_cannot_burn_a_vault_through_fees shows, and it does not stop many. Each transaction may burn 8 times its minimum. At a rate of 10000000000:
| Thief's transactions at 8 times the minimum | Burned |
|---|---|
| 4 transfers queued and 4 cancelled, signed by one ML-DSA-65 key | 2206720000000000 |
1 Call queued with a gas limit of 5,000,000, which is not refunded when cancelled | 25283040000000000 |
No rule limits how many transactions an account sends. Block capacity and the mempool's 64 transactions per sender limit the rate, not the total. Rotating the owner ends it.
The queue can be filled. With 16 entries, the owner's next Transfer or SetAuth fails with TooManyPending. Recovery's SetAuth and every CancelPending need no slot.
A vault without recovery only delays. With recovery: None, the thief and the owner hold the same powers. Each can cancel the other's actions indefinitely, and neither can lock out the other, because SetAuth queues and can be cancelled.
A stolen recovery key is serious too. It can't move funds, but it can replace the owner at once, leaving the real owner's key useless. The thief's transfers then wait the delay as usual, and the real owner's old key can no longer cancel them.
If ML-DSA breaks
An SLH-DSA recovery key protects against theft of an ML-DSA owner key and against a break of ML-DSA, with a limit the design doc states. Consensus also rests on ML-DSA. If ML-DSA is fully broken, an attacker can forge validator signatures and censor the recovery transaction. A transaction with an invalid signature is still refused, because nodes re-execute every block, so funds can't be taken from an account whose owner is also SLH-DSA. The chain would have to restart with new consensus keys.
Contract calls from a vault
A contract call can move anything the vault holds in a contract, so it queues too. Without that, a stolen owner key could move tokens around the delay. The test a_stolen_vault_key_cannot_move_tokens_through_a_contract checks this.
| Stage | What happens |
|---|---|
| Queuing | The vault pays the full fee, including gas_limit. No gas is reserved in the block and nothing runs. The transaction's receipt is the default one. |
| Executing | The sender of ExecutePending pays only its own fee, computed with no gas. reserve_gas then counts gas_limit against the block, and the call runs with the vault as its caller and amount taken from the vault. |
| Refund | None. Gas was paid when it was queued, so nothing comes back to anyone. |
| Receipt | The outcome is on the ExecutePending transaction |
In a run of token.transfer from a vault, with a gas limit of 2,000,000 and a rate of 10000000000:
queue vault pays fee 1285380000000000
pending id 0, unlock_at_ms 61003
early ExecutePending PendingLocked { unlock_at_ms: 61003 }
execute relayer pays 54200000000000, its first transaction
vault pays 0
outcome Done, gas used 190562
Unlike a transfer, a call that can't complete still counts as executed. The entry is removed, and there is no retry. If a queued call's amount is more than the vault holds at execution, the outcome is Reverted with the reason insufficient balance, with 0 gas used. The fee paid for that gas is gone.
State transitions
For an account:
| State | Event | Signed by | Next state |
|---|---|---|---|
| Plain | SetVault with a policy | Owner | Vault, at once |
| Plain | Transfer, SetAuth, Call, CancelPending, ExecutePending | Owner | Plain, applied at once |
| Plain | Any payload | Recovery | Refused with NoRecoveryPolicy |
| Vault | Transfer, SetAuth, SetVault, Call | Owner | Vault, entry added |
| Vault | SetAuth | Recovery | Vault, auth replaced at once |
| Vault | CancelPending | Owner or recovery | Vault, entry removed |
| Vault | ExecutePending of SetAuth | Anyone | Vault, auth replaced |
| Vault | ExecutePending of SetVault with a policy | Anyone | Vault, vault replaced |
| Vault | ExecutePending of SetVault(None) | Anyone | Plain, other entries remain |
| Plain with entries | ExecutePending | Anyone | Plain, entry runs |
| Plain with entries | CancelPending | Owner | Plain, entry removed |
For a pending action:
| State | Event | Condition | Next state |
|---|---|---|---|
| None | Owner queues | Fewer than 16 entries | Locked |
| Locked | Block time reaches unlock_at_ms | Unlocked | |
| Locked | CancelPending | Removed | |
| Locked | ExecutePending | Locked, transaction refused | |
| Unlocked | CancelPending | Removed | |
| Unlocked | ExecutePending of SetAuth or SetVault | Removed, applied | |
| Unlocked | ExecutePending of Transfer | Balance covers it | Removed, funds moved |
| Unlocked | ExecutePending of Transfer | Balance too low | Unlocked, transaction refused |
| Unlocked | ExecutePending of Call | Gas fits the block | Removed, outcome in the receipt |
| Unlocked | ExecutePending of Call | Gas doesn't fit | Unlocked, transaction refused |
Timing
nowis the block'stimestamp_ms, never a node's own clock.unlock_at_msis the timestamp of the block that included the queuing transaction, plusdelay_ms.- The bound is inclusive. An action executes in a block whose timestamp is at or above
unlock_at_ms. In a run with a 60,000 ms delay queued at block time 1002, a block at 61001 failed withPendingLockedand one at 61002 succeeded. delay_msis read when queuing. A laterSetVaultdoes not move what is already queued.- Block time is set by the proposer. Validators refuse a timestamp more than
MAX_CLOCK_DRIFT_MS, 10,000 ms, ahead of their own clock, so block time can run up to 10 seconds ahead. Set a delay far above that. - A chain with no work produces no blocks, and block time stands still. Admission uses the larger of the local clock and the head's timestamp plus 1, so an
ExecutePendingis accepted once the time has passed by the clock, and the block carrying it has a fresh timestamp.
Reading a vault
GET /v1/accounts/{address} returns the policy and the queue. qubits account qb1... prints the same. This vault has a 24-hour delay, with a transfer and a key change queued. Keys are shortened.
{
"address": "qb1dx6lxtksedtyjjqn547vzsue6pald63x4nk0g77jzs5y3rz8f7aq2d9vg6",
"exists": true,
"nonce": 3,
"auth": {
"threshold": 1,
"keys": ["01c4e999a2033fb0..."]
},
"vault": {
"delay_ms": 86400000,
"recovery": {
"threshold": 1,
"keys": ["0232323232323232..."]
}
},
"balances": [
{ "asset": 0, "amount": "999999857020000000000" }
],
"pending": [
{
"id": 0,
"unlock_at_ms": 86401002,
"action": {
"transfer": {
"to": "qb142khwhdlmdyvcwl3u36qcc9l7u8q82ftzem0dv5fakxjd94yt84qlnehmw",
"asset": 0,
"amount": "1000"
}
}
},
{
"id": 1,
"unlock_at_ms": 86401003,
"action": {
"set_auth": {
"threshold": 1,
"keys": ["019c24571fe0b101..."]
}
}
}
]
}
A monitor for a vault only needs to watch pending. /v1/proofs/accounts/{address} proves the same account against a certified state root, see HTTP API.
CLI
| Action | Command |
|---|---|
| Create | qubits vault create --delay-secs 86400 --recovery-key recovery.json --key owner.json |
| Inspect | qubits account qb1... |
| Cancel as owner | qubits vault cancel --id 0 --key owner.json |
| Cancel as recovery | qubits vault cancel --id 0 --recovery --key recovery.json --account qb1... |
| Execute | qubits vault execute --vault qb1... --id 0 --key relayer.json |
| Replace the owner as recovery | qubits key rotate --new-key new.json --recovery --key recovery.json --account qb1... |
| Remove | qubits vault remove --key owner.json |
--recovery-key reads only the public key of that file, so it needs no password, and it always builds a 1-of-1 policy. An M-of-N recovery policy is valid on the chain, but the CLI can't create one. --recovery needs --account, because a recovery key doesn't hash to the vault's address. Commands that send a transaction also take --unsigned-out, to prepare a transaction file for a multisig.
The browser wallet has no vault screens. For an account that happens to be a vault, it only says that a transaction leaves after the delay, see Browser wallet.
Source
chain/crates/qubits-types/src/account.rschain/crates/qubits-types/src/lib.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-execution/tests/execution.rschain/crates/qubits-execution/tests/contracts.rschain/crates/qubits-types/tests/types.rschain/crates/qubits-node/src/app.rschain/crates/qubits-node/src/lib.rschain/crates/qubits-cli/src/main.rs