Skip to content

wallet: implement the BDK wallet improvement plan - #179

Merged
bennyhodl merged 24 commits into
postgres-storage-fixesfrom
wallet-improvements
Aug 14, 2026
Merged

wallet: implement the BDK wallet improvement plan#179
bennyhodl merged 24 commits into
postgres-storage-fixesfrom
wallet-improvements

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

Implements all five phases of docs/wallet-improvement-plan.md, one commit per plan item: sync correctness fixes, persistent UTXO reservation with BDK outpoint locking, chain-truth tracking of contract funding outputs, wallet events with live fee estimation, and BIP-329 labels with coin control.

Stacked on postgres-storage-fixes (#178); merge after it.

Changes

Phase 1 — correctness

  • Full scan covers both keychains (stop gap 50, five parallel requests), so a wallet restored from seed finds its change outputs
  • Sync always runs instead of short-circuiting on an unchanged tip height, so mempool transactions are visible without a new block
  • Syncs seed esplora with expected txids, so replaced or evicted transactions drop out of the canonical view and a stuck pending balance recovers
  • Incremental syncs no longer fabricate last-active derivation indices
  • bdk_wallet manifest floor raised to 3.1.0
  • The funding PSBT is signed in place once; later per-input calls find their input already finalized

Phase 2 — UTXO reservation

  • Coin selection runs in the wallet actor, skips locked outpoints, and locks the selection when the manager asks, so concurrent offers cannot pick the same coins
  • unreserve_utxos unlocks through BDK instead of being a no-op
  • Postgres persists locked_outpoints (new migration); sled and memory already round-trip the field
  • Wallet-owned inputs of a signed funding PSBT lock at sign time and unlock when a confirmed transaction spends them
  • crate::Balance reports spendable (unspent minus locked) and reserved (locked)

Phase 3 — contract UTXO tracker

  • ContractUtxoTracker: an SpkTxOutIndex<ContractId> over its own TxGraph, sharing the wallet's local chain view
  • Each sync registers funding scripts from contract storage and runs a second targeted sync over the funding SPKs and outpoints, learning confirmation and any spend (CET, refund, counterparty close) in one round trip
  • contract_confirmed/contract_pending come from TxGraph::balance() over the tracked outpoints instead of collateral math; PnL stays from contract state
  • The tracker changeset persists through the Storage trait in memory, sled, and postgres (new migration), with no-op defaults for external backends
  • contract_utxos() on the wallet and DDK API lists locked collateral per contract

Phase 4 — events and fees

  • Syncs apply updates via apply_update_events; WalletEvents forward on a broadcast channel and transaction events trigger the manager's periodic check immediately
  • Esplora fee estimates fill a per-target cache each sync; the old constants stay as floors/fallback, and every ConfirmationTarget now has an entry (the old map panicked on MaximumFeeEstimate and OutputSpendingFee)
  • SendToAddress/SendAll share one build-sign-broadcast helper that also persists the revealed change index

Phase 5 — labels and coin control

  • BIP-329 labels via the bip329 crate, stored through the Storage trait in all three backends (new migration), keyed by record type plus reference
  • Contracts auto-label their funding transaction, funding outpoint, and CET (with the attested outcome); labels export/import as JSONL
  • send_with_coin_control: caller-selected UTXOs, an unspendable list, and a confirmation floor
  • bump_fee replaces a stuck unconfirmed send via RBF
  • The 25 000 sat minimum change size is a WalletConfig/builder option

Testing

  • cargo test -p ddk --features "sled,postgres" --lib: 83 tests pass, including new tests for restore-from-seed, mempool visibility, RBF eviction, concurrent selection, lock restart survival, sign-time locking, balance split, tracker unit tests, storage round trips, fee cache, events, labels, coin control, and fee bumping
  • End-to-end contract tests pass: cargo test -p ddk --test enumeration -- --ignored (now also asserts tracker state, chain-truth balance, and auto-labels through settlement) and cargo test -p ddk --test short_call -- --ignored
  • cargo clippy -p ddk --all-features shows only pre-existing warnings in untouched oracle files

The initial full scan only requested external SPKs, so a wallet
restored from seed did not find its change outputs. Scan the internal
keychain as well, raise the stop gap from 10 to 50, and make five
esplora requests in parallel instead of one.
The sync path returned early when the wallet tip equaled the chain
height, so unconfirmed transactions stayed invisible until the next
block. Always run the sync; the timer in ddk.rs bounds the cost.
Seed each incremental sync with the txids we expect under our SPKs.
Esplora reports the ones that no longer show up, BDK stamps them
evicted_at, and replaced or evicted transactions drop out of the
canonical view. Without this a stuck pending balance never recovered
when its transaction left the mempool.
The incremental branch wrote the wallet's current derivation indices
into the update as last-active indices. A sync must not set them; only
a full scan learns which indices are active. Use the BDK conversions
for both branches and make incremental syncs use the same request
parallelism as full scans.
The lock file already resolved 3.1.0; raise the manifest floor so
fresh builds get the add_foreign_utxo non-witness validation fix and
the Utxo::txout panic fix for foreign UTXOs, both on our splice code
paths.
Each sign_psbt_input call cloned the full PSBT and signed every input,
and the manager calls it in a loop over the funding inputs. Sign the
PSBT in place on the first call and skip inputs that are already
finalized, so a funding transaction costs one signing pass.
Move coin selection into the wallet actor. Selection skips locked
outpoints, and when the manager asks for locked UTXOs the selected
outpoints are locked through BDK and persisted. Two concurrent offers
can no longer fund themselves with the same coins, and the locks
survive a restart.
unreserve_utxos was a no-op, so coins locked for a failed offer or a
rejected contract stayed unspendable. Unlock them through BDK and
persist the change. The manager trait method is synchronous, so it
fires the unlock command without waiting; unlock_outpoints on the
wallet API waits for persistence.
The postgres backend decomposes the BDK changeset into tables and
silently dropped the locked_outpoints field, so a UTXO lock did not
survive a restart. Add a locked_outpoints table, upsert with the merge
semantics of the BDK changeset, and read it back on initialize. Sled
and memory serialize the whole changeset and already round-trip the
locks; a sled test now proves it.
A signed funding transaction can stay unbroadcast while the
counterparty finishes the protocol, and its inputs were still
selectable. Lock the wallet-owned inputs of a PSBT when the wallet
signs it, release the lock when a confirmed transaction spends the
outpoint, and expose locked_outpoints() so consumers can inspect the
reserved coins. Unconfirmed spends keep their locks: an evicted
transaction returns the coin to the spendable set.
The reported balance treated locked coins as available. Report
spendable (unspent minus locked) and reserved (locked unspent) from
the same wallet snapshot, surface both on crate::Balance, and return
the richer WalletBalance from the wallet API.
The 2-of-2 funding output of a contract is invisible to the BDK
wallet. Track it beside the wallet: an SpkTxOutIndex keyed by contract
id over its own TxGraph, sharing the wallet's local chain view. The
tracker reports funding confirmation, spend of the funding output, and
a chain-truth balance, and stages a serde changeset for persistence.
Each wallet sync now registers the funding script of every contract in
storage and runs a second, targeted sync request over the funding SPKs
and outpoints. Esplora resolves outpoint spend status, so one round
trip learns both confirmation of the funding transaction and any spend
of the funding output (CET, refund, or counterparty close).
The contract balance came from collateral math over confirmed
contracts. Report contract_confirmed and contract_pending instead,
computed from the tracked funding outpoints against the wallet's local
chain, and keep PnL from contract state. crate::Balance replaces the
contract field with the two chain-truth fields.
Load the tracker changeset at wallet startup and persist staged
changes after each sync through two new Storage trait methods. Memory
merges in RAM, sled stores merged JSON under its own key, and postgres
keeps one merged JSONB document per wallet. The trait methods default
to a no-op so external backends keep compiling; without persistence
the tracker rebuilds from contract storage and a chain sync. Funding
script registrations serialize as a list of pairs, because a JSON map
cannot key on a byte array.
contract_utxos() lists the tracked funding outputs with their chain
state (confirmation and spending transaction), so a consumer such as
ddk-node or an FFI binding can show locked collateral per contract and
observe a counterparty close. The end-to-end contract test now asserts
the tracker sees the confirmed funding output on both sides and its
spend after the CET broadcasts.
Apply sync updates with apply_update_events and forward the BDK
wallet events (TxConfirmed, TxUnconfirmed, TxReplaced, TxDropped,
ChainTipChanged) on a broadcast channel. A transaction event triggers
the manager's periodic check right away instead of waiting for the
next timer tick, and consumers get reorg-aware confirmation
notifications through subscribe_events.
The wallet's fee estimator returned constants and the esplora client
returned one sat/kw. Each sync now fetches esplora's fee estimates
into a lock-free cache keyed by confirmation target; the constants
stay as floors and as the fallback while no estimate has arrived. The
wallet delegates to the client's cache, every confirmation target has
an entry (the old map panicked on MaximumFeeEstimate and
OutputSpendingFee), and a failed fetch never fails a sync.
SendToAddress and SendAll shared the same build-sign-broadcast body.
One helper now builds the transaction for either spend shape, signs,
broadcasts, and also persists the wallet so the change index a build
reveals survives a restart (both paths previously dropped it).
Labels for transactions, addresses, and outputs use the bip329 crate,
keyed by record type plus reference so an input and an output record
on the same outpoint coexist. Memory, sled, and postgres implement
load, upsert-by-reference, and delete; the trait methods default to an
empty store. DlcDevKit exposes set_label, labels, and delete_label.
Each sync labels the funding transaction and funding outpoint of a
newly tracked contract with the contract id, and the broadcast CET of
a closed contract with the attested outcome. DlcDevKit exports and
imports the labels as BIP-329 JSONL. The end-to-end test asserts the
labels and a JSONL round trip after settlement.
send_with_coin_control takes caller-selected UTXOs (spent exclusively
via manually-selected-only), an unspendable exclusion list, and a
confirmation floor. send_to_address and send_all stay as thin wrappers
over the same command.
bump_fee builds an RBF replacement of an unconfirmed wallet
transaction through Wallet::build_fee_bump (RBF is on by default in
BDK 3.x), signs, broadcasts, and persists it. The send and bump paths
share one sign-and-broadcast helper.
The 25 000 sat minimum change size of coin selection was a hardcoded
constant. WalletConfig carries it, new_with_config takes it, and the
DDK builder exposes set_min_change_size; the existing constructor and
default stay unchanged.
@bennyhodl
bennyhodl merged commit 18c68c7 into postgres-storage-fixes Aug 14, 2026
134 checks passed
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.

1 participant