Skip to content

ddk: sign contracts with keys held outside the process - #229

Open
bennyhodl wants to merge 1 commit into
benny/225-verify-signed-contractfrom
benny/225-external-signer
Open

bennyhodl wants to merge 1 commit into
benny/225-verify-signed-contractfrom
benny/225-external-signer

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

Item 1 of #225. accept_offer, sign_accept and sign_cet took the DLC funding secret key in-process, and the funding PSBT only covered wallet inputs. So a vault, an HSM, a browser extension or a hardware signer could not drive the stateless API. Lygos wrote ~1,700 lines (lygos_utils::dlc) to fill the gap. The new ddk::contract::external module splits each step around the signer.

API (ddk::contract::external, builds without manager)

Step Prepare Complete
accept accept_request(&OfferDlc, AcceptOfferParams) -> AcceptRequest complete_accept(AcceptRequest, ContractSignatures) -> AcceptResult
sign sign_request(&OfferDlc, &AcceptDlc) -> SignRequest complete_sign(SignRequest, ContractSignatures, &Psbt) -> SignResult
finalize finalize_request(&OfferDlc, &AcceptDlc, &SignDlc) -> FinalizeRequest complete_finalize(FinalizeRequest, &Psbt) -> Transaction
  • ContractSigningRequest { funding_pubkey, refund: Psbt, cets: Vec<Psbt>, adaptor_points } is what the contract key signs. Each PSBT carries witness_utxo, the 2-of-2 witness_script and SIGHASH_ALL.
  • ContractSignatures { refund_signature, cet_adaptor_signatures } is the signer's answer.
  • FundingSigningRequest { psbt, splice_inputs } is the funding PSBT plus the splice inputs this party half-signs. A splice half travels back as a SIGHASH_ALL partial signature in that PSBT.
  • Preparation needs no placeholder signatures. Completion verifies the refund signature, every adaptor signature and every splice half before it builds a message. It rejects a changed funding transaction (PsbtMismatch), a wrong key, a non-SIGHASH_ALL sighash and a wrong adaptor count (InvalidSignature).
  • accept_offer, sign_accept(_spliced) and finalize_sign(_spliced) are now request → in-process signer → complete. There is one code path.
  • Request types hold private state and aren't serializable. A remote signer gets the PSBTs, points and keys inside them. The caller keeps the request or rebuilds it from the messages. accept_request draws random serial ids unless the params pin them.

Supporting primitives (one implementation each)

  • advanced::cet_adaptor_points(&OfferDlc), from ContractInfo::get_adaptor_points (ddk-manager) over a new DlcTrie::generate_adaptor_points. Signing and verification compute the points once per step.
  • advanced::contract_id_from_outpoint and its inverse temporary_contract_id_from_outpoint live in ddk-manager and replace utils::compute_id and ddk's copy. Contract-id bytes are unchanged.
  • funding_input_index and decode_previous_transaction are public in advanced.
  • contract::network_from_chain_hash is public and knows Testnet4.
  • create_funding_psbt fills witness_utxo, witness_script and the sighash on splice inputs. The DDK wallet signer skips those inputs; the xpriv and descriptor signers reject them as unsupported.
  • New ContractError::InvalidSignature.

Breaking changes

  • ContractKeyProvider::dlc_input_signing_key(&self, splice_input: &FundingInput) now takes only the splice input. It recovers the previous temporary id from the input and returns whichever of the two prior funding keys the provider derives. ddk-ffi (ffi/src/contract.rs:282) drops its prior_temporary_contract_id / prior_funding_pubkey plumbing and passes the input.
  • The new ContractError variant needs a match arm in ddk-ffi's error conversion.
  • A splice input with no key and no half now fails at completion with InvalidSignature. It used to be InvalidFundingInput("missing prior funding secret key").

Behaviour notes

  • The in-process path now verifies its own signatures too, because there is one path. accept_offer does about twice the adaptor-signature crypto it did; sign_accept adds one verification pass and drops one point computation.

Follow-up

  • Wallet-input witnesses returned by the signer are still taken as finalized. Funding-witness verification is item 2 (verify_funding_witness, in a separate PR). Once both land, complete_sign and complete_finalize should run it on the signer's wallet witnesses.

Testing

  • New in ddk/tests/stateless.rs:
    • external_signers_fund_a_contract_that_settles (settles through sign_cet and sign_refund);
    • external_signers_splice_a_contract;
    • one rejection test each for a changed transaction, a wrong contract key, a wrong splice key, a non-ALL refund sighash, a non-ALL splice sighash, and a wrong adaptor count.
    • The test signer (tests/external_signer) uses only rust-bitcoin and secp256k1-zkp, as a real external signer would.
  • New on-chain test: external_signers_fund_splice_and_close in stateless_execution.rs.
  • cargo test -p ddk --lib --test stateless (80 + 48) and cargo test -p ddk-trie -p ddk-manager --lib (59 + 42) pass.
  • Nine selected stateless_execution ignored tests pass on regtest: numeric-with-difference splices, disjoint, accept-side splice, mnemonic key recovery, and the new external-signer flow.
  • cargo fmt --check, cargo clippy -- -D warnings, cargo check -p ddk --no-default-features and cargo run --example stateless_splice pass.
  • Reviewed by GPT-6 Astra:
    • no mutation path into the completion requests;
    • adaptor-point generation and contract-id bytes match master;
    • two findings: the wallet-witness check is left to item 2 (the follow-up above), and the dlc_input_signing_key break is 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.

The biggest deletion of the set: with #231, roughly 400–600 lines of request building and completion in lygos-utils/src/dlc/request.rs and complete.rs, plus dlcd-rs's adaptor-point code. The request/complete seam is right. Before freezing:

  1. Accept-time funding request. AcceptRequest exposes only contract(); the accepting party's splice halves are requested at finalize. Our lender signs through an HSM: it signs its splice halves with the refund and CETs in one batch at accept, stores them, and finalizes without asking the HSM again. Please expose the accepting party's funding/splice request on AcceptRequest, and accept the halves in complete_accept.
  2. Pinned ids. accept_request draws omitted serial ids, so a request made again differs. Expose the chosen ids on the request (or require them) so a crashed signer can rebuild the same request.
  3. External wallet witnesses. "Wallet input witnesses are taken as finalized". For an external signer that is the one place nothing checks what came back. With ddk: verify a signed contract from its three messages #231's verify_funding_witness available, verify the ordinary funding witnesses a signer returns at sign and finalize, not only the splice halves.
  4. ddk: sign contracts with keys held outside the process #229 and ddk: verify a signed contract from its three messages #231 both rewrite finalization. Land them together and keep fee-rule recovery, strict splice parsing, and the checks on both parties' witnesses in the merged path.

@bennyhodl
bennyhodl force-pushed the benny/225-external-signer branch from 55efeeb to f4ab2c5 Compare October 15, 2026 15:50
@bennyhodl
bennyhodl changed the base branch from master to benny/225-verify-signed-contract October 15, 2026 15:54
@bennyhodl
bennyhodl force-pushed the benny/225-verify-signed-contract branch from 4b72d44 to 785180c Compare October 15, 2026 18:11
accept_offer, sign_accept and finalize_sign took the DLC funding keys
in-process, and the funding PSBT only covered wallet inputs, so a vault,
HSM, browser extension or hardware signer could not drive the stateless
API. The new contract::external module splits each step around the
signer: accept_request / sign_request / finalize_request say what to
sign (refund and CET PSBTs with their adaptor points, and the funding
PSBT with the splice inputs to half-sign), and complete_accept /
complete_sign / complete_finalize verify the answer before building the
message. Completion rejects a changed funding transaction, a wrong key,
a sighash other than SIGHASH_ALL and a wrong adaptor signature count.

The key-in-process functions are now those same halves with an
in-process signer between them, so there is one code path, and there is
one place a funding transaction is completed: complete_finalize.
finalize_request verifies the three messages with
verify_signed_contract, so finalizing keeps the fee-rule recovery for
contracts signed under the pre-rc.4 rule, the strict parsing of the
offering party's splice halves, and the check of its funding witnesses.

Nothing a signer returns is taken on trust, wallet witnesses included:
complete_sign and complete_finalize verify this party's own wallet
witnesses with verify_funding_witness, with the same strictness as the
counterparty's. A witness for a script type DDK cannot verify is refused,
on either side, with ContractError::UnverifiableFundingWitness.

The funding transaction is fixed at accept, so AcceptRequest::funding
asks for the accepting party's funding there too. A signer that signs in
one batch, such as an HSM, signs its splice halves with the refund and
the CETs; complete_accept takes the funding PSBT, verifies the halves it
carries and returns it in AcceptResult::funding_psbt, and that PSBT
finalizes the contract without asking the signer again. A half left out
at accept is signed at finalize, as before.

accept_request draws omitted payout and change serial ids. The request
reports them (payout_serial_id, change_serial_id), so a signer that
crashed can be sent the identical request again by passing them back in
the parameters.

Supporting primitives, each with one implementation:
- ContractInfo::get_adaptor_points (ddk-manager, over a new
  DlcTrie::generate_adaptor_points) and advanced::cet_adaptor_points;
  signing and verification in ddk run over the points, computed once
  per step.
- contract_id_from_outpoint and its inverse
  temporary_contract_id_from_outpoint in ddk-manager, replacing
  utils::compute_id and ddk's own copy; ContractKeyProvider::
  dlc_input_signing_key now takes the splice input and finds the prior
  key from it.
- create_funding_psbt fills witness_utxo, the 2-of-2 witness_script and
  SIGHASH_ALL on splice inputs; a party's splice half travels as a
  partial signature in the funding PSBT, and is verified by ddk-dlc's
  verify_dlc_funding_input_signature.
- network_from_chain_hash is public and knows Testnet4;
  funding_input_index and decode_previous_transaction are public in
  advanced.
@bennyhodl
bennyhodl force-pushed the benny/225-external-signer branch from f4ab2c5 to f4cef5d Compare October 15, 2026 18:33
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