Skip to content

ddk-dlc: typed insufficient-funds error and exact funding requirements - #232

Open
bennyhodl wants to merge 2 commits into
masterfrom
benny/225-funding-requirements
Open

bennyhodl wants to merge 2 commits into
masterfrom
benny/225-funding-requirements

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

Item 6 of #225. Lygos's offer builder probes a fabricated accept and parses required_input_funds= out of an InvalidArgument string to size its coin selection. This PR gives constructors a typed shortfall error, and gives coin selection a pure function that returns the exact requirement without building a transaction.

API

  • ddk_dlc::Error::InsufficientFunds { required: Amount, available: Amount } replaces the string error from the fund-transaction constructor.
  • ddk_dlc::FundingRequirements { required, fund_fee, cet_fee } with change(available).
  • PartyParams::funding_requirements(total_collateral, fee_rate_per_vb, extra_fee, counterparty_payout_spk, fee_rule) is now the single fee formula, and the constructor uses it.
  • PartyParams::spliced() holds the spliced constructor's input layout (drop inputs whose witness is over 108 bytes, add DLC inputs as ordinary ones). Construction and pricing share it, so splice offers are priced the way they're built.
  • ddk::contract::funding_requirements(offer, accept_payout_spk) -> FundingRequirements is what accept_offer will require of the offering party, splice inputs included. It builds without manager, and FundingRequirements is re-exported from ddk::contract.
  • ContractError::InsufficientFunds { required, available }. From<ddk_manager::error::Error> unwraps DlcError through the same conversion, so the typed error survives a manager wrapper.

Breaking changes

  • PartyParams::get_change_output_and_fees_with_counterparty is removed. It was the workaround; use funding_requirements. get_change_output_and_fees stays, as an OwnPayoutOnly wrapper.
  • Downstream: ddk-ffi's exhaustive ContractError match needs an InsufficientFunds arm. Lygos's required_funds can call ddk::contract::funding_requirements and match the typed error.

Compatibility

  • Fees, change and funding transactions are unchanged for every contract shape that built before, under both fee rules. That includes a zero-collateral party, which still gets a zero-value change output whatever its inputs hold.

Known issue (predates this PR, not fixed)

A splice offer that also has an ordinary input declaring a witness over 108 bytes can't be accepted. The spliced constructor leaves that input out of the funding transaction, build_funding_psbt then fails to match it, and its value still counts toward the party's input total. funding_requirements prices it the way the constructor does. The constructor behaviour itself needs its own fix.

Not changed

  • ddk-manager/src/utils.rs::get_approximate_required_amount is still a separate, approximate pre-selection estimate. Moving it onto this formula changes the manager's coin-selection amounts.
  • The error doesn't say which party is short. An accept_offer caller can compare available with its own inputs.

Testing

  • New in ddk-dlc:
    • funding_requirements_are_exactly_sufficient: covers both fee rules, single- and dual-funded contracts, and both sides being short. Inputs at exactly required build; one sat less fails with the exact InsufficientFunds { required, available }.
    • a_zero_collateral_party_gets_no_change: a regression test for a zero-collateral party with an extra fee.
    • a_splicing_party_is_priced_on_its_spliced_inputs.
  • New stateless tests:
    • an_offer_funded_with_exactly_its_requirement_is_accepted
    • a_splice_offer_funded_with_exactly_its_requirement_is_accepted
    • a_splice_offer_is_priced_as_the_spliced_constructor_funds_it
  • Unit suites: ddk-dlc 25, ddk contract lib 42, stateless 45, ddk-manager lib 59, all passing.
  • stateless_execution --ignored on regtest: splice-in, single-funded and dual-funded closes pass.
  • cargo fmt --check, cargo clippy -- -D warnings and cargo check -p ddk --no-default-features pass.
  • Reviewed by GPT-6 Astra. Fixed from the review:
    • an unintended change to zero-collateral allocation, now reverted;
    • splice pricing drifting from the constructor.

@bennyhodl

Copy link
Copy Markdown
Owner Author

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Review against orange-grove's consumers (lygos_utils::dlc, the wallet crate, the server's Fordefi signer) before 2.0.0. Change first.

Right owner for the fee math: orange-grove's required_funds (lygos-utils/src/dlc/offer.rs) becomes one call. Before freezing:

  1. PartyParams::spliced() as public API. It drops every ordinary input whose max_witness_len exceeds a P2WPKH witness, not only the duplicated splice input. The doc says that's deliberate so old spliced contracts rebuild identically, which is right for rebuilding. Published in 2.0.0, though, it freezes a lossy rule for new contracts. Keep the legacy layout behind a clearly legacy name (or private), and for new contracts dedupe splice inputs by outpoint.
  2. Who is short. ContractError::InsufficientFunds { required, available } doesn't say which party. A two-party funding failure is ambiguous without it; add the party now, since adding a field later breaks matches.
  3. Consider #[non_exhaustive] on ContractError before 2.0.0, so new typed variants (this one, ddk: verify a signed contract from its three messages #231's signature errors) don't need a major release.

@bennyhodl

bennyhodl commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner Author

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Follow-up on the review above:

  1. spliced(): done in 5471cb1. spliced() now drops only the ordinary copy of each splice input, matched by outpoint. The old rule is kept as legacy_spliced() and is used only to rebuild contracts from rc.8 and earlier. signed_context tries the new layout first, then the legacy one. The legacy step is needed: an rc.8 splice with a small P2WSH input does not rebuild under the new rule. All six pinned contract fixtures still rebuild byte for byte.
  2. Which party is short: not adding it. The shortfall also comes from the single-party PartyParams::get_change_output_and_fees, which has no role to report, and neither does its one outside caller (ddk-ffi's change_output_and_fees). The options were a party argument the caller can't fill, an Option<Party> that every consumer would need a fallback for, or removing that function from the public API. None is worth it for this release. InsufficientFunds { required, available } stays as it is. The two-party callers already know which party they asked about.
  3. #[non_exhaustive] on ContractError: done in 5471cb1. No match in the workspace needed changing. ddk-ffi's exhaustive match will need a wildcard arm when it moves to this release.

…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).
… 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.
@bennyhodl
bennyhodl force-pushed the benny/225-funding-requirements branch from 8d6f813 to 5471cb1 Compare October 15, 2026 19:09
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