Skip to content

Import addresses as watch-only wallets - #964

Merged
Rigidity merged 16 commits into
feat/key-managementfrom
feat/watch-wallets
Oct 29, 2026
Merged

Rigidity merged 16 commits into
feat/key-managementfrom
feat/watch-wallets

Conversation

@Rigidity

@Rigidity Rigidity commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #947. Part of #945. Stacked on #962.

Lets users import one or more XCH/TXCH addresses as a read-only watch wallet, to follow the balances, assets and history of addresses whose keys Sage doesn't hold (cold storage, another wallet). Watch wallets know only puzzle hashes, never puzzle reveals, so unlike cold wallets they can't even build unsigned transactions. This adds the watch wallet kind to the plumbing from #961.

Wallet config

  • wallets.toml defines watch wallets with type = "watch" and the watched p2_puzzle_hashes, flat alongside the other fields. A watch entry without p2_puzzle_hashes fails to load with a clear error.
  • A watch wallet has no key, so its fingerprint is taken from a hash of its set of addresses at import. If that fingerprint is taken (by a wallet, a keychain key or a leftover database folder), later candidates are hashed from the same input. The fingerprint stays the same when addresses are added or removed.
  • Watch wallets follow the default network like other wallets, and can be switched to another network with the usual override. Address prefixes aren't checked: only the puzzle hashes are stored, and the wallet's network decides how they're shown, so addresses for another network can be imported first and the wallet switched to that network afterwards. This departs from Import XCH/TXCH addresses as read-only watch wallets #947, which asked for wrong-network addresses to be rejected. Importing the same set of puzzle hashes again returns WalletExists whatever their prefix, since it's the same wallet on any network.
  • Importing a BLS key whose fingerprint is already a watch wallet's returns FingerprintCollision.

Database and sync

  • A new External p2 puzzle kind (kind = 4) stores a watched puzzle hash with no puzzle reveal. It counts as custody in custody_p2_puzzle_hashes, is_custody_p2_puzzle_hash and the transaction history's custody columns, so coins and hinted CATs, NFTs, DIDs and options whose inner p2 is a watched address register as owned. No migration is needed, since the kind has no table of its own. The sqlx query cache is regenerated.
  • When a watch wallet is opened, its database is brought in line with wallets.toml before syncing, and sync subscribes to every watched puzzle hash like any other custody puzzle. Coin subscriptions skip external puzzles, since the puzzle subscriptions already cover them.
  • Fully synced puzzle hashes are tracked, for every wallet kind. Migration 0010_synced_p2_puzzles.sql adds p2_puzzles.is_synced. Existing rows are marked synced, as they were assumed to be before, and new rows start unsynced. A puzzle hash is marked synced only once every page of its sync has been fetched. When a wallet opens, custody puzzle hashes that aren't synced yet (derivations, arbor and watched addresses) are synced from the start of the chain, and the rest resume from the last peak. That covers addresses added while a wallet was closed, a subscription that never went out before the app quit, an address removed and added again, and a sync interrupted partway through an address's history, which used to leave a permanent gap. BLS derivations added by increasing the derivation index get the same protection.
  • ChildKind detection works unchanged, since it keys off inner p2 puzzle hashes.
  • Coin ID subscriptions also skip arbor puzzles (kind = 3), which were subscribed twice before, since sync already subscribes to their puzzle hashes.
  • Fixes a check in the puzzle queue that passed a synced child's coin ID instead of its puzzle hash to is_custody_p2_puzzle_hash, so it was always false. A child at one of the wallet's own puzzle hashes that isn't in the database yet is now skipped, since syncing that puzzle hash delivers it. The root coin's update goes through the batch's open transaction, rather than a second connection that would wait on its write lock.
  • Watch wallets never derive addresses: auto-derivation is skipped, and insert_unhardened_derivations, p2_puzzle_hashes and increase_derivation_index return DerivationsNotSupported.

Read-only enforcement

  • sage_wallet::Wallet has a Watch kind. Building any spend (selecting coins, completing spends, change) returns WatchOnly ("Watch-only wallets can't create or sign transactions"), as does a spend that reaches an external puzzle.
  • Signing goes through one master_secret_key helper, which returns WatchOnly for watch wallets and NoSigningKey for cold ones. That covers sign_coin_spends and offers. WalletConnect message signing checks it before looking up the address, so a watch wallet gets WatchOnly instead of InvalidKey. Finalizing a clawback checks it too, since that skips the usual spend path when there's no fee. chip0002_getAssetCoins returns WatchOnly, since it can't reveal the puzzles.
  • WalletConnect signing commands are already rejected for read-only wallets in the frontend. Sage Apps bridge methods that sign (wallet.signMessage, wallet.sendXch and wallet.signCoinSpends) are refused with a watch_only error before the user is asked to approve them.

API

  • import_watch_wallet { name, addresses, login, emoji } imports a watch wallet from addresses on any network and returns its fingerprint. Importing the same set of addresses twice returns WalletExists.
  • add_watch_addresses { fingerprint, addresses } and remove_watch_addresses { fingerprint, addresses } change what a watch wallet watches. For the open wallet, added addresses are subscribed right away and removed ones are deleted along with their synced coins, so balances update without a resync. Other wallets catch up when they're opened. A watch wallet keeps at least one address (NoWatchAddresses), and other wallets return NotWatchWallet.
  • WalletKind gains { "watch": { addresses } }.
  • get_sync_status and get_wallet_address return a watch wallet's first address as its receive address. A change address set over RPC doesn't replace it, since watch wallets don't make change.
  • sage wallet status in the CLI shows that a wallet is watch-only. The new endpoints are also available through sage rpc.

Frontend

  • The Import page has Key and Addresses tabs. Addresses imports a watch wallet from pasted addresses, separated by spaces, commas or new lines.
  • WalletContext has isWatchOnly. Watch wallets are read-only, and isTransactionDisabled stays on even when building unsigned transactions is allowed for cold wallets, so every spend action is disabled and "export unsigned" isn't offered.
  • The read-only banner says the wallet is watch-only. Wallet cards show a Watch badge, list the watched addresses in their details, and have a delete message that says the addresses aren't affected.
  • The Addresses page shows the receive address and a Watched Addresses card, where addresses can be added or removed, instead of derivations. Sign Message is hidden. Settings hides the change address and derivation index for watch wallets.
  • New UI strings are wrapped in Lingui. The .po catalogs aren't touched, so the next release needs pnpm extract.

Not included

Detecting that an imported address is a vault's P2Singleton address, and offering to import the vault, needs the vault puzzles and sync from #948 and #949. It's moved to #950.

Testing

  • pnpm prettier:check, pnpm lint, tsc -b, pnpm test:unit (122 passed)
  • cargo fmt --all -- --files-with-diff --check, cargo clippy --workspace --all-features --all-targets
  • cargo test for sage-config, sage-database, sage-wallet, sage, sage-rpc, sage-api, sage-apps and sage-cli. New tests cover:
    • in the peer simulator, a watch wallet over a BLS wallet's addresses showing the same XCH, CAT, NFT, DID and option holdings;
    • adding and removing an address syncing or dropping the NFT held there, without a resync;
    • an address added while the watch wallet was closed syncing its whole history when it's opened again, even though it otherwise resumes from its last peak;
    • an address whose sync was interrupted partway (some coins missing and not marked synced) being synced again from the start;
    • new custody puzzle hashes starting unsynced and being marked once synced;
    • send, create DID, sign, message signing and derivation index returning read-only errors, a change address not replacing the receive address, and the BLS wallet still spending the same coins;
    • importing addresses for another network, empty and duplicate imports, removing the last address, and adding addresses to a BLS wallet;
    • deleting a watch wallet leaving the keychain alone;
    • watch wallets never deriving or spending in sage-wallet, and the wallets.toml shape and errors of watch entries.
  • The Sage Apps refusal isn't covered by a test, since the bridge dispatcher needs a running Tauri app.
  • The UI wasn't checked in the running app.

@Rigidity
Rigidity added this pull request to stack #963 October 29, 2026 03:08
@Rigidity Rigidity changed the title feat/watch wallets Import addresses as watch-only wallets Oct 29, 2026
@Rigidity Rigidity mentioned this pull request Oct 29, 2026
2 of 15 tasks
@Rigidity
Rigidity force-pushed the feat/watch-wallets branch 2 times, most recently from 7cdb96a to ef99a1e Compare October 29, 2026 03:37
@Rigidity
Rigidity force-pushed the feat/watch-wallets branch 3 times, most recently from 760a49d to 1fb0c0c Compare October 29, 2026 03:54
@Rigidity
Rigidity force-pushed the feat/watch-wallets branch 2 times, most recently from df7d9ad to c39e2af Compare October 29, 2026 04:21
@Rigidity
Rigidity force-pushed the feat/watch-wallets branch 3 times, most recently from 9311ca3 to 0d03189 Compare October 29, 2026 04:33
@Rigidity
Rigidity force-pushed the feat/watch-wallets branch 2 times, most recently from 18cb578 to 48c7745 Compare October 29, 2026 05:48
@Rigidity
Rigidity marked this pull request as ready for review October 29, 2026 06:23
@Rigidity
Rigidity merged commit 6f1b4b1 into main Oct 29, 2026
11 checks passed
@Rigidity
Rigidity deleted the feat/watch-wallets branch October 29, 2026 06:38
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 XCH/TXCH addresses as read-only watch wallets

1 participant