Skip to content

messages: let an enum outcome pay a third party script via a payout override record - #222

Closed
matthewjablack wants to merge 6 commits into
bennyhodl:masterfrom
matthewjablack:feat/liquidator-payout-override
Closed

matthewjablack wants to merge 6 commits into
bennyhodl:masterfrom
matthewjablack:feat/liquidator-payout-override

Conversation

@matthewjablack

@matthewjablack matthewjablack commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

A CET pays the offerer or the accepter and nobody else. A loan settled by a DLC needs an outcome that pays whoever liquidated the position on the EVM side, so the offer has to say which script that outcome pays. Both sides build the same CETs for the adaptor signatures to verify, which is why this rides on the offer instead of living in one application.

This adds a PayoutScriptOverrides TLV record on the offer, type 65005 in the odd application range. It's a list of enum outcome strings with a script each. For a named outcome, the output that would have paid the accepter's payout_spk pays that script instead. Amounts come from the descriptor as before, so a liquidation outcome is written as offer = 0, accept = total and only the script is swapped. In a loan the accepter is the lender and a liquidator is paid in the lender's place, so the lender liquidating itself is just the normal accepter payout with no override. A peer that doesn't know the record carries it through, and one that doesn't apply it fails signature verification instead of signing the wrong transaction.

ContractInfo::apply_payout_script_overrides does the rewrite on the CETs in payout order. It runs at every build site before adaptor signatures are derived or verified, four in the manager and one in the stateless context, and the patched CETs are what the manager stores so a close broadcasts the right one. OfferedContract::validate rejects a record whose outcome no enum descriptor has, an empty script, or any override on a numerical contract. The stateless path checks the same things since validate_offer never builds an OfferedContract.

One thing to know. The accepter's output is found by script, so offer and accept payout scripts have to differ. Every real contract already does that.

Tests cover the record round trip, an offer rebuilt through context_from_messages paying the override script on the named outcome and leaving the other alone, an unknown outcome rejected, and a numerical contract rejected. The record bytes are pinned against node-dlc's encoder in both repos so either side changing its encoding fails there. Workspace lib tests green, clippy and fmt clean on the touched crates. I didn't run the bitcoind backed execution tests.

Skipped multi output CETs. The design pays the whole output to the liquidator and the borrower gets USDC on the other chain, so there's no second third party payee. Worth a new record if that changes.

The node-dlc side is AtomicFinance/node-dlc#242 and the BAL side is AtomicFinance/bitcoin-abstraction-layer#218.

…comes

A CET pays the offerer and the accepter only. A loan settled by a DLC needs
an outcome that pays whoever liquidated the position, so the offer has to
carry, per enum outcome, the script the offerer's output pays instead. Both
sides must build identical CETs for the adaptor signatures to verify, which is
why the mapping rides on the offer rather than living in one application.

Type 65005, in the odd application range: a peer that does not know the record
carries it through, and one that does not apply it fails signature
verification rather than signing the wrong transaction.
…record

ContractInfo::apply_payout_script_overrides rewrites the offerer's output on
each CET whose outcome the record names. It runs on the CETs in payout order
before any adaptor signature is derived or verified, since those commit to
the CET bytes. Numerical descriptors have no outcome strings and are left
alone.

OfferedContract::validate rejects a record whose outcome no enum descriptor
has, or whose script is empty, so a mistyped liquidator outcome fails at the
offer instead of producing a CET nobody can match to an attestation.
The manager builds CETs in four places, two for the first contract info and
two in the loop over the rest, on both the accept and the sign path. The
stateless API builds them once in its context. Each site now applies the
offer's record before adaptor signatures are derived or verified, and the
patched CETs are what the manager stores, so a close broadcasts the CET that
pays the liquidator.

The stateless path validates the record the same way the manager does, since
validate_offer never builds an OfferedContract.
node-dlc writes the same record from its own encoder. Both tests hold the same
bytes, so either side changing its encoding fails here instead of at adaptor
signature verification between the two stacks.
… payout override

A liquidation outcome is the lender's payout path paid to someone
else, and the lender is the accepter. Writing every liquidated-by
outcome as accept = total means the lender's own liquidation outcome
needs no override and every liquidation CET has the same shape.
@matthewjablack

Copy link
Copy Markdown
Contributor Author

Alternative shape in #223 after the Slack thread: DDK takes the overrides as a plain input on the stateless API and defines no TLV record. This PR stays as the manager path for 1.x.

@bennyhodl

Copy link
Copy Markdown
Owner

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Replaced by #224. It keeps the PayoutScriptOverrides record at type 65005 and builds on it:

  • Overrides can name a numeric range as well as an enum outcome.
  • The record is applied once, in the shared transaction builder, for both the manager and the stateless module.
  • A manager only accepts an offer carrying it if the application allows the type (with_allowed_offer_tlv_types).

The wire encoding changed: each override now starts with a variant byte (0 for an enum outcome, 1 for a numeric range). node-dlc has to match the bytes pinned in dlc-messages/src/payout_overrides.rs.

@matthewjablack

Copy link
Copy Markdown
Contributor Author

Superseded by #224.

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