Repository navigation
v2.0.0-rc.4
Pre-releaseDLC 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, thechannelmodule, and their tests and stored fixtures. Only contracts remain. (2ceea52) Builderrequires a seed.Builder::defaultno longer starts from 64 zero bytes.finishreturnsBuilderError::NoSeedif you never calledset_seed_bytes. The wallet returnsWalletError::AllZeroSeedfor an all-zero seed on every path. A bad mnemonic now returnsBuilderError::InvalidMnemonicand does not panic. (cddc0f0)- The Nostr identity changed.
NostrDlc::newnow derives its identity at the NIP-06 pathm/44'/1237'/0'/0/0and 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 statelessvalidate_offer, andAcceptOfferParamsnow takenow_unix. (9d825c0) OfferedContract::newreturnsResult. It used to assert. (4e8875e)- Kormir storage API.
Storage::save_announcementno longer takes nonce indexes.OracleEventDatano longer stores them.Storage::get_next_nonce_indexesis deprecated, has a default body, and nothing calls it. The newError::EventAlreadyExistsvariant is added. (5866fdb) - Balance types. On
crate::Balance, thecontractfield is replaced bycontract_confirmedandcontract_pending. Two new fields,spendableandreserved, are added. The wallet API returns the newWalletBalance. (32065f3, 6d7c0f6) - Messages have a TLV stream field.
OfferDlc,AcceptDlc, andSignDlchave a new trailingTlvStreamfield. The storedOfferedContract,AcceptedContract, andSignedContractstructs 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, andcommit_signtake back the final message, so the application can add records before the contract is stored. (98a2e1a) ddk-nodedefaults. The gRPC server now binds127.0.0.1:3030, not0.0.0.0:3030. The network option accepts onlyregtestandsignet. (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 nowHMAC-SHA256(nonce_xpriv, "kormir/nonce/v1" || event_id || position). Storage rejects a second announcement of the same event id withError::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 returnsInvalidNonces. (5866fdb) - Offers on matured events (FND-002).
OfferDlc::validateused 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.validatenow rejects an offer whose closest oracle event maturity is less than or equal tonow_unix. The manager passes itsTimeprovider when it receives an offer. It does the check again against the stored CET locktime inaccept_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
AcceptDlcorSignDlccaused 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::newasserted 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_contractnow checks the shape before it reserves wallet inputs. The new publicgroup_announcementsgroups 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_bytesgot 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 theLightningTransportseed 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-nodehardening. The gRPC server has no authentication and no TLS, and it can spend the wallet, so it now binds loopback.seed.ddkis created owner-only (it used to use the umask, usually0644). 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 returninvalid_argument,not_found, orinternalstatuses. They no longer unwrap bad public keys, contract ids, addresses, JSON bodies, or storage failures. (4e046d7)- Dependency advisories.
nostrmoves to 0.44.8 andnostr-relay-poolto 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.h2moves to 0.4.19 andcrossbeam-epochto 0.9.21. The yankedspinandasync-utilityare replaced, andsccandevent-listenerget soundness fixes. Thenostrminimum inddkandkormiris raised, so a fresh resolution cannot select the yanked versions. A new CI job runscargo audit. The only advisory that remains isrsa0.9.10, which has no fix and comes in only throughsqlx-mysql, which no crate enables. (a8b7f13)rustlsmoves to 0.23.45 for RUSTSEC-2026-0285, TLS 1.3 handshake messages accepted across encryption levels.inquire0.9 removes the unmaintainedfxhash(RUSTSEC-2025-0057) fromddk-node.sledstill pulls infxhash. (3ae8198)
✨ New Features
Custom TLV records on DLC messages
TlvStreamon 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, andSignDlcnow keep all trailing records in wire order, with duplicates, and write them back byte for byte.getandsetuseTlvRecord::TYPE_ID. An empty stream writes zero bytes, so messages without records encode the same as in rc.3.PROTOCOL_VERSIONstays at 1. (dd1964c, c067a8d)- Records persist on stored contracts.
OfferedContract,AcceptedContract, andSignedContracteach 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, andcommit_signabove. (98a2e1a) - Use the serialization macros from other crates.
impl_dlc_writeable!andimpl_dlc_tlv_record!now use$cratefor all paths, andlightningis re-exported. A crate whose only dependency isddkcan 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_utxosnow unlocks coins, andunlock_outpointswaits 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
Storagetrait methods. These methods default to a no-op, so external backends still compile (475d433).contract_utxos()on the wallet and onDlcDevKitlists the funding outputs and their chain state (c7b472f). - Contract balance from chain data.
contract_confirmedandcontract_pendingare 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_eventsand sendsTxConfirmed,TxUnconfirmed,TxReplaced,TxDropped, andChainTipChangedon a broadcast channel throughsubscribe_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,
MaximumFeeEstimateandOutputSpendingFeecaused a panic. A failed fetch never fails a sync. (7ec5ea7) - Coin control.
send_with_coin_controltakes caller-selected UTXOs, an exclusion list, and a minimum number of confirmations.send_to_addressandsend_allare thin wrappers around it. (602c77f) - Fee bumping.
bump_feebuilds, 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_configandBuilder::set_min_change_size. The default does not change. (1b93a71) - BIP-329 labels. Labels for transactions, addresses, and outputs are stored through the
Storagetrait in memory, sled, and Postgres. The trait methods default to an empty store (456dfee).DlcDevKithasset_label,labels, anddelete_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_contractstable 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_contractis the only writer andContractRow::into_contractis the only reader. Aformat_versioncolumn protects the next layout change. Closes #190. (7b66a3b, 30f861b, ac38ecf)
🐛 Bug Fixes
- P2SH-wrapped funding inputs.
redeem_script_to_script_sigwrapped the redeem script in a new witness program, so a 22-byte P2SH-P2WPKH redeem script caused a panic inWitnessProgram::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 thesh(wpkh())descriptor path.PartySetuphas a newpayout_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_counterpartyadds the counterparty's payout script when the caller funds the whole contract.create_dlc_transactionsalways 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 toPreClosed. The other contracts stayedConfirmed, and their watchers tried to settle outputs that were already spent. A shared helper now pre-closes everyConfirmedcontract 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_outpointstable 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_inputcall 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_SIZEdecoded with its end dropped. It is now rejected.TlvStream::setnow 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::deserializeand return the TLV streams. (55c4c7d) - Stored failed contracts. A loaded
FailedAcceptContractorFailedSignContractput its error string into the TLV stream. Their messages are now length-framed, with a fallback for the old layout. (c5613e1)
🔧 Other Changes
Contractnow 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 declarativedefaultfield kind. (3c20459)bdk_walletminimum is now 3.1.0, for theadd_foreign_utxoandUtxo::txoutfixes on the splice paths. (6b7b7ee)- The test fixtures move from
testconfigintoddk-testenv, which exposes the contract binaries through aContractBinaryenum. The deadold/fixtures are deleted. The pre-contract_flagsoffer stays atlegacy/Offered. (b205a6a) release.jsnow retries on the cargo package-cache lock, finds the correct commit range for release notes, and fails a--dryrun 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