Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
21b86f9
Add BIP-329 label interoperability
schnuartz-ai Sep 10, 2026
31c7ed7
Harden BIP-329 import state handling
schnuartz-ai Sep 10, 2026
8cae7ef
Tighten BIP-329 detection and import reporting
schnuartz-ai Sep 10, 2026
0731084
Reconcile BIP-329 frozen state with Bitcoin Core
schnuartz-ai Sep 10, 2026
83e8cef
Roll back frozen state after persistence failure
schnuartz-ai Sep 11, 2026
336d3b3
Document BIP-329 output label projection
schnuartz-ai Sep 11, 2026
ad8c7fb
Clarify repeated BIP-329 output label imports
schnuartz-ai Sep 11, 2026
fe85063
Streamline BIP-329 interoperability documentation
schnuartz-ai Sep 11, 2026
f9aa5c5
Restore persisted frozen state after save failure
schnuartz-ai Sep 11, 2026
ed34bd9
Define frozen-state persistence commit boundary
schnuartz-ai Sep 11, 2026
fb36873
Serialize wallet frozen-state mutations
schnuartz-ai Sep 11, 2026
e4133f5
Coordinate pending PSBT and frozen UTXO locks
schnuartz-ai Sep 11, 2026
7c500ef
Serialize wallet snapshots with UTXO state
schnuartz-ai Sep 11, 2026
e4fe4f0
Fix label import settings redirect
schnuartz-ai Sep 11, 2026
09fffa1
Update wallet settings Cypress assertion
schnuartz-ai Sep 12, 2026
ba50e23
Fix locked UTXO output reconstruction for BIP-329 export
schnuartz-ai Sep 13, 2026
2d7fee2
Preserve confidential Liquid locked UTXO reconstruction
schnuartz-ai Sep 13, 2026
54d3ca0
Serialize BIP-329 exports with wallet UTXO state
schnuartz-ai Sep 13, 2026
be2ac91
Use safe frozen-state reconciliation in wallet UI
schnuartz-ai Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/integration/spec_ghost_machine.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('Ghost machine', () => {
// Advanced
cy.get('#btn_settings').click()
cy.get('#advanced_settings_tab_btn').click()
cy.contains("Import address labels")
cy.contains("Import wallet labels (BIP-329, Electrum or Specter)")
// Export options
cy.get('#export_settings_tab_btn').click()
// Specter JSON
Expand Down
123 changes: 123 additions & 0 deletions docs/bip329-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# BIP-329 label interoperability

Specter's BIP-329 support is an import/export adapter around the existing wallet
state. It does not replace the address-label store, add a per-output label
database, or change the Specter wallet backup format. The existing `labels`
object remains a mapping from a label to a list of addresses, so existing wallet
files and backups require no migration.

## Export

The separate **Download BIP-329 labels** action creates a deterministic UTF-8
JSON Lines (`.jsonl`) file containing:

- `addr` records for explicitly stored address labels. Generated display names
such as `Address #4` and `Change #8` are excluded.
- `output` records for current known wallet UTXOs whose address has an explicit
label. The output label is derived from that address label.
- `spendable:false` on current outputs in Specter's frozen UTXO list. A frozen
unlabeled output is exported without inventing a `label` value.

Specter does not fabricate `tx` records from its address-derived transaction
display labels. It also omits `spendable:true`; omission is the minimal BIP-329
representation for outputs that are not frozen.

Label exports contain privacy-sensitive addresses, transaction outpoints, and
descriptions. Keep them private and delete unencrypted copies when no longer
needed.

## Why export and import are intentionally asymmetric

Specter stores an explicit label on an address but also uses that label as the
effective label shown for the address's UTXOs. BIP-329 can represent address and
output labels independently. Sparrow also imports `addr` and `output` records
independently and does not propagate an imported address label to its outputs.

An `addr`-only export would therefore preserve Specter's stored label but lose
the UTXO-label semantics users see and use for coin control after migration.
The adapter materializes the same explicit address label on each current known
output for that address. These output records preserve existing semantics for a
wallet with a richer label model; they do not mean that Specter stores
independent per-output labels.

The reverse conversion is unsafe. An external wallet may assign different
labels to outputs on a reused address, including spent historical outputs.
Collapsing them into one Specter address label could destroy information and
change the apparent labels of other transactions.

For example, even if ten current UTXOs on the same address all contain
`output.label = "Alice"`, Specter does not infer the address label `Alice`.
Agreement among current UTXOs does not prove that spent historical outputs on
that reused address had the same meaning. The file must contain an explicit
`addr` record to update the Specter address label. Any `spendable` value on the
ten output records is still processed independently for each known outpoint.

This implementation keeps that adapter boundary. The underlying
address/transaction-label coupling is tracked in
[issue #2018](https://github.com/cryptoadvance/specter-desktop/issues/2018).
A long-term redesign could store address, transaction, and output labels
independently; lossless BIP-329 output-label import would require that additional
output-label storage beyond the transaction-label change proposed there.

## Import, conflicts, and frozen state

An `addr` record for an address in the selected wallet updates that address
through Specter's existing label mechanism. Unknown addresses and outpoints do
not create wallet state. Unknown record types and optional fields are ignored
for forward compatibility.

The optional `origin` descriptor is type-checked as a string but is neither
parsed nor used for wallet selection. Specter does not import the transaction
labels for which BIP-329 primarily defines origin disambiguation, and
address/output records
must already resolve to the selected wallet. Records with different labels for
the same reference remain conflicting even if their origins differ; Specter
does not guess which origin should win.

Imported `output.label` values are reported as unsupported rather than written
to the address store. Conflicting duplicate address or `spendable` records are
skipped instead of being resolved by file order. Malformed records are validated
atomically and skipped without creating partial state. The UI reports ignored,
unsupported, malformed, conflicting, and failed records without logging label
or outpoint contents.

For a known output, `spendable:false` freezes it and `spendable:true` unfreezes
it through Specter's existing frozen-UTXO mechanism. The wallet refreshes its
UTXO set before importing or exporting output state.

Frozen-state updates reconcile Specter's persisted ownership marker with
Bitcoin Core's current lock state. The operation is idempotent, repairs a
missing non-persistent Core lock for an existing Specter freeze, and does not
record a successful update unless the required Core operation and verified
wallet JSON write succeed. Storage callbacks and balance refreshes run only
after that write; their failure does not roll back or misreport the persisted
frozen state. A wallet-write failure restores the prior in-memory and persisted
state and independently attempts to restore Core's prior lock state.

A wallet-specific reentrant lock serializes the complete pending-PSBT read,
Core RPC, RAM update, and wallet-write sequence. Pending-PSBT save/delete and
the legacy UI freeze toggle use the same lock, so their Core-lock ownership
transitions cannot interleave. Every wallet JSON snapshot and write also takes
this lock, so an older concurrent save cannot overwrite a newer ownership
state. Deleting a pending PSBT does not unlock an input that remains protected
by a Specter freeze or another pending PSBT.

Outputs used by pending PSBTs are never frozen or unfrozen by this importer.
A Core lock without a matching Specter frozen marker is protected in the same
way. Such requests are reported as conflicts.

Bitcoin Core does not record an owner for `lockunspent` locks. If Specter has a
persisted frozen marker for an outpoint, its original Core lock disappears, and
another process later locks the same outpoint, the two locks are
indistinguishable. The importer treats the persisted marker as ownership in
that case. Pending PSBT inputs remain protected independently of this marker.

## References

The implementation follows the current
[BIP-329 specification](https://github.com/bitcoin/bips/blob/master/bip-0329.mediawiki).
Sparrow compatibility was checked against commit `3dc99b6` of its
[`WalletLabels.java`](https://github.com/sparrowwallet/sparrow/blob/3dc99b6b54a6c7a43071aa9e8852c8b2e813b6d1/src/main/java/com/sparrowwallet/sparrow/io/WalletLabels.java).
That implementation reads UTF-8 JSON Lines, distinguishes `addr` and `output`
labels, identifies outputs by `txid:vout`, accepts `spendable` without a label,
and maps the boolean value to its frozen status.
57 changes: 56 additions & 1 deletion src/cryptoadvance/specter/liquid/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

from embit import ec
from embit.descriptor.checksum import add_checksum
from embit.liquid.addresses import to_unconfidential
from embit.liquid.addresses import (
addr_decode,
address as liquid_address,
to_unconfidential,
)
from embit.liquid.descriptor import LDescriptor
from embit.liquid.pset import PSET
from embit.liquid.transaction import LTransaction
Expand All @@ -24,6 +28,57 @@ class LWallet(Wallet):
PSBTCls = SpecterPSET
DescriptorCls = LDescriptor

def _get_locked_utxo_address_amount(self, tx_from_core, vout):
"""Resolve confidential values using wallet-aware Elements data."""
output = self.TxCls.from_string(tx_from_core["hex"]).vout[vout]

def belongs_to_output(address):
try:
script, _ = addr_decode(address)
return script == output.script_pubkey
except Exception:
return False

# Elements unblinds wallet transaction details. Check the actual script
# as an accounting SEND entry may also carry this vout index.
for detail in tx_from_core.get("details", []):
if not isinstance(detail, dict) or detail.get("vout") != vout:
continue
address = detail.get("address")
amount = detail.get("amount")
if (
detail.get("category") == "send"
or not isinstance(address, str)
or not isinstance(amount, (int, float))
or isinstance(amount, bool)
or amount < 0
or not belongs_to_output(address)
):
continue
address_obj = self._addresses.get(address)
return amount, address_obj.address if address_obj else address

# Change outputs may be absent from details. LiquidRPC's decoder can
# unblind them, unlike the value commitment in the raw transaction.
decoded_output = self.rpc.decoderawtransaction(tx_from_core["hex"])["vout"][
vout
]
amount = decoded_output.get("value")
if (
not isinstance(amount, (int, float))
or isinstance(amount, bool)
or amount <= 0
):
raise ValueError("Could not unblind locked Liquid output")
addresses = decoded_output.get("scriptPubKey", {}).get("addresses", [])
for address in addresses:
if isinstance(address, str) and belongs_to_output(address):
address_obj = self._addresses.get(address)
return amount, address_obj.address if address_obj else address
address = liquid_address(output.script_pubkey, network=self.network)
address_obj = self._addresses.get(address)
return amount, address_obj.address if address_obj else address

@classmethod
def construct_descriptor(
cls, sigs_required, key_type, keys, devices, blinding_key=None
Expand Down
7 changes: 6 additions & 1 deletion src/cryptoadvance/specter/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,13 @@ def _write_json_file(content, path, lock=None):
)


def write_json_file(content, path, lock=None):
def write_json_file_without_callback(content, path, lock=None):
"""Write and verify JSON without running post-persistence callbacks."""
_write_json_file(content, path, lock)


def write_json_file(content, path, lock=None):
write_json_file_without_callback(content, path, lock)
storage_callback(path=path)


Expand Down
88 changes: 78 additions & 10 deletions src/cryptoadvance/specter/server_endpoints/wallets/wallets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import requests
from flask import Blueprint
from flask import current_app as app
from flask import jsonify, redirect, render_template, request, url_for
from flask import Response, jsonify, redirect, render_template, request, url_for
from flask_babel import lazy_gettext as _
from flask_login import login_required

Expand Down Expand Up @@ -413,7 +413,10 @@ def history(wallet_alias):
if request.method == "POST":
action = request.form["action"]
if action == "freezeutxo":
wallet.toggle_freeze_utxo(request.form.getlist("selected_utxo"))
try:
wallet.toggle_freeze_utxo(request.form.getlist("selected_utxo"))
except SpecterError as e:
flash(str(e), "error")
tx_list_type = "utxo"
elif action == "abandon_tx":
txid = request.form["txid"]
Expand Down Expand Up @@ -782,18 +785,37 @@ def addresses(wallet_alias):
###### Wallet settings ######


@wallets_endpoint.route("/wallet/<wallet_alias>/settings/", methods=["GET", "POST"])
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/",
methods=["GET", "POST"],
)
# In case of exceptions in the "subactions" POST method handlers, the error-handler
# will redirect to the same endpoint but GET-method. Specifying them here:
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/importaddresslabels", methods=["GET"]
"/wallet/<wallet_alias>/settings/importaddresslabels",
methods=["GET"],
endpoint="settings_importaddresslabels_get",
)
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/keypoolrefill",
methods=["GET"],
endpoint="settings_keypoolrefill_get",
)
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/rescan",
methods=["GET"],
endpoint="settings_rescan_get",
)
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/deletewallet",
methods=["GET"],
endpoint="settings_deletewallet_get",
)
@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/keypoolrefill", methods=["GET"]
"/wallet/<wallet_alias>/settings/clearcache",
methods=["GET"],
endpoint="settings_clearcache_get",
)
@wallets_endpoint.route("/wallet/<wallet_alias>/settings/rescan", methods=["GET"])
@wallets_endpoint.route("/wallet/<wallet_alias>/settings/deletewallet", methods=["GET"])
@wallets_endpoint.route("/wallet/<wallet_alias>/settings/clearcache", methods=["GET"])
@login_required
def settings(wallet_alias):
wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
Expand Down Expand Up @@ -846,14 +868,60 @@ def settings_importaddresslabels(wallet_alias):
wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
action = request.form["action"]
address_labels = request.form["address_labels_data"]
imported_addresses_len = wallet.import_address_labels(address_labels)
report = wallet.import_address_labels(address_labels, return_report=True)
imported_addresses_len = report.imported_address_labels
if report.is_bip329:
if imported_addresses_len or report.updated_frozen_utxos:
flash(
_(
"Successfully imported {} address labels and updated {} frozen UTXOs."
).format(imported_addresses_len, report.updated_frozen_utxos)
)
if report.has_warnings:
flash(
_(
"Some BIP-329 metadata was not imported (ignored records: {}, unsupported output labels: {}, malformed records: {}, conflicting records: {}, failed records: {})."
).format(
report.ignored_records,
report.unsupported_output_labels,
report.malformed_records,
report.conflicting_records,
report.failed_records,
),
"warning",
)
elif not imported_addresses_len and not report.updated_frozen_utxos:
flash(
_("No wallet labels or frozen UTXO states needed updating."),
"warning",
)
return redirect(url_for("wallets_endpoint.settings", wallet_alias=wallet_alias))
if imported_addresses_len > 1:
flash(f"Successfully imported {imported_addresses_len} address labels.")
elif imported_addresses_len == 1:
flash(f"Successfully imported {imported_addresses_len} address label.")
else:
flash("No address labels were imported.")
return redirect(url_for("wallets_endpoint.settings"))
return redirect(url_for("wallets_endpoint.settings", wallet_alias=wallet_alias))


@wallets_endpoint.route(
"/wallet/<wallet_alias>/settings/exportbip329labels", methods=["GET"]
)
@login_required
def settings_exportbip329labels(wallet_alias):
wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
response = Response(
wallet.export_bip329_labels(),
content_type="application/x-ndjson; charset=utf-8",
)
response.headers.set(
"Content-Disposition",
"attachment",
filename=f"{wallet.alias}-labels.jsonl",
)
response.headers.set("Cache-Control", "no-store")
return response


@wallets_endpoint.route(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@
<p>{{ _("Create a JSON file containing all wallet data including labels.") }}</p>
<a download="{{ wallet.name }}.json" class="button mt-3" id="export_specter_format">Download Specter JSON file</a>

<h3 class="mt-8">{{ _("Wallet labels (BIP-329)") }}</h3>
<p>{{ _("Export wallet address and UTXO labels in the BIP-329 standard format for use with compatible wallet software. Frozen UTXOs are included. Keep this sensitive wallet metadata private.") }}</p>
<a href="{{ url_for('wallets_endpoint.settings_exportbip329labels', wallet_alias=wallet.alias) }}" download="{{ wallet.alias }}-labels.jsonl" class="button mt-3" id="export_bip329_labels">{{ _("Download BIP-329 labels") }}</a>

<h3 class="mt-8">{{ _("Printable PDF backup") }}</h3>
<p>
{{ _("It is recommended that you print this backup and save it with each of your devices.") }}
Expand Down Expand Up @@ -190,15 +194,15 @@

<div class="hidden" id="wallet_advanced_settings_tab">
<div>
<h3>{{ _("Import address labels (Electrum or Specter)") }}</h3>
<h3>{{ _("Import wallet labels (BIP-329, Electrum or Specter)") }}</h3>

<form action="./importaddresslabels" method="POST" class="padded" data-style="max-width: 500px">
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<dnd-textarea
name="address_labels_data"
id="address_labels_data"
placeholder="{{ _('You can paste and drag & drop here: ') }}{{ _('Electrum JSON file, Specter addresses CSV or Specter JSON file') }}"
placeholder="{{ _('You can paste and drag & drop here: ') }}{{ _('BIP-329 JSONL, Electrum JSON, Specter addresses CSV or Specter JSON file') }}"
textarea-style="margin-bottom: 10px;"
>
</dnd-textarea>
Expand All @@ -210,6 +214,9 @@
<button type="submit" name="action" value="import_address_labels" class="button bg-accent text-white" data-style="max-width: 130px; margin-left: 20px">{{ _("Import") }}
<tool-tip width="200px">
<span slot="paragraph">
{{ _('BIP-329 JSONL uses one JSON object per line, for example:') }}<br>
{{ _('{"type":"addr","ref":"bc1qj3...","label":"some label"}') }}<br><br>
{{ _('Specter imports BIP-329 address labels and frozen output state. Independent output labels cannot be represented by Specter and are reported without changing address labels.') }}<br><br>
{{ _('The expected format for Electrum contains:') }}<br>
{{ _('{"8a5e4...": "some tx id label", "bc1qj3...": "some address label"}') }}<br><br>
{{ _('The expected format for Specter JSON contains:') }}<br>
Expand Down
Loading
Loading