BRC-249 Wallet

Vault Account Holding Across Devices

A BRC-208 vault master opens many accounts, and the same backup can be restored on several devices. Two installs that open one account are two spenders of one key. Each picks coins from its own copy of the account's state, and they double-spend each other. This BRC keeps every account on exactly one install at a time, without a network request on the spend p

Brandon Cryderman / HandCash (brandongcryderman@gmail.com) Status: Draft (proposed number, not a BSVA assignment)

How it connects

Relationships

Authoritative content mirror

Full upstream text

View source ↗
Rendered from wallet/0249.md at the indexed upstream commit

Abstract

A BRC-208 vault master opens many accounts, and the same backup can be restored on several devices. Two installs that open one account are two spenders of one key. Each picks coins from its own copy of the account's state, and they double-spend each other. This BRC keeps every account on exactly one install at a time, without a network request on the spend path.

Each account is held by one install. Holding is local state, and a wallet refuses to sign for an account it does not hold. Other installs learn who holds an account from a small holder record signed by the account's own root and stored beside the account's history backup. The record changes only when an install reserves a new account, takes an account, or releases one. Its sequence number only grows, so a host can delay a change but cannot forge or roll one back.

The user never moves an account by hand. Every install may show the accounts it does not hold, read-only. When the user acts on one, the install asks the holder to release it. A holder that is online releases at once. A holder that does not answer is treated as offline, and the account is taken. An install checks its holding again after every gap (start, resume, reconnect) before it spends, so an offline holder learns of the move before it can double-spend.

Motivation

BRC-208 lets one backup recover every account, but says nothing about two devices using one vault at the same time. That leaves three problems.

  1. Shared accounts double-spend. Two installs that both open account 0 each build transactions from their own view of its outputs. One install's spend is invisible to the other until a chain refresh, and outputs that only one install's local state knows about (for example, BRC-29 change) never become visible to the other. A spend lease checked before each payment would prevent the conflict, but it adds a round trip to every payment and fails when the host is unreachable.
  2. Allocation races. BRC-208 allocates the next account as one more than the highest index ever allocated. Two devices that each create an account at the same time pick the same index, which is the same key.
  3. Moving between devices. A user who wants their spending wallet on a phone and their savings on a laptop needs to hand an account from one device to the other, with its history, and have the first device stop.

Holding accounts per device solves all three. Devices that share a vault use different accounts, so they never share a coin. Reserving an index is a conditional create, so two devices cannot reserve the same one. Moving an account is a release on one device followed by a take on the other, started by the device that wants it.

Terminology

Term Meaning
Install One wallet installation with its own local state and a random device id.
Held here This install may open the account, build its state and sign for it.
Held elsewhere Another install holds the account, or released it and nobody has taken it yet.
Holder record The signed JSON object below, one per account.
History host The host that stores the account's history backup (for example, a BRC-38 export of its local state encrypted under BRC-39).
Fresh Local holding is fresh while the install has run without interruption and kept its network connection since its last successful check of that account.
Gap Anything that ends freshness: the install starts, resumes after the operating system suspended it, or regains a lost connection.
Vault link A draft BRC-246 (#248) session between two installs of one vault, on the vault identity (account 0), with its signals in box vault-link on that identity instead of webrtpay_messages.
Holding channel How installs reach each other about an account: the vault link to the holding install when one is open, otherwise the account's BRC-33 messagebox, box account-holding.

Specification

Holder record

{
  "v": 1,
  "identityKey": "<account identity key, compressed hex, lower case>",
  "deviceId": "<install's device id, 1–128 characters>",
  "state": "held" | "released",
  "seq": <integer ≥ 1>,
  "at": <milliseconds since the Unix epoch>,
  "signature": "<hex>"
}

signature is a 64-byte compact ECDSA signature (r ‖ s, hex) by the account root over the SHA-256 of the UTF-8 preimage:

account-holder
v1
<identityKey>
<deviceId>
<state>
<seq>
<at>

Lines are joined with a single \n and there is no trailing newline. Nonces are deterministic per RFC 6979. at is informational only; ordering comes from seq.

A reader MUST reject a record whose identityKey is not the account it asked for, whose seq is not a positive safe integer, or whose signature does not verify. A rejected record counts as an unreachable host, not as an absent record.

The device id SHOULD be random and SHOULD NOT be derived from any key or hardware identifier. It is visible to the history host.

Storage

The holder record lives on the account's history host, beside its history backup, at a path the host scopes to the account identity key (HandCash uses /v1/wallets/<identityKey>/holder.json). The host:

  1. MUST accept reads and writes only from requests authenticated as that identity key (for example, a request signed by the account root under BRC-103 or BRC-104).
  2. MUST return an ETag with every read.
  3. MUST honour If-None-Match: * on write (create only when absent) and If-Match: <etag> (replace only that version), and MUST answer 412 when the condition fails.
  4. SHOULD store records only when they are signed records of this form, and MAY cap their size (4 KB is ample).

A host that does not support holder records answers 404 or 405 on the path. A wallet MUST NOT trust the host for anything beyond availability. The signature and seq carry the meaning.

Local holding

For every account it lists, a wallet keeps held here or held elsewhere, plus the highest seq it has acted on (0 when no record has been seen). That state changes only by the rules below.

Spending

A wallet:

  1. MUST NOT sign for, spend from, or build spendable state for an account it does not hold. A BRC-100 request that needs such an account MUST either move it here first (see Moving on demand) or be refused.
  2. MAY show an account it does not hold read-only, from that account's history backup. A read-only view MUST NOT sign, reserve outputs, internalize payments, or acknowledge the account's messagebox messages. Those belong to the holder.
  3. MUST decide holding from local state while that state is fresh. Before the first spend after a gap, it MUST check the account. Outside that one check per gap, it MUST NOT make a network request to authorize a payment.
  4. MUST stop spending an account the moment a check finds it held elsewhere, and SHOULD leave it for an account it holds.

Checking

A wallet MUST check an account before its first spend after a gap. It SHOULD also check the accounts it lists after unlock, before opening an account, and periodically for the active account (HandCash checks every five minutes). For each account it reads the record and:

  1. Host unreachable or unsupported: keeps its local holding.
  2. No record: if it holds the account, publishes held with seq one more than its local seq, create-only. Otherwise keeps its local holding.
  3. Record older than local (record.seq < local.seq): its own newer write is missing. If it holds the account it republishes held with seq = local.seq + 1, conditioned on the record's ETag; otherwise it keeps its local holding.
  4. Otherwise: adopts the record. held with this install's device id means held here; anything else means held elsewhere.

A conditional write that fails with 412 means another install wrote first. The wallet re-reads and decides again.

A successful check makes local holding fresh again. When the host cannot be reached after a gap, the wallet keeps its local holding and spends. Security considerations describes the window this leaves.

When a check finds an account held elsewhere that this install held, it hands over what it recorded since its last upload.

Reserving a new account

This replaces BRC-208's allocation rule for wallets that implement this BRC. Starting from the next index this install would allocate:

  1. Read the candidate's record. If one exists, the index is in use: list it as held elsewhere (as the record says) and try the next index.
  2. Check the candidate for use as BRC-208 discovery does (history backup, waiting messages). If used, list it as held elsewhere and try the next index. If any source cannot answer, stop.
  3. Write held, seq 1, create-only. On success the account is held here. On 412, try the same index again from step 1.
  4. If the history host cannot be reached, stop. The wallet MUST NOT create an account it could not reserve.

A history host without holder records cannot arbitrate. In that case a wallet MAY allocate locally only when every account it lists is held here. Otherwise it MUST refuse.

Releasing

An install releases an account when it accepts a release request, or when it gives the account up for another reason. The holding install:

  1. Refuses new spends on the account and waits for spends in progress to finish.
  2. Uploads the account's history backup and any other state another install needs to spend it (for example, recipes for outputs whose keys need more than the account root). If any of this fails, it stops and the account stays held here.
  3. Writes released with seq one more than the higher of its local seq and the record's, conditioned on the record's ETag (create-only when absent). If the record names another holder at an equal or higher seq, it adopts that record instead and stops.
  4. On success, marks the account held elsewhere. If anything changed during the upload, it uploads again.

A wallet that uploads an account's history only while the account is open MAY release an account that is not open without step 2. It stops all work on the account, writes released as in step 3, then hands over what it recorded since its last upload. The new holder may spend before the handover arrives. A transaction it builds on an output the handover shows spent conflicts with one already on the network, and miners refuse it.

Taking

To hold an account another install held, a wallet reads its record:

  1. released, or held by this install: writes held with seq + 1 conditioned on the ETag (or adopts its own record).
  2. held by another install, or no record on an account in use: the wallet MUST first send a release request and wait as Moving on demand describes. Only when no install accepts in time does it write held with seq + 1, conditioned on the ETag, or create-only when absent. This is a forced take.
  3. Host unreachable or unsupported: the take fails.

No take asks the user. A forced take is safe without confirmation: the install it displaces did not answer, and that install checks before its next spend after the gap that kept it from answering.

After taking an account, the wallet MUST bring the history backup into local state before it spends. That backup was uploaded by the releasing install and is newer than any copy this install kept from before. The merge MUST NOT be deferred for a waiting payment.

Moving on demand

When the user acts on an account held elsewhere (opens it for spending, pays from it, or approves a BRC-100 request that needs it), the wallet moves it here. Viewing it read-only is not acting on it.

  1. Request. The wallet reads the record and sends a release request on the holding channel:

    { "v": 1, "type": "release-request", "identityKey": "<account>", "deviceId": "<requesting install>", "seq": <record seq read>, "nonce": "<32 hex>", "at": <ms>, "signature": "<hex>" }
    
  2. Accept. An install that holds the account and receives a request whose identityKey is that account, whose seq equals its local seq, whose at is within ten minutes and whose signature verifies, MUST accept it without asking the user. It answers on the holding channel at once:

    { "v": 1, "type": "release-accept", "identityKey": "<account>", "deviceId": "<holding install>", "nonce": "<request nonce>", "at": <ms>, "signature": "<hex>" }
    

    and then releases. It ignores a request with a lower seq, which a later move has overtaken.

  3. Wait. The requesting wallet takes as soon as the record reads released. If no accept arrives within the answer window, the holder is offline and the wallet forces the take. After an accept, it waits up to the release window for the record to change before it forces the take. HandCash uses 10 seconds and 2 minutes.

Both messages are signed by the account root, as compact ECDSA over the SHA-256 of their fields joined by \n after a first line account-release-request or account-release-accept and a second line v1, in the order shown. Every install of the vault can sign them. The messagebox host cannot. Each install acknowledges (deletes) on the messagebox only the messages it consumed: the holder its requests, the requester the accept carrying its nonce. It ignores its own messages.

The vault link

Moves SHOULD go device to device. An install that is unlocked SHOULD keep a vault link to every other install it knows of (the device ids its holder records name) and answer offers from other installs of the vault. It MUST NOT answer an offer from any other identity on box vault-link.

  1. The requesting wallet dials the install the record names. When a link opens within a short wait (HandCash: 8 seconds), it sends the release request on it. It uses the box only when no link opens or the link does not acknowledge the frame.
  2. The holder MUST accept a request that arrives on a link only when its deviceId is the device the link authenticated. It answers on the same link, or through the box when the link does not acknowledge.
  3. The requester watches both the link and the box for the accept.

The link is a transport, not an authority. A request or accept is valid because the account root signed it, whichever way it arrived, and the take is still ordered by the holder record. The box stays the meeting point for offers and answers, and the path for an install that cannot hold a link (for example, behind a NAT a data channel cannot cross without a relay).

Handing over

An install that finds an account it held now held elsewhere may have recorded changes since its last successful upload: transactions it built, payments it internalized, messages it acknowledged, deliveries it completed. It:

  1. MUST NOT upload a history backup for the account. The backup on the host now belongs to the new holder and is newer.
  2. MUST NOT broadcast transactions it signed for the account but never broadcast. They are void.
  3. MUST send the changes since its last upload to the account in box account-holding, not on a vault link, so they wait for a new holder that is offline. They go as a BRC-78 message to the account's own identity key containing a BRC-38 export of them, in type handover. If they exceed the channel's size limit it sends them in parts.

The holder merges a handover into local state. It internalizes the transactions that neither the chain nor its own state contradicts, and drops the rest. It MUST NOT replace its state with the handover. The handing-over install then keeps the account read-only or drops it.

What the account owes

Some of an account's work is owed to other people, not to the chain. A BRC-29 payee cannot find a payment until the payer's remittance reaches them, because the derivation prefix and suffix are random. A delivery like this belongs to the account, not to the install that started it:

  1. A wallet MUST record each delivery it owes in the account's local state, in the same step that records the payment, and mark it done only when a channel accepts it. A queue kept outside that state is a work list, not the record.
  2. The holder delivers what the account owes. After taking an account, merging a handover or restoring a backup, the wallet sends every delivery still recorded as owed. An install that has given the account up stops sending.
  3. A delivery can arrive twice, for example when a channel accepted it but the mark was lost with the device. A receiver MUST treat a second remittance for the same output as the same payment.

Because the record is part of the account's history, the backup and the handover carry it like any other change, and a lost device loses nothing the account owed.

Restoring onto a new device

Restoring a backup onto a device asks how the new install relates to the old one:

  • Replace: the new install takes over. It marks every account it opens as held here and, on its first check, publishes held over whatever each record says (seq = max(record.seq, local.seq) + 1). The old install gives the accounts up at its next check and hands over what it recorded.
  • Keep both: the old install keeps its accounts. The new install MUST complete BRC-208 discovery first. If any source cannot answer, the restore fails. It then lists every discovered account as held elsewhere, checks them, and reserves a new account for itself.

A wallet SHOULD default to Replace, which matches a restore after a lost device.

Upgrading

Accounts in use before a wallet implements this BRC have no record. Each install that already held an account publishes it create-only on its first check. When two installs of one vault upgrade, the first to publish keeps the account; the other adopts the record and gives it up. A user who wants both devices to keep spending moves accounts until each install holds different ones.

Security considerations

  • The host cannot forge or roll back. Only the account root can sign a record or a release message, and readers ignore a record older than the one they last acted on. A hostile host can only withhold. A withheld take leaves the old install spending until its next successful check. A withheld release blocks a move without moving anything.
  • Every install that is online knows before a take. A live holder receives the request and releases itself, so the take that follows is ordinary. An install that could not answer was suspended or disconnected, and it checks before its first spend after that gap, so it finds the take before it can spend.
  • The window that remains. Two cases still let both installs spend for a while. A host that withholds the request (or the holder's accept) from a live holder turns a move into a forced take, and the holder keeps spending until its next periodic check. An install that comes back able to reach miners but not the history host keeps its local holding until the host answers. In both cases the conflicting transactions double-spend each other, so miners accept at most one of them. Both installs belong to the same vault owner, nothing is stolen, the losing transaction is reported as rejected, and the losing install hands over what it recorded.
  • Holding is not authority. Anyone who holds the vault master can derive and spend every account regardless of any record. Holding coordinates honest installs of one owner. It does not protect against a stolen backup.
  • Privacy. The history host learns the account identity keys it stores, which it already knew from the history backups, and the device ids that hold them, which reveals how many installs share a vault. Device ids SHOULD be random to avoid linking installs across vaults.
  • Fail closed, except for spending what you hold. Every path that cannot reach the host or verify a record keeps the more restrictive state: reserving refuses, a take fails, a release keeps the account held here, and an upgrading install that loses the create gives the account up. The one exception is the check after a gap. An install that cannot reach the host keeps spending what it holds, so payments never depend on the history host.
  • A read-only view is not a holder. It must not acknowledge the account's messagebox messages. Otherwise it would consume a payment notice that only the holder can internalize.

Rationale

  • Why not a spend lease? A lease checked before each payment puts a network round trip on every spend and stops payments whenever the host is unreachable. HandCash shipped one and removed it after it cost about 1.7 seconds per payment. Holding moves the coordination to rare events (a move, a gap), and payments stay local.
  • Why move on demand without asking? A move is safe when the holder knows about it first. A live holder learns from the request. An absent one learns at its check after the gap, before it spends. Asking the user adds nothing they can judge. They cannot see whether their other device has unsent payments, and the protocol already makes that device find out.
  • Why a check after every gap, not only a periodic one? A suspended phone does not answer a request, so it looks offline and the account is taken. If it then resumed and spent from its old local holding until the next periodic check, the take would double-spend. One read per gap prevents that without a read per payment.
  • Why per account and not per vault? Accounts have separate keys and separate coins, so two installs holding different accounts never compete for an output. Holding the whole vault on one device would make a phone and a laptop on one backup impossible.
  • Why the history host? A take has to be ordered after the releasing install's history upload. With the record beside that upload, under the same authentication, one host orders both. Each account's history is already scoped to its own identity key there.
  • Why a sequence number and not timestamps? Device clocks disagree, and a host could replay an old record with a plausible time. A counter that only grows, checked by each reader against what it last acted on, needs neither synchronized clocks nor a trusted host.

Non-goals

  • Two installs spending one account at the same time. That needs shared state for every output, not a holder.
  • Syncing an account's history continuously between devices. History moves with the account, at release and take, plus a handover from an install that missed the release.
  • Choosing which account a payment uses. Moving value between accounts is an ordinary payment.

Implementations

  • HandCash Desktop and Mobile hold BRC-208 accounts per install. A restore asks whether to replace the old device or keep both. BRC-CLOUD stores holder records with create-only and ETag-conditional writes beside each account's BRC-39 backup.
  • Moving is automatic. Choosing an account listed as "On another device" moves it here without a prompt. The menu shows only progress ("Connecting to your other device…", "Asking your other device…", "Other device offline · moving anyway…").
  • Moves go device to device. Each unlocked install keeps a vault link (WebRTC data channel, STUN only) to every install its holder records name, pinged every 4 minutes. The release request and its accept ride that link. Box account-holding carries them only when no link opens within 8 seconds or a frame is not acknowledged.
  • While unlocked, an install serves the vault link and the box of every account it holds every 5 seconds. That tick also keeps holding fresh: start, lock, a reconnect, or a pause between ticks longer than 15 seconds (suspension or throttling) is a gap, and the next spend checks the record once first.
  • A displaced install stops the account's monitor, voids the transactions it never broadcast, and sends the BRC-38 changes since its last upload, sealed to the account under BRC-78, in parts of up to 150,000 characters. The holder merges them (newer rows win) and acknowledges the parts only after the merge succeeds. An account held but not open is released the same way, because its backup is uploaded only while it is open.
  • A BRC-29 payment output records notice: pending in its custom instructions until a box or link accepts the remittance, then delivered. After every unlock, restore and handover merge, the holder re-sends each notice still pending for a broadcast payment; payments made before the field existed are never re-sent. The payee keeps one payment card per output, however often its notice arrives.
  • Accounts held elsewhere are listed but not yet shown read-only. Opening one moves it.

References

  • BRC-29: Simple Authenticated BSV P2PKH Payment Protocol
  • BRC-33: PeerServ Message Relay Interface
  • BRC-38: User Wallet Data Format
  • BRC-39: User Wallet Data Format Encryption Extension
  • BRC-78: Serialization Format for Portable Encrypted Messages
  • BRC-100: Unified, Vendor-Neutral, Unchanging, and Open BSV Blockchain Standard Wallet-to-Application Interface
  • BRC-103: Peer-to-Peer Mutual Authentication and Certificate Exchange Protocol
  • BRC-104: HTTP Transport for BRC-103 Mutual Authentication
  • BRC-208: Vault Sub-Accounts
  • Draft BRC-246 (#248): Session Upgrade: BRC-33 Messages over WebRTPay
  • RFC 6979: Deterministic Usage of DSA and ECDSA
  • RFC 9110 §13: HTTP Conditional Requests