Skip to content

v2.0.0-rc.4

Pre-release
Pre-release

Choose a tag to compare

@bennyhodl bennyhodl released this 23 Sep 13:24
· 33 commits to master since this release
d912164

DLC Dev Kit v2.0.0-rc.4

This release candidate closes a large set of security findings from the Bitcoin Red Team review, hardens the manager against malformed peer messages, and reworks the BDK wallet integration. It also carries custom TLV records on the DLC messages from end to end, replaces the Postgres contract blob with a columnar table, and removes the DLC channel manager.

All workspace crates move to 2.0.0-rc.4: ddk, ddk-manager, ddk-messages, ddk-dlc, ddk-trie, ddk-payouts, kormir, and ddk-node.

⚠️ Breaking Changes

  • DLC channels are removed. The DLC channel manager is gone from ddk-manager: channel_updater, chain_monitor, the channel module, and their tests and stored fixtures. Only contracts remain. (2ceea52)
  • Builder requires a seed. Builder::default no longer starts from 64 zero bytes. finish returns BuilderError::NoSeed if you never called set_seed_bytes. The wallet returns WalletError::AllZeroSeed for an all-zero seed on every path. A bad mnemonic now returns BuilderError::InvalidMnemonic and does not panic. (cddc0f0)
  • The Nostr identity changed. NostrDlc::new now derives its identity at the NIP-06 path m/44'/1237'/0'/0/0 and no longer uses the wallet's BIP32 master key. This gives the node a new Nostr public key. See Upgrading. (bf641e6)
  • Offer validation takes the clock. OfferDlc::validate, the stateless validate_offer, and AcceptOfferParams now take now_unix. (9d825c0)
  • OfferedContract::new returns Result. It used to assert. (4e8875e)
  • Kormir storage API. Storage::save_announcement no longer takes nonce indexes. OracleEventData no longer stores them. Storage::get_next_nonce_indexes is deprecated, has a default body, and nothing calls it. The new Error::EventAlreadyExists variant is added. (5866fdb)
  • Balance types. On crate::Balance, the contract field is replaced by contract_confirmed and contract_pending. Two new fields, spendable and reserved, are added. The wallet API returns the new WalletBalance. (32065f3, 6d7c0f6)
  • Messages have a TLV stream field. OfferDlc, AcceptDlc, and SignDlc have a new trailing TlvStream field. The stored OfferedContract, AcceptedContract, and SignedContract structs have one too. Code that builds these structs with literals must set the new field. (dd1964c, c067a8d, c5613e1)
  • Manager commit hooks. commit_offer, commit_accept, and commit_sign take back the final message, so the application can add records before the contract is stored. (98a2e1a)
  • ddk-node defaults. The gRPC server now binds 127.0.0.1:3030, not 0.0.0.0:3030. The network option accepts only regtest and signet. (4e046d7)

🔒 Security Fixes

  • Oracle nonce reuse (KEY-005, SIG-002, SIG-001). Kormir used to derive each nonce key as a hardened child at a counter index that the storage gave out. Two storage states on the same signing key could both give out index 0: a restore from seed, a hot standby, or a restarted MemoryStorage. They then announced the same nonce for two events, and the two attestations revealed the signing key. Nonce keys are now HMAC-SHA256(nonce_xpriv, "kormir/nonce/v1" || event_id || position). Storage rejects a second announcement of the same event id with Error::EventAlreadyExists. Signing derives the keys again from the event id and checks them against the announced nonces. For an announcement that this oracle did not make, signing returns InvalidNonces. (5866fdb)
  • Offers on matured events (FND-002). OfferDlc::validate used to compare only the offer's locktimes with each other. As a result, an offer on an event that was already attested passed every check, and the offering party knew the outcome before the accepting party locked collateral. validate now rejects an offer whose closest oracle event maturity is less than or equal to now_unix. The manager passes its Time provider when it receives an offer. It does the check again against the stored CET locktime in accept_contract_offer, because the event can mature while the offer waits. (9d825c0)
  • One malformed accept stopped all DLC message processing. The enum, numeric trie, and settlement paths indexed the peer-supplied adaptor signature list with no length check. A short list in AcceptDlc or SignDlc caused a panic in the message handler, and the listener did not restart. Every indexed access now does a bounds check and returns an invalid-argument error. The Nostr and Lightning transports run each inbound message in its own task and log a panic, so a panic no longer stops the transport. The Nostr reply path logs send failures and does not unwrap them. (3f6a7d9)
  • One bad offer request stopped settlement. OfferedContract::new asserted on the announcement shape, and the DDK actor always wrapped the flat announcement list into one inner list. An offer with more than one contract info therefore caused a panic in the task that also runs the periodic checks, and settlement stopped until a restart. offer_contract now checks the shape before it reserves wallet inputs. The new public group_announcements groups the flat list per contract info by oracle count and event id. The actor runs each message in its own task, so a panic goes back to the caller as an error. (4e8875e)
  • Wallet with an all-zero seed. Before this fix, a consumer that forgot set_seed_bytes got a working wallet. Its wallet and contract keys were public knowledge. See the breaking change above. The READMEs and the Lightning and Postgres examples now generate the LightningTransport seed at random. They no longer ship an all-zero literal that gave every copy the same node identity. (cddc0f0)
  • Nostr key was the wallet master key. The Nostr transport identity, the BIP84 wallet, and every contract key used one secret. The Nostr public key was the wallet's master public key, up to parity. The identity now comes from a separate hardened NIP-06 path. (bf641e6)
  • ddk-node hardening. The gRPC server has no authentication and no TLS, and it can spend the wallet, so it now binds loopback. seed.ddk is created owner-only (it used to use the umask, usually 0644). An existing file with a wider mode is tightened when it is read. A seed file with a length other than 64 bytes is now an error, not a panic. gRPC handlers return invalid_argument, not_found, or internal statuses. They no longer unwrap bad public keys, contract ids, addresses, JSON bodies, or storage failures. (4e046d7)
  • Dependency advisories.
    • nostr moves to 0.44.8 and nostr-relay-pool to 0.44.3. These fix eleven advisories on the DLC transport's inbound path: NIP-04 parsing crashes and memory amplification from any DM sender, and relay event verification bypass. h2 moves to 0.4.19 and crossbeam-epoch to 0.9.21. The yanked spin and async-utility are replaced, and scc and event-listener get soundness fixes. The nostr minimum in ddk and kormir is raised, so a fresh resolution cannot select the yanked versions. A new CI job runs cargo audit. The only advisory that remains is rsa 0.9.10, which has no fix and comes in only through sqlx-mysql, which no crate enables. (a8b7f13)
    • rustls moves to 0.23.45 for RUSTSEC-2026-0285, TLS 1.3 handshake messages accepted across encryption levels. inquire 0.9 removes the unmaintained fxhash (RUSTSEC-2025-0057) from ddk-node. sled still pulls in fxhash. (3ae8198)

✨ New Features

Custom TLV records on DLC messages

  • TlvStream on offer, accept, and sign. A record that a peer appended to a message used to be dropped silently. For example, node-dlc appends records per dlcspecs PR #163. OfferDlc, AcceptDlc, and SignDlc now keep all trailing records in wire order, with duplicates, and write them back byte for byte. get and set use TlvRecord::TYPE_ID. An empty stream writes zero bytes, so messages without records encode the same as in rc.3. PROTOCOL_VERSION stays at 1. (dd1964c, c067a8d)
  • Records persist on stored contracts. OfferedContract, AcceptedContract, and SignedContract each keep the stream of their message. When the manager rebuilds a message from storage (for example, for a splice), the records are restored. (c5613e1)
  • Applications can add records before sending. See commit_offer, commit_accept, and commit_sign above. (98a2e1a)
  • Use the serialization macros from other crates. impl_dlc_writeable! and impl_dlc_tlv_record! now use $crate for all paths, and lightning is re-exported. A crate whose only dependency is ddk can define a record with one import: use ddk::ddk_messages::{impl_dlc_tlv_record, impl_dlc_writeable};. (d99c031)

Wallet

  • UTXO locking. Coin selection now runs in the wallet actor and skips locked outpoints. Selected outpoints are locked through BDK and persisted, so two concurrent offers cannot fund themselves with the same coins. (fe5936c) When the wallet signs a PSBT, it locks the signed funding inputs. The locks are released when a confirmed transaction spends the outpoint. locked_outpoints() shows the reserved coins. (d8f7a03) unreserve_utxos now unlocks coins, and unlock_outpoints waits for persistence. (b2537b0)
  • Contract UTXO tracker. The 2-of-2 funding output is invisible to the BDK wallet, so the new tracker indexes it by contract id beside the wallet (45b7e2b). Each sync runs a targeted Esplora request over the funding SPKs and outpoints, which finds both confirmation and spend (CET, refund, or counterparty close) in one round trip (da4591d). The tracker state is persisted through new Storage trait methods. These methods default to a no-op, so external backends still compile (475d433). contract_utxos() on the wallet and on DlcDevKit lists the funding outputs and their chain state (c7b472f).
  • Contract balance from chain data. contract_confirmed and contract_pending are now calculated from the tracked funding outpoints against the wallet's local chain. They are no longer calculated from collateral values. PnL still comes from contract state. (32065f3)
  • Spendable and reserved balance. Locked coins are no longer counted as available. (6d7c0f6)
  • Wallet events. Sync applies updates with apply_update_events and sends TxConfirmed, TxUnconfirmed, TxReplaced, TxDropped, and ChainTipChanged on a broadcast channel through subscribe_events. A transaction event starts the manager's periodic check immediately, without a wait for the next timer tick. (b47d930)
  • Live fee estimates. Each sync gets Esplora's fee estimates and puts them in a lock-free cache. The old constants stay as floors and are the fallback until an estimate arrives. Every confirmation target now has an entry. Before, MaximumFeeEstimate and OutputSpendingFee caused a panic. A failed fetch never fails a sync. (7ec5ea7)
  • Coin control. send_with_coin_control takes caller-selected UTXOs, an exclusion list, and a minimum number of confirmations. send_to_address and send_all are thin wrappers around it. (602c77f)
  • Fee bumping. bump_fee builds, signs, broadcasts, and persists an RBF replacement of an unconfirmed wallet transaction. (ce73284)
  • Configurable minimum change. The 25,000 sat minimum change size is now WalletConfig / new_with_config and Builder::set_min_change_size. The default does not change. (1b93a71)
  • BIP-329 labels. Labels for transactions, addresses, and outputs are stored through the Storage trait in memory, sled, and Postgres. The trait methods default to an empty store (456dfee). DlcDevKit has set_label, labels, and delete_label. Each sync labels the funding transaction and outpoint with the contract id, and labels the CET of a closed contract with the attested outcome. Labels export and import as BIP-329 JSONL (759f12d).

Storage

  • Columnar Postgres contract layout. The new dlc_contracts table has one row per contract. The offer, accept, and sign messages are stored byte for byte, with their TLV streams. The manager-only state has one typed column per field. ContractRow::from_contract is the only writer and ContractRow::into_contract is the only reader. A format_version column protects the next layout change. Closes #190. (7b66a3b, 30f861b, ac38ecf)

🐛 Bug Fixes

  • P2SH-wrapped funding inputs. redeem_script_to_script_sig wrapped the redeem script in a new witness program, so a 22-byte P2SH-P2WPKH redeem script caused a panic in WitnessProgram::new. Any input with a redeem script crashed the accepting side, including one that a counterparty sent. The script signature is now a single push of the redeem script, and an oversized script returns an error. The manager clears script signatures before it builds the funding PSBT and restores them on extraction. (aa38fc9) New stateless tests fund both sides from BDK BIP49 wallets and cover the sh(wpkh()) descriptor path. PartySetup has a new payout_spk. (fc32490)
  • Single-funded CET fees. In a single-funded contract, the funding party priced only its own payout script. Every CET that paid the counterparty was signed below the agreed fee rate. The new PartyParams::get_change_output_and_fees_with_counterparty adds the counterparty's payout script when the caller funds the whole contract. create_dlc_transactions always uses it, and the required-amount estimate reserves the same weight. The original method keeps its signature. (91f8dcd)
  • Splices that spend several contracts. Both splice paths used find(), so only the first previous contract moved to PreClosed. The other contracts stayed Confirmed, and their watchers tried to settle outputs that were already spent. A shared helper now pre-closes every Confirmed contract that the signed splice spends. (8711b23)
  • Full scan skipped change outputs. The initial full scan now also scans the internal keychain, so a wallet restored from seed finds its change. The stop gap goes from 10 to 50, and the scan makes five parallel Esplora requests. (782d6da)
  • Unconfirmed transactions were invisible until the next block. Sync returned early when the tip height did not change. Sync now always runs. (1e65868)
  • Stuck pending balance. Incremental syncs send the expected txids, so evicted or replaced transactions leave the canonical view. (65aae02)
  • Incremental sync wrote last-active indices. Only a full scan sets them now. Incremental syncs use the same request parallelism as full scans. (5bc9993)
  • Postgres dropped UTXO locks. A new locked_outpoints table keeps locks through a restart. (25a65ce)
  • Send paths lost the change index. One shared helper now persists the wallet after it builds, signs, and broadcasts a transaction. (6053d10)
  • Funding PSBT signed once per input. Each sign_psbt_input call cloned and signed the full PSBT. The PSBT is now signed in place once, and finalized inputs are skipped. (7fe8e8f)
  • Oversized TLV stream was truncated. A stream larger than MAX_TLV_STREAM_SIZE decoded with its end dropped. It is now rejected. TlvStream::set now replaces all records of a type, not only the first. (ab98fed)
  • Sled and Postgres state queries. These queries decoded the struct directly and did not recognize the versioned blob marker. They now decode through Contract::deserialize and return the TLV streams. (55c4c7d)
  • Stored failed contracts. A loaded FailedAcceptContract or FailedSignContract put its error string into the TLV stream. Their messages are now length-framed, with a fallback for the old layout. (c5613e1)

🔧 Other Changes

  • Contract now owns the contract blob encoding. Each state struct returns its own streams, and the blob starts with an explicit version marker. The wire macros have a declarative default field kind. (3c20459)
  • bdk_wallet minimum is now 3.1.0, for the add_foreign_utxo and Utxo::txout fixes on the splice paths. (6b7b7ee)
  • The test fixtures move from testconfig into ddk-testenv, which exposes the contract binaries through a ContractBinary enum. The dead old/ fixtures are deleted. The pre-contract_flags offer stays at legacy/Offered. (b205a6a)
  • release.js now retries on the cargo package-cache lock, finds the correct commit range for release notes, and fails a --dry run on real publish errors. (ad59dee)
  • Documentation and comment cleanup. (c826547, d267abe, 4ef7abb, d566942, 9640c62, fedabee)

Upgrading

Stored contracts (sled, memory, Postgres). Contracts stored by earlier versions still load. Blobs written before the version marker start with the state prefix, which is never zero, so they decode through the old path. A contract with no TLV records keeps the same bytes that earlier versions wrote. The stored_contracts_round_trip_byte_for_byte test checks this against the checked-in fixtures. (3c20459, c5613e1)

Postgres contract migration. The old blob tables stay as the version 1 layout. When the store opens with migrations on, it moves each blob into a dlc_contracts row, with one transaction per contract. It reports each row that it cannot move and continues. A row still in the old layout loads through a fallback and moves on its next update. The store logs a warning at startup, and on each legacy read, while any contract is not moved. To run the move by itself and print the report, use ddk-node migrate. Migrations 0007 to 0010 add locked_outpoints, the contract tracker, wallet labels, and dlc_contracts. Refer to docs/postgres-contract-migration.md. (7b66a3b, 30f861b)

Wire compatibility. PROTOCOL_VERSION stays at 1. An empty TLV stream writes zero bytes, so offers, accepts, and signs without records have the same bytes as in rc.3, and rc.3 peers interoperate. Old-format offers without protocol_version still decode with an empty stream. (dd1964c, c067a8d, 9640c62)

Kormir oracles. Nonces now come from the event id. rc.4 cannot sign an announcement made by an earlier Kormir version with counter-derived nonces: signing returns InvalidNonces. Attest all pending events before you upgrade an oracle. Do not use the same event id again for a signing key. Storage now rejects it with Error::EventAlreadyExists. (5866fdb)

Nostr identity. After you upgrade, a node gets a new Nostr public key from the NIP-06 path. If your peers already know the old key, use NostrDlc::new_with_derivation with the deprecated NostrKeyDerivation::Master to keep the old identity. (bf641e6)

Seeds. You must call Builder::set_seed_bytes. A node that ran with the implicit all-zero seed has keys that anyone can derive. Move its funds to a wallet with a real seed. (cddc0f0)

ddk-node. To reach the gRPC server from another host, put it behind an authenticated, TLS-terminating proxy. It now binds 127.0.0.1 only. An existing seed.ddk file is tightened to owner-only permissions when it is read. (4e046d7)

Custom storage backends. The new Storage methods for the contract tracker and BIP-329 labels have default bodies, so external backends still compile. Without tracker persistence, the tracker rebuilds from contract storage and a chain sync. (475d433, 456dfee)

Installation

Add DDK to your Cargo.toml:

[dependencies]
ddk = "2.0.0-rc.4"

Individual crates are also available at the same version:

[dependencies]
ddk-manager = "2.0.0-rc.4"
ddk-messages = "2.0.0-rc.4"
ddk-dlc = "2.0.0-rc.4"
ddk-trie = "2.0.0-rc.4"
ddk-payouts = "2.0.0-rc.4"
kormir = "2.0.0-rc.4"

To install the node:

cargo install ddk-node --version 2.0.0-rc.4