Skip to content

Import existing vaults as read-only wallets and attach their signers - #969

Open
Rigidity wants to merge 16 commits into
rigidity/vault-syncfrom
rigidity/vault-import
Open

Rigidity wants to merge 16 commits into
rigidity/vault-syncfrom
rigidity/vault-import

Conversation

@Rigidity

@Rigidity Rigidity commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #950. Part of #945. Stacked on #968.

Imports an existing vault, minted by Sage or Chia Cloud Wallet, as a read-only wallet, and lets users attach the keys that sign for it. Spending stays disabled until #952.

Vault wallets

  • wallets.toml defines vault wallets with type = "vault", the launcher_id, and signers: the fingerprints of the keychain keys that sign for it. The fingerprint is taken from the launcher ID, with collisions handled like watch wallets.
  • WalletKind::Vault in sage_wallet. Opening a vault wallet tracks its vault, so sync follows it from its launcher. It receives at, and sends change to, its nonce 0 P2 singleton address, and doesn't derive addresses. Building spends returns VaultSpendNotSupported, and so does signing. Sage Apps bridge methods that sign are refused with vault_unsupported.
  • A key used as a vault signer counts as used, so it can't be deleted on its own. delete_wallet with delete_keys removes a vault's signer keys unless another wallet still uses them.
  • sage wallet status shows the vault's launcher ID.

Importing

import_vault { name, launcher_id | address | config, recovery?, login, emoji } takes exactly one source. Launcher IDs are returned as vault1… bech32m IDs everywhere in the API (the wallet kind, get_vault, find_vault, nested vault members, and errors), and accepted as either that or hex. wallets.toml keeps hex. Every source needs a connected peer:

  • Launcher ID. The vault is checked to exist by following its lineage from the launcher. Its config is read from the memos on chain as sync reaches them.
  • Address. The address's puzzle is read from a coin spent from it, and must be a P2 singleton, which names the vault's launcher ID. An address that has only received coins reveals nothing, so the error suggests watching it instead.
  • Config file. A config in the JSON format Cloud Wallet exports (vault_config_file in sage_wallet). It's only accepted if it rebuilds the custody hash of the vault's latest coin. A file without a puzzle version is tried with both. It's stored with the config_file source.

A vault with no memos still imports read-only, and shows its balances. import_vault_config adds a config file to an imported vault, and export_vault_config writes the open vault's config in the same format, from whichever source it came from. While recovery is pending, that's the config from before it started. Otherwise, an older config is never used for the current coin, since it may no longer apply, for example after a rekey. The file format can't say whether members fast forward, so a config with members that don't follow Cloud Wallet's defaults isn't exported, since it wouldn't import again.

Rebuilding configs without memos

Cloud Wallet is phasing out vault config files in favor of memos, and can't currently export new ones. So as well as a config file, the config of a vault without memos can be rebuilt from its chain history, and its recovery keys if it has recovery. import_vault takes an optional recovery: { keys, threshold?, timelock? } with a launcher ID or address (not with a config file), and rebuild_vault_config { recovery? } does the same for an imported vault, returning the threshold and timelock it found.

  • What spends reveal. A spend through custody reveals a single member, or an M of N with the members that signed (parse_vault_spend in sage_wallet::vault_history). The puzzle doesn't say whether the vault has recovery, so each spend is read both ways: as the custody side of a top level 1 of 2, and as the custody of a vault without recovery. A vault with recovery and a single custody member looks just like a 1 of N vault without recovery that only one member has signed, so both stay possible until one rebuilds the custody hash.
  • Without recovery, the custody is the whole puzzle, so no keys are needed. import_vault without recovery keys tries this on the vault's last 10 spends, and falls back to memos or an unknown config if it doesn't work. With recovery, the recovery side only appears as a hash, which depends on the recovery members and threshold, the timelock, the puzzle version, and the custody side.
  • The search. Every recovery key must be given, in any order. A lone key is tried as each member its curve can be (BLS or a vault, with or without fast forward, or a compressed secp key as secp256r1, secp256k1 or a passkey, with or without fast forward). With several keys, each is tried as the member Cloud Wallet uses for its curve, in every combination, with every threshold from 1 to the number of keys unless it's given. Each candidate is tried with every timelock and both puzzle versions until the custody hash is rebuilt. Without a timelock, the timelocks are Cloud Wallet's 12 hour default, then every minute up to a week, every hour up to a year and every day up to ten years (about 22,000).
  • Speed. Nothing that doesn't depend on the timelock is hashed twice, and the timelocks are interleaved across threads, so early ones are still tried first. On a 12 core Mac, release build, a search that doesn't match takes 0.8 seconds for one BLS key, 1.4 seconds for one secp key, 0.7 seconds for three BLS keys and 13 seconds for three secp keys (54 candidates). A debug build is around 15 times slower, because sha2 isn't optimized there. Searches over 1.5 million hashes, or 4,096 candidates, are refused and ask for the threshold or timelock, which caps the worst case at about 15 seconds in release.
  • The keys can be public keys in hex, BLS mnemonics or secret keys, whose master public key is used, or vault1 IDs of vaults. Hex launcher IDs aren't accepted, since they look like secret keys. Secrets aren't stored, so they still have to be attached as signers.
  • Walking the lineage. The vault is walked from its launcher, then up to 100 of its spends are read from the latest back, until the current custody is rebuilt. The members revealed by different spends of an M of N are merged, so its config becomes known once each member has signed something, even if the vault had other custodies in between. The recovery side is always searched for in the current custody, but only in the latest 2 others, so wrong keys don't search every custody the vault has had. Recovery states can't be rebuilt, so a vault that's in recovery, or has cancelled one, doesn't use up those 2 before reaching its custody. The rebuilt config is checked against its custody hash, and stored with the new reconstructed source (3, after unknown).
  • The current custody. It can only be rebuilt once it's been spent through. Until then, for example after a rekey without memos, the latest older custody that can be rebuilt is stored, but isn't used, and rebuild_vault_config fails, unless recovery is pending and it's the config from before recovery started.
  • Repeated signers. Cloud Wallet allowed listing a signer twice until September 2026. The SDK refuses to verify an M of N with a repeated member, since it could count twice towards the threshold, so those vaults' configs stay unknown.
  • Errors say whether the vault hasn't been spent through its custody yet, it needs recovery keys, the keys, threshold or timelock don't match, the threshold is out of range, a key was given twice, the search is too large, only some custody members have been revealed, or the current custody hasn't been spent through.
  • Not covered: the config of a vault mid-recovery (the config from before recovery started is rebuilt, which is what can cancel it).

When a single address is imported as a watch wallet and turns out to belong to a vault (find_vault), the Import page offers to import the vault instead. This is the vault detection that moved here from #947.

Signers

  • attach_vault_signer { fingerprint, key } takes a BLS mnemonic or secret key. As in Cloud Wallet, a BLS member's key is the mnemonic's master key. The key must be a custody member of the vault's current config, or a recovery member of its latest config outside the recovery state, so the config must be known. It's stored in the keychain and listed in the wallet's signers. Public keys are rejected, since they can't sign. Secure element signers come with Support secp256r1 secure element signers for vaults #957.
  • get_vault returns the open vault's state, recovery start time and the time it can be finished (recovery_finishes_at, from the current coin), custody hash, config (with each member's kind, key and attached signer) and its signers with their roles.

API

  • New endpoints: import_vault, get_vault, attach_vault_signer, export_vault_config, import_vault_config, rebuild_vault_config and find_vault, under a new Vaults tag. src/bindings.ts is regenerated.
  • WalletKind gains { "vault": { launcher_id, signers } }.
  • get_sync_status and get_wallet_address return the vault's nonce 0 address.

Frontend

Everything is behind VITE_DISABLE_VAULTS.

  • The Import page has a Vault tab, which imports from a launcher ID or an address, with optional recovery keys (one per line), a threshold once there are several, and a timelock in days, hours and minutes, or from a config file, pasted or chosen.
  • Wallet cards show a Vault badge, and the launcher ID and signers in their details, with a delete message that says the vault isn't affected.
  • Vault wallets get a banner saying spending isn't supported yet, a Vault page, and a receive address with no derivation list or Sign Message on the Addresses page. Settings hides the change address and derivation index, as for watch wallets, and shows keys used by vaults as in use.
  • The Vault page shows the vault's state, launcher ID, recovery progress (including when it can be finished) and config, exports its config, imports a config file or rebuilds the config from its spends (and recovery keys, if it has recovery) when the config isn't known, and attaches signers. A vault without recovery says so instead of showing a recovery time.
  • New strings are wrapped in Lingui. The .po catalogs aren't touched, so the next release needs pnpm extract.

Tests

RPC tests with the peer simulator import vaults by launcher ID, by an address the vault has spent from (with a real vault-approved spend), by config file, and by recovery keys. They check:

  • a config file that doesn't match, or that's given with a recovery key, is rejected, one without a puzzle version is accepted, and it exports again unchanged; a config file imported later for another vault is rejected, and the right one fills the config in;
  • a recovery key is rejected until the vault is spent through its custody, a wrong or invalid key is rejected, and the right key finds the timelock;
  • a 2 of 2 recovery is rebuilt from a public key and a secret key in either order, with the threshold, timelock and Legacy version found, and an out of range threshold is rejected;
  • a vault without recovery is rebuilt on import with no keys, has only custody signers, rebuilds again with no keys, and exports without recovery;
  • after a rekey without memos, the old config isn't used: the old custody key can't be attached, export fails, and rebuilding fails until the new custody has been spent through, then rebuilds it;
  • a vault without memos imports read-only with its balances, fails to rebuild without recovery keys, then gets its config rebuilt with rebuild_vault_config, which can then be exported;
  • signers: a non-member key and a public key are rejected, the custody key is attached from a mnemonic and the recovery key from a secret key, and deleting the vault removes them;
  • receive addresses, spending being refused, and duplicate or unknown vaults.

Unit tests cover config file round trips (with and without recovery), parsing custody reveals (single members, M of N merged across spends, secp keys, vaults without recovery including the ambiguous 1 of N), the search with common and uncommon timelocks, both versions, several keys of mixed curves with the threshold found or given, the search limits, the fast hasher against the SDK's puzzle hashes for single and M of N recovery, and P2 singleton parsing. A simulator test rebuilds a vault whose recovery was cancelled, then started again to other custody, and recreated while pending, which finds the config from before recovery started. bench_full_recovery_search is ignored, and times searches that don't match.

Open question: hints

Cloud Wallet's memos carry no hint, so a Sage seed can't rediscover its own vaults: keys only appear inside memos, which can't be searched for. A hint would have to be a puzzle hash derived from a member key, written as the first memo of each vault recreation. Cloud Wallet only reads the first memo as the CHIP-0043 memo list, so adding one ahead of it would break its decoding, and adding one after it is ignored by peers' hint indexes, which only index the first memo. So this relies on the launcher ID, address lookup and the recovery keys.

Not included

Review-loop changes

  • Rebuilding a config skips the vault's recovery states, which are told apart from finishing a recovery by walking the lineage forward. Each cancelled recovery no longer uses up a search for older custodies.
  • Importing with recovery keys that only rebuild an older config fails with VaultCustodyUnrevealed, as rebuilding later does, unless recovery is pending and that config is the custody that started it.
  • find_vault checks the address's network. Import treats the same singletons as vaults as sync does.
  • The Vault page shows timelocks in days, hours and minutes. The watch form ignores a vault lookup for an address that has since changed.

Decisions for review

  • The Sage lock during import and rebuild. Like other endpoints, it's held through the lineage walk (one request per vault coin) and the recovery search (capped at about 15 seconds). Releasing it for the network part would need a dispatch change that no other endpoint has.
  • Vault spend builders. The start-recovery spend is copied into the sync tests, the RPC tests and the sandbox until Spend from a vault: spend pipeline, signing, and XCH/CAT transactions #952/Recover, cancel, and rekey vaults #954 add production builders that all three can use. The alternative is a test-utils feature in sage-wallet now.

@Rigidity

Copy link
Copy Markdown
Collaborator Author

Screenshots and end-to-end testing

Taken in pnpm sandbox (alice, 1280 wide) at 93046e3. The sandbox now mints a vault for each way one can be imported (see "Vaults" in docs/sandbox.md): alice's key is the custody of each, and bob's key is the recovery key.

Every import method was run end to end in the browser:

Method Vault Result
Launcher ID memos on chain config read from its memos
Launcher ID no memos, no recovery config rebuilt from its spends, no keys needed
Launcher ID + 2 recovery keys, threshold and timelock left empty no memos, 2 of 2 recovery rebuilt with the 2 of 2 recovery and a 1 day timelock
Address + 1 recovery key no memos, legacy rebuilt from its spends
Config file, chosen with the file picker recovery pending refused: the file is the config from before recovery started (see below)
Config file, pasted no memos, no recovery imported
Launcher ID only, then Rebuild Config on the vault page no memos unknown until rebuilt, then rebuilt with bob's key
Launcher ID only, then Import Config File on the vault page no memos, 2 of 2 recovery imported from the file
Watching the vault's address no memos, no recovery offers to import the vault instead, with the launcher ID filled in

Attaching alice's key as a custody signer, and bob's as a recovery signer, Export Config (byte for byte the same as the minted file), deleting and re-importing a vault, the wallet switcher, and the wallet card's details all work.

Fixes from this run (93046e3)

  • Signers were only shown by fingerprint. They're now named after their key wallet, here "Alice".
  • Member keys were cut off at 16rem even with room to spare. They now use the full width.
  • A pending recovery looked like any other state. Its badge is now red.
  • Recovery keys wrapped mid-key, which made two keys look like four lines. They no longer wrap.
  • Import Vault from the watch form dropped the name and emoji already entered, and the suggestion stayed after the address was changed.

The sandbox also got a stand-in for the save dialog, so exports work in the browser (da38eec).

Import

Launcher ID Address with two recovery keys
Config file Watching a vault's address

Vault page

Config from a file, with a recovery signer attached Recovery pending
Config unknown until rebuilt or imported Wallet list

Not fixed here

  • A config file can't import a vault whose recovery is pending, since the file matches the coin before recovery started, not the current one. Importing by launcher ID works if the recovery has memos, and the error says why. Allowing it would mean matching the file against the coin that started recovery.
  • Importing a vault that's already imported says "Wallet 501289534 already exists". The fingerprint is derived from the launcher ID, so it means little to the user; naming the existing wallet would be clearer.
  • The sandbox has no block timestamps, so the "recovery started on" and "can be finished from" times weren't checked.

The sandbox mints a vault for each way one can be imported: with memos, without memos for its
recovery keys to rebuild, with a 2 of 2 recovery, without recovery, and with a pending recovery.
Alice's key is the custody of each and bob's the recovery, and instance.json lists their ids,
addresses, recovery keys and config files.
A save dialog now picks its default name, and writing the file downloads it and records it in
window.__SAGE_SANDBOX__.savedFiles, so exports such as a vault's config work in the browser.
Testing the vault pages in the sandbox turned up some rough edges:

- Signers were only shown by fingerprint. They're named after their key wallet where there is one.
- Member keys were cut off at a fixed width, even with room to spare.
- A pending recovery looked like any other state, though it takes the vault away unless it's
  cancelled.
- Recovery keys wrapped mid-key, so it was hard to tell where one ended.
- Importing the vault found from a watched address dropped the name and emoji already entered, and
  the suggestion stayed after the address was changed.
- Skip recovery states when rebuilding a config, rather than letting each cancelled recovery
  use up one of the searches for older custodies, which left the original custody unsearched
  after two cancels. They're found from the spends through the recovery side that start them.
- Refuse an import with recovery keys that only rebuild an older config, unless recovery is
  pending, as rebuilding later does, instead of importing with the config unknown.
- Check an address's network in find_vault, as import does.
- Treat the same singletons as vaults on import as in sync.
- Show a timelock in days, hours and minutes, rather than seconds when it isn't whole hours.
- Ignore a vault lookup for an address that was changed while it ran.
- Test a vault with recovery pending, the keys a signer's vault keeps or deletes, and importing
  each of the sandbox's vaults.
Both go through the recovery side, so the custody a finished recovery moved to was taken for a
recovery state and never rebuilt, and the vault looked like it was still in recovery. The
lineage is now walked forward: a spend through the recovery side of a coin in the recovery
state finishes it. Also test importing a vault without memos with its recovery keys while
recovery is pending.
A config rebuilt from an older custody is only the one from before recovery started if that
custody's coin is the one whose spend started it. Also start recovery before importing in the
recovery-pending test, which made it flaky when recovery was started on a live wallet.
@Rigidity
Rigidity force-pushed the rigidity/vault-import branch from 9bb3e7d to c89dd0e Compare November 15, 2026 06:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Import an existing vault as read-only and attach signers

1 participant