Skip to content

ddk-manager: let records on the offer shape the CETs, and settle every contract info - #224

Merged
bennyhodl merged 7 commits into
masterfrom
feat/cet-construction-records
Oct 13, 2026
Merged

bennyhodl merged 7 commits into
masterfrom
feat/cet-construction-records

Conversation

@bennyhodl

@bennyhodl bennyhodl commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a way for TLV records on an offer to change the CETs a contract builds, with PayoutScriptOverrides as the first record: the CETs of named outcomes, an enum outcome or a range of numeric values, pay a third-party script in place of the accepting party (e.g. a loan liquidation paying the liquidator). Along the way it fixes a pre-existing bug where a contract with several contract infos could only settle on its first one, and moves funding/CET/refund construction into one builder.

Changes

  • Multi-contract-info settlement fix. The manager signed adaptor signatures, verified them, and picked the settlement CET with each contract info's local CET indexes against the whole CET list. Both sides were wrong the same way, so signatures verified, but a contract settling on a later contract info broadcast the wrong CET. Each site now uses the contract info's own CET slice. CET ranges come from payout counts and adaptor offsets from adaptor infos, so stored contracts need no migration and nothing new is serialized.
  • One transaction builder. contract::transactions::build_contract_transactions replaces three copies (manager accept, manager sign, stateless module). It owns the "no contract info", "no CET" and collateral-total checks. Its BuildError keeps the stateless API returning InvalidOffer / InvalidAccept. The collateral sum is checked, so an accept with u64::MAX collateral is an invalid accept instead of a panic.
  • CET records. contract::cet_records defines CetRecord: validate runs on the offer alone when it arrives, and apply rewrites the built CETs once both parties are known. A record type that appears twice is rejected, because this crate reads the first copy and node-dlc the last. An offer without records builds exactly what it built before.
  • PayoutScriptOverrides (TLV type 65005, agreed with node-dlc) in dlc-messages. The type is odd, so a peer that doesn't know the record carries it through and then fails signature verification. node-dlc has to match the new encoding, which carries numeric ranges and is pinned byte for byte. An override names an enum outcome or a numeric range; a range covers every CET whose values lie inside it. A CET's values are the ones it settles: with oracles of different digit counts, the last CET also settles every value above the curve, so its range runs to the largest value an oracle can attest. Each overridden CET is rebuilt with create_cet rather than patched by script matching, which broke when both parties share a payout script. The following are rejected:
    • an override that covers no CET, an empty script, a range that starts after it ends, a range covering only part of a CET's values, or two overrides covering one CET: invalid offer;
    • an override covering a CET that pays the accepter under the 1000-sat dust limit: invalid offer, because that CET has no accepter output to redirect and the third party would silently get nothing. Outcomes without an override are unaffected.
  • CET fee for longer override scripts. An override script may be longer than the accepter's payout script, e.g. a liquidator's taproot address with a P2WPKH lender. The funding transaction reserves the CET fee for the accepter's output at the longest of the accepter's payout script and every override script. Whoever pays for the accepter's output pays the difference: the accepter when both fund, the offerer when it funds everything. CetRecord::accept_payout_script_len reports the length, and the _with_fee_rule constructors in ddk-dlc take it. Offers without overrides fund exactly as before. node-dlc has to price funding the same way. ddk_dlc::DUST_LIMIT is now public.
  • Allowed offer TLV types (behaviour change). A record can redirect what the accepting party is paid, and the library applies every record it knows. So the manager now takes TLV records on an offer only of types the application allows, with Manager::with_allowed_offer_tlv_types or the builder's set_allowed_offer_tlv_types. None is allowed by default: an offer received or committed with any other type is rejected. That includes the application records offers have been able to carry since rc.4, so an application that sends one must now list its type. Checking the records an offer carries before accepting it is still the application's job. The stateless module is unchanged, because its caller holds the offer.
  • MemoryOracle returns an error for an event it hasn't signed, as a networked oracle does, instead of an attestation with no signatures.

Testing

  • ddk/tests/fixtures/contract_transactions/ pins every existing contract shape (enum/numerical, dual/single funded, disjoint, spliced, refund-to-accepter): a wire-encoded offer and accept per shape (<shape>.offer.bin, <shape>.accept.bin) and the transaction digests in digests.json, all written by the builder before the move. The moved builder reproduces them byte for byte.
  • cargo test --all-features passes. cargo fmt --check and cargo clippy -- -D warnings pass.
  • New stateless tests settle an overridden enum outcome and an overridden numeric range from both sides, keep the offering party's output when both parties share a script, keep the offer's CET fee rate with a taproot override for a P2WPKH accepter, and cover each rejection, including collateral overflow. The overflow test panics without the fix.
  • Bitcoind-backed manager tests:
    • The override tests settle an overridden outcome from each side, with and without manual close.
    • New tests settle a disjoint contract on its second contract info, enum and numerical, from both sides.
    • Every close path asserts that the broadcast CET belongs to the attested contract info.
  • Not fully verified:
    • Manager suite incomplete. The full --ignored manager_execution_tests run was interrupted after 32 passing tests. enum_and_numerical_with_diff_3_of_5_test and enum_single_oracle_bad_accept_cet_sig_test failed while a second bitcoind/electrs suite ran at the same time.
    • enum_and_numerical_with_diff_3_of_5_test reruns. Rerun alone, it passed once. It failed twice with electrs dropping the connection in generate_blocks, but those reruns also overlapped the full suite. It needs a clean run, and enum_single_oracle_bad_accept_cet_sig_test hasn't been rerun.
    • Stateless suite incomplete. --ignored stateless_execution passed up to and including the slow splice_out_numeric_with_difference_three_of_five_oracles_closes, which takes ~640s on both this branch and master. The tests after it haven't run on the final commit.
    • Master behaviour unconfirmed. The second-contract-info tests haven't been run against master to confirm they fail there.

The transaction builder is about to move out of the stateless module and
the manager into one place. These digests, replayed from pinned offer and
accept messages, are the proof that the move changes no transaction of an
existing contract: enum and numerical, dual and single funded, disjoint,
spliced, and with the refund-to-accepter flag.
…wn CETs

A contract with several contract infos holds their CETs one after another,
and every contract info indexes its CETs from zero. The manager used those
indexes against the whole list when the offering party signed its adaptor
signatures, when the accepting party verified them, and when either party
picked the CET to settle with. Both sides were wrong the same way, so the
signatures verified, and a contract could only ever settle on its first
contract info: the other side's CET did not match the decrypted signature,
or, when the adaptor indexes happened to line up, the first contract
info's CET was broadcast in its place.

Each site now works on the contract info's own slice. The slices follow
from each contract info's payout count and the adaptor signature offsets
from its adaptor info, so stored contracts need no migration and nothing
new is serialized. Finding the offsets from a contract info means
comparing it with the contract's, hence PartialEq on the contract info
types.

MemoryOracle reported an event it had not signed as an attestation with no
signatures, which validates and so stops the periodic check at the first
contract info. It now reports it as an error, as a networked oracle does.

The manager execution tests gain a disjoint contract whose first contract
info never attests, settled from both sides, by hand and by the periodic
check, with the second info enum or numerical. Every close path now also
checks that the broadcast CET belongs to the attested contract info.
@bennyhodl
bennyhodl force-pushed the feat/cet-construction-records branch 2 times, most recently from c852ff4 to 965586c Compare October 9, 2026 15:18
A TLV record for an offer naming the outcomes whose CETs pay a script in
the accepting party's place, for products that settle an outcome to a
third party: an outcome of an enum descriptor, or a range of the values
of a numeric one. Only the wire form lives here; how it changes the CETs
is the manager's concern.

The type number is the one agreed with node-dlc, 65005. It is odd, so a
peer that does not know the record carries it through and then fails
signature verification, which is the safe failure. The record redirects
what the accepter is paid, so an accepter has to check the overrides on
an offer before accepting it. The encoding is pinned byte for byte, for
node-dlc to match.
Accepting an offer, signing an accept and the stateless contract module
each built the funding transaction, the CETs and the refund transaction
themselves: the same choice of plain or spliced constructor for the first
contract info, the same loop adding every further info's CETs after it.
Three copies of what both parties have to agree on byte for byte.

`contract::transactions::build_contract_transactions` is now the one
builder. It takes both parties, the contract infos and the offer's terms,
returns the transactions with each contract info's CET range, and owns
the checks the copies made or skipped: no contract info, no CET, and
collaterals that do not add up to the offer's total. Its `BuildError`
keeps those apart from construction failures, so the stateless module
still reports an invalid offer or an invalid accept rather than a
generic DLC error.

The CETs of every contract info after the first keep their locktime of
zero and their copy of the first CET's input. That is how every stored
disjoint contract was built, and the pinned transaction fixtures hold the
builder to it.

@matthewjablack matthewjablack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation makes a lot of sense.

One thing: the override script length check means a liquidator with a taproot address fails the accept when our payout script is P2WPKH. So either Taproot needs to be prevented application side, or we allow different lengths. What we could do is, at funding time, size the CET fee off the longest script among the accepter's payout script and the overrides, then drop the check.

Also, an override on a CET whose accept payout is under dust silently drops the liquidator output. We should probably fail this in the validate stage.

Comment thread ddk-manager/src/contract/cet_records.rs Outdated
// Funding priced the CET fee from the accepting party's payout
// script; a longer script in its place underpays the fee rate.
let accept_script_len = contract.accept.payout_script_pubkey.len();
if override_.script_pubkey.len() > accept_script_len {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we size the CET fee off the longest script at funding time instead of rejecting here?

By having this check, it's possible for the third party's address type to fail the accept. If the accepter's payout script is P2WPKH (22 bytes) and an override script is taproot or P2WSH (34 bytes), the accept gets rejected, and the offerer doesn't always control what address the third party hands them. The fee only got reserved off the accepter's own script when the funding tx was built, here:

dlcdevkit/dlc/src/lib.rs

Lines 424 to 440 in 24cff24

// size of the payout script pubkey scaled by 4 from vBytes to weight units
let output_spk_weight =
self.payout_script_pubkey
.len()
.checked_mul(4)
.ok_or(Error::InvalidArgument(format!(
"Output spk checked multiplication failed: {} * 4",
self.payout_script_pubkey.len()
)))?;
// A party funding the whole contract also pays for the counterparty's
// payout output, which nobody else prices.
let counterparty_spk_weight = match counterparty_payout_script_pubkey {
Some(script_pubkey) if self.collateral == total_collateral => script_pubkey
.len()
.checked_mul(4)
.ok_or(Error::InvalidArgument(format!(

So if the offer carries overrides, we could price the CET off the longest script among the accepter's payout script and every override script. Then this check can go, and the only cost is a few sats per CET when the accepter's own script is the shorter one.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Done in dbecd57, the way you described. The length check is gone. A CetRecord now reports the longest script it pays in the accepter's place (accept_payout_script_len), and the builder has funding reserve the accepter's CET output fee at the longest of that and the accepter's own script. The extra goes to whoever already pays for the accepter's output: the accepter when both fund, the offerer when it funds everything. Offers without overrides fund exactly as before, and the pinned fixtures still match.

a_payout_script_override_longer_than_the_accept_script_keeps_the_cet_fee_rate signs a CET paying a taproot override for a P2WPKH accepter and checks it meets the offer's fee rate. Without the fee change it fails (a 180-vbyte CET paying 340 sats at 2 sat/vB). node-dlc has to price funding the same way; it's written up in the payout_overrides module docs.

One gap is left as is: coin selection still estimates with a P2WPKH script, so a party whose inputs only just cover the estimate can fail with "input amount is less than required" when a taproot override is present. That estimate already ignores the wallet's own script type.

Comment thread ddk-manager/src/contract/cet_records.rs Outdated
}
let mut covers_any = false;
for (index, contract_info) in contract_infos.iter().enumerate() {
for (position, _) in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also be rejecting an override on a CET whose accept payout is under dust here?

covered_cets already hands back the payout, so it's for (position, payout) in ... and a check that payout.accept >= DUST_LIMIT. Right now the override is accepted, then create_cet drops the output as dust, so the third party silently ends up with nothing and nobody gets an error.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Done in dbecd57. validate rejects an override covering any CET whose accept payout is under DUST_LIMIT (now public in ddk-dlc) as an invalid offer. Outcomes without an override are unaffected, so a loan whose borrower-favourable outcomes pay the lender nothing still works. A numeric range can't run into the part of the curve where the accepter gets under dust; it has to end before it. The invalid-offer test has an enum case and a numeric case, and each fails without the check.

A CET pays the two parties and nothing else, and a product settled by a
DLC can need an outcome to pay someone else: a loan whose liquidation
pays the liquidator in the lender's place. Both parties must build the
same CETs or their adaptor signatures do not verify, so what changes a
CET has to travel on the offer, and it is protocol, not a per-node
option.

`contract::cet_records` is the extension point. A `CetRecord` is a TLV
record on the offer with a `validate` that runs when the offer arrives,
before any accept exists, and an `apply` that rewrites the built CETs
with both parties in hand. `CET_RECORDS` is the registry: the decoders of
the record types the library knows, in the order the records apply. A
record type that appears twice is rejected, since this crate reads the
first copy and node-dlc the last. Adding a record is a type in
dlc-messages, a `CetRecord` impl and an entry in the registry; the module
documents what a record may change and the fee it has to stay within.
The builder applies the records once for every path, the manager and the
stateless module validate them when an offer arrives, and an offer
without records builds exactly what it built before.

The library applies every record it knows, whichever party wrote it, and
a record can redirect what the accepting party is paid. So the manager
takes TLV records on an offer only of the types the application allows
with `Manager::with_allowed_offer_tlv_types`, or the builder's
`set_allowed_offer_tlv_types`. None is allowed by default: an offer
received or committed with a record of any other type is rejected. That
includes the application records offers could carry since rc.4, so an
application sending one now lists its type. Checking the records an
offer carries before accepting it stays the application's job.

`PayoutScriptOverrides` is the first record. For each CET an override
covers, the output that would have paid the accepting party pays the
override script, in every contract info the override matches. An enum
override covers its outcome's CET; a numeric one covers every CET whose
values lie in its range, and a range that covers part of a CET's values
is an invalid offer, since that CET would have no single script. A CET's
values are the ones it settles: when the oracles use different numbers
of digits, the last CET also settles every value above the curve that
the oracles with more digits can attest, so its range runs to the
largest of those. The CET is rebuilt with `create_cet` rather than
patched by matching scripts, which breaks when both parties pay out to
the same one. An override that covers no CET, an empty script, a range
that starts after it ends, two overrides covering one CET, and an
override covering a CET that pays the accepting party under the dust
limit, which would silently pay the script nothing, are invalid offers.

An override script may be longer than the accepting party's payout
script: a liquidator's taproot address with a P2WPKH lender. A
`CetRecord` reports the longest script it pays in the accepting party's
place, and the builder has funding reserve the CET fee for the accepting
party's output at the longest of that and the party's own script. The
`_with_fee_rule` constructors in ddk-dlc take that length. Whoever pays
for the accepting party's output pays the difference: the accepting
party when both fund, the offering party when it funds the whole
contract. An offer without overrides funds exactly as before. ddk-dlc's
`DUST_LIMIT` is public now, and the manager's contract input uses it in
place of its own copy.

Tests settle an overridden enum outcome from both sides through the
stateless module and through the manager, and an overridden numeric range
through the stateless module, keep the offering party's output when both
parties share a script, keep the offer's CET fee rate with a taproot
override for a P2WPKH accepting party, and cover each rejection.
The builder added the two parties' collateral with `Amount`'s `+`, which
panics on overflow. The accepting party picks its own collateral, so an
accept of `u64::MAX` against any offer took the node down instead of
being rejected. The sum is now checked, and an overflow is the same
invalid accept as a collateral that does not complete the total.
The override tests attested a random outcome, and only the outcomes that
pay the accepting party are overridden, so half the runs settled on a CET
the record never touched and passed with or without it. They now attest
an overridden outcome, settle it from each side, and check the CET pays
the whole collateral to the third party.
@bennyhodl
bennyhodl force-pushed the feat/cet-construction-records branch from 24cff24 to 8fad012 Compare October 12, 2026 12:25
@bennyhodl
bennyhodl merged commit 789c9bf into master Oct 13, 2026
147 checks passed
bennyhodl added a commit that referenced this pull request Oct 15, 2026
…g requirements

A party whose inputs could not cover its collateral plus its fee share got
an InvalidArgument string, so callers parsed `required_input_funds=` out of
it, and coin selection called the public
get_change_output_and_fees_with_counterparty with a fabricated input amount.

ddk-dlc now returns Error::InsufficientFunds { required, available } and
prices a party with PartyParams::funding_requirements, which the fund
transaction constructor uses, so there is one fee formula. It replaces
get_change_output_and_fees_with_counterparty and the private
change_output_and_fees; get_change_output_and_fees stays as a wrapper over
it. Who pays what is unchanged, including the zero-collateral party's zero
fees and zero-value change.

funding_requirements takes the payout script lengths rather than a
counterparty script, because #224 prices the CET fee for the longest script
a CET pays the accepting party, which a payout script override on the offer
can make longer than its payout script. A requirement priced from the
payout scripts alone would fall short for such an offer.

The spliced constructor's input layout moves into PartyParams::spliced, so
the constructor and the requirement for a splicing party lay out inputs in
one place. Spliced contracts rebuild as before.

#224 also moved the transaction builder into ddk-manager, so the offering
party's price lives next to it: transactions::offer_funding_requirements
shares the override fold with build_contract_transactions and lays out a
splice offer as the builder funds it. ddk's
contract::funding_requirements(offer, accept_payout_spk) wraps it, so it
prices the offering party exactly as accept_offer will, override and splice
inputs included. ddk maps the error to ContractError::InsufficientFunds
(also when it arrives wrapped in a ddk-manager error).
bennyhodl added a commit that referenced this pull request Oct 15, 2026
… for rebuilds

PartyParams::spliced dropped every ordinary input whose witness is larger
than a P2WPKH witness, not only the ordinary copy of a splice input. As
public 2.0.0 API that would freeze a lossy rule: a new splice that also
spends a P2WSH coin would leave the coin out of its funding transaction.

spliced now removes the ordinary copy of each splice input by outpoint and
keeps every other input, so new contracts spend and price all of their
inputs. The old rule moves to PartyParams::legacy_spliced, documented as
rebuild-only for contracts built by 2.0.0-rc.8 and earlier. For inputs
whose witnesses fit the old rule the two layouts are identical, so every
pinned contract shape, enum_spliced_in included, rebuilds to the same
digests.

A splice built by rc.8 with a small wide input has a valid funding
transaction that the new layout no longer reproduces, so a signed contract
is rebuilt under a BuildRule: a fee rule and a splice layout. #224 made
ddk-manager's build_contract_transactions the one place transactions are
built, so BuildRule lives next to it and the builder takes one in place of
a FeeRule. BuildRule::CURRENT builds new contracts, in ddk-manager's accept
path, ddk's accept_offer and offer_funding_requirements. ddk's
signed_context tries BuildRule::SIGNED in order, each fee rule with the
current layout and then with the legacy one, and accepts the first whose
contract id matches the sign message. The builder lays out both parties
under the rule and calls one constructor, so the spliced and plain
constructor calls no longer repeat.

ContractError is now #[non_exhaustive], so typed variants can be added
after 2.0.0 without a major release. Every match on it in the workspace
already has a wildcard arm.
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.

2 participants