All pages

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:

RuleError
delay_ms above 0delay must be positive
delay_ms at most MAX_VAULT_DELAY_MS, 31,536,000,000 ms or 365 daysdelay is longer than 365 days
recovery, if present, passes AuthPolicy::validateSee 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 },
}
TagVariantJSON keyQueued byAt execution
0TransfertransferPayload::TransferDebits the vault, credits to
1SetAuthset_authPayload::SetAuthReplaces auth
2SetVaultset_vaultPayload::SetVaultReplaces vault
3CallcallPayload::CallRuns 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:

  1. If the account already has MAX_PENDING_ACTIONS (16) entries, fail with TxError::TooManyPending.
  2. Set unlock_at_ms to the block's timestamp plus delay_ms. On overflow, fail with TxError::Overflow.
  3. Take next_pending_id as the id and raise it by 1.
  4. 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.

PayloadOwner, plain accountOwner, vaultRecovery
TransferImmediateQueuedNotAllowedForRecovery
SetAuthImmediateQueuedImmediate
SetVaultImmediateQueuedNotAllowedForRecovery
CallImmediateQueuedNotAllowedForRecovery
CancelPendingImmediateImmediateImmediate
ExecutePendingImmediateImmediateNotAllowedForRecovery
SetValidators, DeployNotGovernanceNotGovernanceNotGovernance

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.

ActionChecked at queue time
Transferamount above 0, and the asset's balance covers amount after the fee
SetAuthauth passes validate
SetVaultThe new policy, if any, passes validate
CallSize 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.

  1. Load account. If it has no record, fail with PendingNotFound.
  2. Find id in its queue, or fail with PendingNotFound.
  3. If now < unlock_at_ms, fail with TxError::PendingLocked { unlock_at_ms }.
  4. Remove the entry.
  5. Perform the action. SetAuth and SetVault write the stored value without validating it again.
ActionIf it can't complete
TransferInsufficientBalance or Overflow. The whole transaction is invalid, so the entry stays and can be tried again or cancelled.
Call, block gas exhaustedBlockGasFull. The transaction is invalid and the entry stays.
Call, contract failsThe 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.

PayloadEffect 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:

  • sender is the vault's address, and the vault's own nonce applies.
  • Signatures are checked against vault.recovery, not auth.
  • reveal must be absent. A vault's owner policy is always revealed already.
  • role is 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 transactionSize in bytesRequired fee at a rate of 10000000000
CancelPending, 1-of-1 SLH-DSA16341163410000000000
SetAuth to one ML-DSA-65 key, 1-of-1 SLH-DSA18295182950000000000

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 onceLimit
Queue actions16 at a time, each waiting the full delay
Cancel any pending action, including the real owner'sOne fee each
Execute unlocked actionsAnyone can
Burn native tokens as feesThe cap per transaction, see below
The thief can't, before the delay endsBecause
Move native tokensTransfer queues
Move what the vault holds in contractsCall queues
Lock out the owner keySetAuth queues
Remove the vault or the recovery policySetVault queues

Responding

  1. Read pending from the account. Every action the thief queued is listed there with its unlock_at_ms.
  2. Send SetAuth with a new owner policy, signed by recovery. It is immediate, and every later transaction signed by the stolen key fails with TxError::Unauthorized.
  3. Send one CancelPending signed by recovery for each action, before its unlock_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 queueWhat happened
The thief's TransferIt executed and the funds moved, after the owner had been rotated
The thief's SetAuthIt 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 minimumBurned
4 transfers queued and 4 cancelled, signed by one ML-DSA-65 key2206720000000000
1 Call queued with a gas limit of 5,000,000, which is not refunded when cancelled25283040000000000

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.

StageWhat happens
QueuingThe 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.
ExecutingThe 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.
RefundNone. Gas was paid when it was queued, so nothing comes back to anyone.
ReceiptThe 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:

StateEventSigned byNext state
PlainSetVault with a policyOwnerVault, at once
PlainTransfer, SetAuth, Call, CancelPending, ExecutePendingOwnerPlain, applied at once
PlainAny payloadRecoveryRefused with NoRecoveryPolicy
VaultTransfer, SetAuth, SetVault, CallOwnerVault, entry added
VaultSetAuthRecoveryVault, auth replaced at once
VaultCancelPendingOwner or recoveryVault, entry removed
VaultExecutePending of SetAuthAnyoneVault, auth replaced
VaultExecutePending of SetVault with a policyAnyoneVault, vault replaced
VaultExecutePending of SetVault(None)AnyonePlain, other entries remain
Plain with entriesExecutePendingAnyonePlain, entry runs
Plain with entriesCancelPendingOwnerPlain, entry removed

For a pending action:

StateEventConditionNext state
NoneOwner queuesFewer than 16 entriesLocked
LockedBlock time reaches unlock_at_msUnlocked
LockedCancelPendingRemoved
LockedExecutePendingLocked, transaction refused
UnlockedCancelPendingRemoved
UnlockedExecutePending of SetAuth or SetVaultRemoved, applied
UnlockedExecutePending of TransferBalance covers itRemoved, funds moved
UnlockedExecutePending of TransferBalance too lowUnlocked, transaction refused
UnlockedExecutePending of CallGas fits the blockRemoved, outcome in the receipt
UnlockedExecutePending of CallGas doesn't fitUnlocked, transaction refused

Timing

  • now is the block's timestamp_ms, never a node's own clock.
  • unlock_at_ms is the timestamp of the block that included the queuing transaction, plus delay_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 with PendingLocked and one at 61002 succeeded.
  • delay_ms is read when queuing. A later SetVault does 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 ExecutePending is 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

ActionCommand
Createqubits vault create --delay-secs 86400 --recovery-key recovery.json --key owner.json
Inspectqubits account qb1...
Cancel as ownerqubits vault cancel --id 0 --key owner.json
Cancel as recoveryqubits vault cancel --id 0 --recovery --key recovery.json --account qb1...
Executequbits vault execute --vault qb1... --id 0 --key relayer.json
Replace the owner as recoveryqubits key rotate --new-key new.json --recovery --key recovery.json --account qb1...
Removequbits 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.rs
  • chain/crates/qubits-types/src/lib.rs
  • chain/crates/qubits-execution/src/lib.rs
  • chain/crates/qubits-execution/tests/execution.rs
  • chain/crates/qubits-execution/tests/contracts.rs
  • chain/crates/qubits-types/tests/types.rs
  • chain/crates/qubits-node/src/app.rs
  • chain/crates/qubits-node/src/lib.rs
  • chain/crates/qubits-cli/src/main.rs