Repository navigation
Add backwards-compatible BIP-329 label import and export - #2712
schnuartz-ai wants to merge 19 commits into
Conversation
✅ Deploy Preview for specter-desktop-docs canceled.
|
|
Thanks for the detailed BIP-329 interoperability work. A first maintainer pass is blocked by the current CI state: the The failing run is https://github.com/cryptoadvance/specter-desktop/actions/runs/34609800719. GitHub reports the failed Cypress spec as Once the PR is green, this will need a careful review because it changes wallet freeze/lock persistence and import/export semantics in a fairly large surface area. |
|
Manual Testnet3 interoperability check on PR commit
Sparrow before import ( Specter after Sparrow import (same output, address-derived Sparrow after Specter's JSONL import ( These are app-only screenshots from the local test. The files contain testnet wallet metadata; no seed, private key, or other desktop content is shown. The adapter does not solve the independent transaction/output-label model discussed in #2018. |
|
Follow-up: fixed the Liquid/Elements locked-confidential-UTXO regression in
Two new automated tests use a synthetic confidential value commitment: one covers a conflicting SEND detail preceding the valid wallet detail and the other covers a missing change detail, decoder unblinding and confidential-address lookup. The 52 focused tests pass locally. On this exact commit, upstream The earlier manual Sparrow ↔ Specter Testnet3 screenshots remain evidence for the Bitcoin path on the parent commit; they are not presented as a live Liquid test. |
|
Review follow-up on current head 54d3ca0: the Liquid locked-confidential-output regression was fixed in 2d7fee2 with wallet-aware unblinding and two regression tests. This commit closes the export snapshot race: check_utxo() and the complete BIP-329 export refresh/serialization now share the per-wallet UTXO-state RLock used by freeze and pending-PSBT mutations. A deterministic export-vs-freeze test verifies that a concurrent freeze cannot enter its Core-state read until the export completes. All 53 focused tests pass locally; the upstream test, cypress, black, extension-smoketest, and Linux smoke-test jobs pass on 54d3ca0. The existing UI toggle's error handling and moving post-commit callbacks out of the lock remain separate follow-up concerns; this PR has not changed their semantics. No merge has been performed. |
|
Review follow-up on head be2ac91: both findings were confirmed and fixed. The existing freeze UI now delegates to the same set_frozen_state() reconciliation used by BIP-329, so a pending-PSBT input cannot be unfrozen through the UI and an exception or False from lockunspent cannot change Specter's frozen marker. The history route flashes a safe error instead of returning a server error. The canonical wallet settings route again owns wallets_endpoint.settings; GET subaction aliases have distinct endpoints, and the BIP-329 import redirect uses the canonical endpoint. Six regression cases were added (pending-PSBT ownership, four freeze/thaw RPC-failure combinations, and URL generation). All 59 focused tests pass locally; test, cypress, black, extension-smoketest, and Linux smoke-test pass on this exact commit. The PR has not been merged. Live Elements regtest and moving post-commit callbacks out of the wallet lock remain separate follow-up concerns. |



Summary
Adds backwards-compatible BIP-329 label interoperability as a separate layer around Specter's existing address-label and frozen-UTXO state.
spendable:false, including unlabeled frozen UTXOsaddrlabels through Specter's existing address-label storespendablestate through an explicit idempotent Core/Specter reconciliation operationfrozen_utxomarker still owns the freezeoutput.labelas unsupported instead of lossily converting it into an address labeltype/refBIP-329 envelope, including unknown future types, without diverting legacy JSON that merely contains atypekeyWallet.export_labels()unchangedwallets_endpoint.settingsURL endpointFrozen-state and pending-PSBT lock safety
BIP-329 imports and the existing UI freeze action both use
set_frozen_state(), which reads Core's current lock set and Specter's persisted marker together. It is idempotent, refuses pending-PSBT inputs and Core locks without a matching local marker, repairs missing non-persistent Core locks, and requires a successful booleanlockunspentresponse before changing local state. A wallet-specific reentrant lock covers pending-PSBT save/delete, the pending-input and Core-state reads, Core RPCs, RAM mutations, and wallet writes. The UI action also uses this reconciliation operation: it cannot unfreeze pending-PSBT inputs, and failed Core RPCs leave the local marker unchanged. Every wallet JSON snapshot/write takes the same lock, preventing an older concurrent save from overwriting newer UTXO-ownership state. Deleting a pending PSBT unlocks only inputs that are not still protected by a Specter freeze or another pending PSBT.The persistence sequence has an explicit commit boundary:
Only failure of the verified wallet JSON write restores the prior RAM/file snapshot and independently compensates a successful Core RPC. Failure of either rollback emits a separate privacy-preserving critical log.
Storage callbacks and
update_balance()run after the commit. If either fails, the Core, RAM, and wallet JSON remain in the new consistent state and the import remains reported as updated; a post-commit side-effect failure is logged without label or outpoint metadata.Why export and import are intentionally asymmetric
Specter stores explicit labels only on addresses, but uses an address label as the effective label displayed for that address's UTXOs. BIP-329 can represent address and output labels independently.
Sparrow's current BIP-329 importer also handles
addrandoutputrecords independently: importing anaddrrecord labels the address node, but it does not propagate that label to existing outputs. An addr-only export would therefore lose the UTXO-label semantics Specter users currently see and use for coin control when migrating to Sparrow.For that reason, this adapter deliberately materializes each explicit Specter address label on every current known output for that address:
These output records are a derived interoperability representation; they do not imply that Specter stores independent per-output labels.
The reverse conversion is unsafe. Another wallet may assign different labels to outputs on a reused address, including spent historical outputs. Collapsing those distinctions into one Specter address label would destroy information and could change the apparent labels of unrelated transactions. Therefore
output.labelis exported, but arbitrary importedoutput.labelvalues are reported as unsupported rather than written to Specter's address store.Concrete repeated-output case: even if ten current UTXOs on the same address all contain
output.label = "Alice", Specter still does not infer the address labelAlice. Agreement among the current UTXOs cannot prove that spent historical outputs on a reused address had the same meaning. An explicitaddrrecord is required to update the Specter address label. Anyspendablevalue on those ten records is still processed independently for each known outpoint.There are two architectural paths:
This PR intentionally chooses the first path and does not close the broader architecture problem tracked in upstream issue #2018.
Label conflict behavior
Malformed records are validated atomically. Conflicting duplicate address or spendable records are skipped rather than resolved by file order. Unknown wallet references do not create state. Unknown BIP-329 types are ignored for forward compatibility. Output labels from imported files are reported as unsupported and are never silently collapsed into an address label. Optional
originvalues are type-checked as strings but are not parsed or used to select a wallet: supported records must already resolve to the selected wallet, and different labels for the same reference remain conflicting even when their origins differ.Known limitations
Bitcoin Core does not expose an owner for
lockunspentlocks. A Core lock without a matching Specter marker is safely rejected. If a persisted Specter marker exists, its original Core lock disappears, and another process later locks the same outpoint, the locks are indistinguishable; the importer treats the persisted marker as ownership. Pending PSBT inputs are protected independently of this marker.Frozen-state imports currently reconcile and persist each distinct outpoint separately. A multi-outpoint batch would reduce RPC reads and file writes, but requires transaction and rollback semantics across several Core operations and is intentionally left for a separate change.
BIP-329 suggests, but does not require, a 255-character label limit. This adapter does not silently truncate BIP-329 labels while Specter's existing label model and legacy importers accept longer values. The existing 1 MiB per-line and 10 MiB document limits bound BIP-329 parser input.
Compatibility
The implementation follows the current BIP-329 UTF-8 JSON Lines format and was checked against Sparrow's current
WalletLabelsimplementation. Existing Specter backup/export structures are unchanged.Sparrow source inspected: WalletLabels.java at commit 3dc99b6.
Validation
update_balance()exception leaves Core, RAM, and disk in the new committed state and reports one update with zero failed recordsgit diff --checkpassedbitcoindis unavailable; the failure occurred during fixture setupbe2ac91f) fortest,cypress,extension-smoketest,black, and the Linux smoke-test; the Netlify docs preview was canceled without a docs changeThe full repository suite was not run locally; upstream test jobs passed on the current UI freeze and settings-compatibility commit. The Liquid regression tests use a synthetic value commitment and mocked wallet-aware RPC responses; no live Elements regtest was available locally. A manual Testnet3 Sparrow ↔ Specter roundtrip with a real frozen UTXO and screenshots is documented in the PR comments.