Skip to content

ddk: settle a contract from its messages without a key - #228

Open
bennyhodl wants to merge 3 commits into
masterfrom
benny/225-keyless-settlement
Open

bennyhodl wants to merge 3 commits into
masterfrom
benny/225-keyless-settlement

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

Item 3 of #225. Both parties' CET adaptor signatures and both refund signatures are already in the offer, accept and sign messages. So anyone holding the three messages can close a contract without a key: either party, a watchtower, or a server. Today sign_cet / sign_refund require the funding secret key, and the adaptor-secret sum in ddk-dlc is private.

Changes

  • ddk::contract::settle_cet_from_messages(offer, accept, sign, attestations): decrypts both parties' adaptor signatures with the oracle secret, verifies each against the CET under its party's funding key, and orders the witness by key. It covers enum, numeric and multi-oracle contracts.
  • ddk::contract::settle_refund_from_messages(offer, accept, sign): verifies both stored refund signatures, then combines them.
  • ddk_dlc::adaptor_secret is public. It is the former private signatures_to_secret; ddk_dlc::sign_cet uses it.
  • settle.rs is restructured around one private Settlement. CET selection, attestation validation and signature checks live there once. The key-based and keyless functions differ only in where the settling party's half comes from.
  • Module docs (lifecycle diagram, Settlement section) and the ddk-dlc README are updated.

Compatibility

  • sign_cet and sign_refund produce the same transactions as before, witness bytes included. On a key tie the witness ordering follows ddk_dlc::util::sign_multi_sig_input.
  • Error variants change on two paths that are hard to reach: a bad adaptor secret is now InvalidAttestation, and an adaptor signature that won't decrypt is now InvalidAccept/InvalidSign (naming the message that carried it). Both were ContractError::Dlc before. I kept the more precise variants rather than give the key-based path its own error mapping.

Follow-ups

  • The private witness builder in settle.rs should switch to ddk_dlc::util::multisig_witness once the verify-signed-contract PR (item 2) lands. The two PRs are independent.
  • sign_refund is now redundant: settle_refund_from_messages returns identical bytes for either party. sign_cet mostly is too. Deprecating them is a separate decision.

Testing

  • ddk/tests/stateless.rs:
    • keyless CET for single-oracle enum, numeric, and 2-of-3 enum attested by oracles 0 and 2. Each matches sign_cet's txid for both parties, and the witness signatures are verified as OP_CHECKMULTISIG would.
    • keyless refund equals sign_refund byte for byte.
    • a forged attestation, swapped adaptor signatures (accept and sign) and a swapped refund signature are rejected with the message-specific errors.
    • the pre-rc.4 OwnPayoutOnly fixture settles keylessly.
  • ddk/tests/stateless_execution.rs, regtest broadcast and confirm: enum_three_of_five_oracles_closes_from_the_messages, numeric_with_difference_three_of_five_oracles_closes_from_the_messages, refund_closes_the_contract_from_the_messages.
  • After the review fix: stateless 50/50, ddk contract lib tests 42/42, ddk-dlc lib 22/22, fmt and clippy -D warnings clean.
  • Reviewed by GPT-6 Astra:
    • no settlement-validation bypass found;
    • one witness-ordering tie difference from master, fixed;
    • one error-mapping change, kept as described above.

@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.

This deletes about 200 lines of witness construction, decryption and signature checks from lygos-utils/src/dlc/settle.rs. Before freezing:

  1. Per-outcome enum nonces. OracleAttestation::validate (dlc-messages/src/oracle_msgs.rs) zips the signatures with oracle_nonces by position. Magnolia announces one nonce per outcome and signs with the outcome's own nonce, so its attestations fail here. Orange-grove matches nonces as a set (lygos-utils/src/oracle_messages.rs). This is issue contract: the primitives the Lygos wallet had to write itself (external-signer split, keyless settle, tracker, verification)聽#225 item 7, and settlement is where it bites: either support per-outcome nonces in validation and adaptor construction, or reject them explicitly so consumers can plan the oracle migration.
  2. Event id. validate checks the key, the signatures and the nonces, but not attestation.event_id against the announcement. The nonce check already binds it in practice, so this is defence in depth, not a hole.
  3. Document what settlement verifies. It verifies the signatures it needs to close, not the whole signed contract; for example, refund doesn't check funding witnesses or CET adaptors. Callers that want the full check should run ddk: verify a signed contract from its three messages聽#231's verifier first.
  4. Orange-grove still needs the CET index and outcome alongside the transaction, so returning them (or exposing the lookup) would let us drop our adapter too.

Both parties' CET adaptor signatures are encrypted to the same point, so
the sum of the oracle signatures decrypts either one. Settling from the
messages alone needs that sum outside the crate. Rename the private
signatures_to_secret to the public adaptor_secret; sign_cet uses it.
Each party's CET adaptor signatures and refund signature travel in the
message it sent, so either party, a watchtower or a server can close the
contract without a funding key. Add settle_cet_from_messages and
settle_refund_from_messages.

settle.rs now has one path for CET selection, attestation validation and
verification of signatures taken from the messages, and one witness
assembly ordered by the funding script's keys. The key-based and keyless
functions differ only in where the settling party's half comes from, and
return the same transaction (same txid; the refund is byte-identical).
sign_cet's output is unchanged byte for byte.
CET settlement now checks every oracle announcement of the contract before
any adaptor work. OracleEvent::validate already requires exactly one nonce
on an enum event, so an announcement that uses one nonce per outcome (the
Magnolia convention) is refused up front with InvalidOffer, instead of
failing in the positional nonce check of OracleAttestation::validate. The
per-outcome convention is documented as unsupported. Refund settlement does
not check announcements, so such a contract can still be refunded.

OracleAttestation::validate now also binds the attestation's event id to
the announcement's. The nonce check already binds it in practice; this is
defence in depth. Every in-tree oracle builds its attestation from the
announcement's event id, so the manager's callers are unaffected.

settle_cet_from_messages returns a CetSettlement carrying the CET's index
among the contract's CETs alongside the transaction, so consumers no longer
recompute it from the descriptor.

The contract docs now say what settlement verifies: the signatures it needs
to close, not the whole signed contract. Callers that want the full check
are pointed to verify_signed_contract.
@bennyhodl
bennyhodl force-pushed the benny/225-keyless-settlement branch from 3a27ba0 to 52c3a1d Compare October 15, 2026 18:40
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