From b19f630d7da431a5a945bf4d49ad6da660a600c1 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Wed, 5 Aug 2026 16:52:33 -0400 Subject: [PATCH 1/2] tests: run the manager's splice matrix against the stateless API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stateless splice tests ran one contract shape: an enum contract over a single oracle, spliced by the party that offered it. The manager path covers both shapes, several oracles, thresholds below the oracle count and either party initiating. A splice builds a new contract, so the shape of what it produces is what the CET selection and the adaptor signatures then work on, and none of that was exercised. The stateless splice section is now one driver. It takes a contract shape, the rounds to run — who offers each replacement, and which way the collateral moves — and the party that settles the last one. Every contract in the chain is built from that shape over an event of its own, so each round settles on its own attestation. `ContractShape` and `ShapedContract` in stateless_utils carry the shape: enum, numeric, numeric that tolerates an oracle spread, and disjoint, each over any oracle count and threshold. A `ShapedContract` keeps the oracles it announced with, so the scenario that funded the contract attests the event afterwards without tracking them. That takes the stateless splice tests from 6 to 19: enum over one, three and five oracles, numeric over one and three, numeric with difference over five, a disjoint contract settled on either of its two events, accept-party splices at multi-oracle and numeric shapes, either party settling, and four chains. The manager splice path had two holes of its own. It always settled from the party that offered the first contract, so the accepting party never broadcast the CET of a spliced contract. And accept-party splices only ran on an enum contract over one oracle. `TestPath::Splice` now carries a `SplicePath` that names the settling party, and seven tests cover the rest: accept-party splices on enum 3-of-5, numerical with difference 3-of-5 and a disjoint contract, accept-party settlement on enum 3-of-3 and numerical 3-of-3, an accept-party splice closed by hand, and the two existing chains now settle from the accepting side. 15 of these ran against a live regtest chain here, including every new shape and both settling parties. --- ddk-manager/tests/manager_execution_tests.rs | 234 ++++++++-- ddk/tests/stateless_execution.rs | 463 +++++++++++++------ ddk/tests/stateless_utils.rs | 187 ++++++++ 3 files changed, 699 insertions(+), 185 deletions(-) diff --git a/ddk-manager/tests/manager_execution_tests.rs b/ddk-manager/tests/manager_execution_tests.rs index 9067691..b51223e 100644 --- a/ddk-manager/tests/manager_execution_tests.rs +++ b/ddk-manager/tests/manager_execution_tests.rs @@ -189,9 +189,9 @@ enum TestPath { Refund, ManualRefund, CooperativeClose, - /// Splice the funded contract one round per entry, then settle whatever the - /// last round produced. - Splice(Vec), + /// Splice the funded contract, then settle whatever the last round + /// produced. + Splice(SplicePath), BadAcceptCetSignature, BadAcceptRefundSignature, BadSignCetSignature, @@ -217,6 +217,18 @@ impl Party { } } +/// A splice path: the rounds to run, and the party that settles the contract +/// the last round produced. +/// +/// The settling party matters whether it closes by hand or by periodic check: +/// it is the one that broadcasts the CET, leaving the other to pick up its +/// counterparty's close from the chain. +#[derive(Eq, PartialEq, Clone, Debug)] +struct SplicePath { + rounds: Vec, + closer: Party, +} + /// One round of a splice chain: who offers it, and how it moves the collateral. #[derive(Eq, PartialEq, Clone, Copy, Debug)] struct SpliceRound { @@ -714,19 +726,41 @@ async fn single_funded_dlc_test() { // A splice replaces a funded contract with another one, funded by spending the // first contract's 2-of-2 output. These run the same harness as every other // path, so a splice is asserted against the same state machine: the tests below -// cover both contract shapes, one and several oracles, thresholds below the -// oracle count, both parties initiating, collateral going both in and out, and -// chains of several splices before settlement. +// cover both contract shapes and a disjoint one, one and several oracles, +// thresholds below the oracle count, both parties initiating, both parties +// settling what a splice produced, collateral going both in and out, and chains +// of several splices before settlement. // --------------------------------------------------------------------------- -/// One splice-in offered by the party that offered the contract. +/// A chain of splices, settled by `closer`. +fn splice_chain(rounds: Vec, closer: Party) -> TestPath { + TestPath::Splice(SplicePath { rounds, closer }) +} + +/// One splice-in offered by `initiator` and settled by `closer`. +fn splice_in_by(initiator: Party, closer: Party) -> TestPath { + splice_chain( + vec![SpliceRound::splice_in(initiator, SPLICE_AMOUNT)], + closer, + ) +} + +/// One splice-out offered by `initiator` and settled by `closer`. +fn splice_out_by(initiator: Party, closer: Party) -> TestPath { + splice_chain( + vec![SpliceRound::splice_out(initiator, SPLICE_AMOUNT)], + closer, + ) +} + +/// One splice-in offered and settled by the party that offered the contract. fn splice_in() -> TestPath { - TestPath::Splice(vec![SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT)]) + splice_in_by(Party::Bob, Party::Bob) } -/// One splice-out offered by the party that offered the contract. +/// One splice-out offered and settled by the party that offered the contract. fn splice_out() -> TestPath { - TestPath::Splice(vec![SpliceRound::splice_out(Party::Bob, SPLICE_AMOUNT)]) + splice_out_by(Party::Bob, Party::Bob) } #[tokio::test] @@ -905,7 +939,7 @@ async fn splice_out_enum_and_numerical_3_of_5_test() { async fn splice_in_by_accept_party_test() { manager_execution_test( get_enum_test_params(1, 1, None).await, - TestPath::Splice(vec![SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT)]), + splice_in_by(Party::Alice, Party::Bob), false, ) .await; @@ -916,8 +950,85 @@ async fn splice_in_by_accept_party_test() { async fn splice_out_by_accept_party_test() { manager_execution_test( get_enum_test_params(1, 1, None).await, - TestPath::Splice(vec![SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT)]), + splice_out_by(Party::Alice, Party::Bob), + false, + ) + .await; +} + +/// The accepting party splicing a contract that several oracles settle. +#[tokio::test] +#[ignore] +async fn splice_in_enum_3_of_5_by_accept_party_test() { + manager_execution_test( + get_enum_test_params(5, 3, None).await, + splice_in_by(Party::Alice, Party::Bob), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numerical_with_diff_3_of_5_by_accept_party_test() { + numerical_common( + 5, + 3, + get_polynomial_payout_curve_pieces, + Some(get_difference_params()), + false, + splice_out_by(Party::Alice, Party::Bob), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_enum_and_numerical_3_of_5_by_accept_party_test() { + manager_execution_test( + get_enum_and_numerical_test_params(5, 3, false, None).await, + splice_in_by(Party::Alice, Party::Bob), + false, + ) + .await; +} + +/// The accepting party settles what a splice produced, whichever side offered +/// it: it broadcasts the CET and the offering party picks the close up from the +/// chain. +#[tokio::test] +#[ignore] +async fn splice_in_enum_3_of_3_closed_by_accept_party_test() { + manager_execution_test( + get_enum_test_params(3, 3, None).await, + splice_in_by(Party::Bob, Party::Alice), + false, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numerical_3_of_3_closed_by_accept_party_test() { + numerical_common( + 3, + 3, + get_polynomial_payout_curve_pieces, + None, false, + splice_out_by(Party::Bob, Party::Alice), + ) + .await; +} + +/// The accepting party both splices and settles, and closes it by hand. +#[tokio::test] +#[ignore] +async fn splice_in_by_accept_party_manual_close_test() { + manager_execution_test( + get_enum_test_params(1, 1, None).await, + splice_in_by(Party::Alice, Party::Alice), + true, ) .await; } @@ -929,11 +1040,14 @@ async fn splice_out_by_accept_party_test() { async fn splice_chain_in_out_in_enum_test() { manager_execution_test( get_enum_test_params(1, 1, None).await, - TestPath::Splice(vec![ - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_out(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - ]), + splice_chain( + vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + ], + Party::Bob, + ), false, ) .await; @@ -945,11 +1059,14 @@ async fn splice_chain_in_out_in_enum_test() { async fn splice_chain_alternating_parties_test() { manager_execution_test( get_enum_test_params(1, 1, None).await, - TestPath::Splice(vec![ - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), - SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), - ]), + splice_chain( + vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), + ], + Party::Bob, + ), false, ) .await; @@ -960,10 +1077,13 @@ async fn splice_chain_alternating_parties_test() { async fn splice_chain_multi_oracle_enum_test() { manager_execution_test( get_enum_test_params(3, 3, None).await, - TestPath::Splice(vec![ - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), - ]), + splice_chain( + vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + ], + Party::Alice, + ), false, ) .await; @@ -978,10 +1098,13 @@ async fn splice_chain_numerical_test() { get_polynomial_payout_curve_pieces, None, false, - TestPath::Splice(vec![ - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), - ]), + splice_chain( + vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_out(Party::Alice, SPLICE_AMOUNT), + ], + Party::Alice, + ), ) .await; } @@ -992,10 +1115,13 @@ async fn splice_chain_numerical_test() { async fn splice_chain_manual_close_test() { manager_execution_test( get_enum_test_params(1, 1, None).await, - TestPath::Splice(vec![ - SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), - SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), - ]), + splice_chain( + vec![ + SpliceRound::splice_in(Party::Bob, SPLICE_AMOUNT), + SpliceRound::splice_in(Party::Alice, SPLICE_AMOUNT), + ], + Party::Bob, + ), true, ) .await; @@ -1357,9 +1483,9 @@ async fn manager_execution_test_inner(test_params: TestParams, path: TestPath, m fund_contract(&mut ctx, contract_id, accept_msg).await; refund_path(&mut ctx, contract_id, &path, manual_close).await } - TestPath::Splice(rounds) => { + TestPath::Splice(splice) => { fund_contract(&mut ctx, contract_id, accept_msg).await; - splice_path(&mut ctx, &test_params, contract_id, rounds, manual_close).await + splice_path(&mut ctx, &test_params, contract_id, splice, manual_close).await } } @@ -1814,9 +1940,10 @@ async fn splice_path( ctx: &mut TestContext, test_params: &TestParams, contract_id: ContractId, - rounds: &[SpliceRound], + path: &SplicePath, manual_close: bool, ) { + let rounds = &path.rounds; assert!(!rounds.is_empty(), "a splice path needs at least one round"); // A splice offer names the contract it replaces. One this node does not @@ -1863,7 +1990,7 @@ async fn splice_path( .expect_err("a splice offer for an already replaced contract must be refused"); let splice_params = current_params.expect("a splice chain to have run a round"); - settle_spliced_contract(ctx, &splice_params, current, manual_close).await; + settle_spliced_contract(ctx, &splice_params, current, path.closer, manual_close).await; // Every contract the chain replaced stays closed. for previous in replaced.iter().take(replaced.len() - 1) { @@ -1872,15 +1999,20 @@ async fn splice_path( } } -/// Settles the last contract of a splice chain and asserts its CET spends the -/// splice funding output and pays only the two parties. +/// Settles the last contract of a splice chain from `closer` and asserts its +/// CET spends the splice funding output and pays only the two parties. +/// +/// `closer` is the party that broadcasts the CET; the other one only learns of +/// the close from the chain. async fn settle_spliced_contract( ctx: &mut TestContext, splice_params: &TestParams, contract_id: ContractId, + closer: Party, manual_close: bool, ) { - let spliced = signed_or_confirmed(ctx.contract(Party::Bob, &contract_id).await); + let other = closer.other(); + let spliced = signed_or_confirmed(ctx.contract(closer, &contract_id).await); let splice_funding_txid = spliced .accepted_contract .dlc_transactions @@ -1903,7 +2035,7 @@ async fn settle_spliced_contract( if manual_close { let attestations = get_attestations(splice_params).await; let contract = ctx - .bob + .manager(closer) .lock() .await .close_confirmed_contract(&contract_id, attestations) @@ -1914,29 +2046,29 @@ async fn settle_spliced_contract( panic!("Invalid contract state {:?}", contract); }; - let alice_contract = ctx.contract(Party::Alice, &contract_id).await; - let Contract::Confirmed(signed) = alice_contract else { - panic!("Invalid contract state: {:?}", alice_contract); + let other_contract = ctx.contract(other, &contract_id).await; + let Contract::Confirmed(signed) = other_contract else { + panic!("Invalid contract state: {:?}", other_contract); }; - ctx.alice + ctx.manager(other) .lock() .await .on_counterparty_close(&signed, contract.signed_cet, 0) .await .expect("Error registering counterparty close"); } else { - periodic_check!(ctx.bob, contract_id, PreClosed); - periodic_check!(ctx.alice, contract_id, PreClosed); + periodic_check!(ctx.manager(closer), contract_id, PreClosed); + periodic_check!(ctx.manager(other), contract_id, PreClosed); } ctx.mine(10).await; ctx.sync_wallets().await; - periodic_check!(ctx.bob, contract_id, Closed); - periodic_check!(ctx.alice, contract_id, Closed); + periodic_check!(ctx.manager(closer), contract_id, Closed); + periodic_check!(ctx.manager(other), contract_id, Closed); - let Contract::Closed(closed) = ctx.contract(Party::Bob, &contract_id).await else { + let Contract::Closed(closed) = ctx.contract(closer, &contract_id).await else { panic!("Spliced contract is not closed"); }; let closed_cet = closed.signed_cet.expect("a closed contract to have a CET"); diff --git a/ddk/tests/stateless_execution.rs b/ddk/tests/stateless_execution.rs index 402f7c8..fc7ad4e 100644 --- a/ddk/tests/stateless_execution.rs +++ b/ddk/tests/stateless_execution.rs @@ -53,7 +53,7 @@ async fn enum_close( ) .await; - let attestations = oracles.attest_enum("a").await; + let attestations = oracles.attest_enum(SETTLEMENT_OUTCOME).await; close_with_cet(&ctx, &contract, closer, &attestations).await; } @@ -92,8 +92,9 @@ async fn numeric_close( ) .await; - // Well inside the payout curve so the tolerated oracle spread stays in range. - let attestations = oracles.attest_numeric(500, with_difference).await; + let attestations = oracles + .attest_numeric(SETTLEMENT_VALUE, with_difference) + .await; close_with_cet(&ctx, &contract, closer, &attestations).await; } @@ -445,68 +446,135 @@ async fn multiple_inputs_with_interleaved_serial_ids_close() { // A splice spends the previous contract's 2-of-2 funding output as an input to // the new contract. Both parties' previous funding keys are recomputed from the // previous contract's temporary id, never stored. +// +// The matrix mirrors the splice paths of +// `ddk-manager/tests/manager_execution_tests.rs`: both contract shapes and a +// disjoint one, one and several oracles, thresholds below the oracle count, +// either party offering the splice, either party settling what it produced, +// collateral going in and out, and chains of several splices before +// settlement. + +/// The collateral each round adds to or removes from the funding output. +const SPLICE_AMOUNT: Amount = Amount::from_sat(100_000); + +/// One round of a splice chain: who offers the replacement contract, and which +/// way the collateral moves. +#[derive(Clone, Copy)] +struct SpliceRound { + /// The side of the contract being spliced that offers the replacement. + /// + /// Both sides can splice: the party that offers the replacement is credited + /// with the whole of the previous funding output, whichever side of that + /// contract it was, so it also puts up the whole of the new collateral. + splicer: Party, + splice_in: bool, +} + +impl SpliceRound { + fn splice_in(splicer: Party) -> Self { + Self { + splicer, + splice_in: true, + } + } -/// Funds a contract, splices its funding output into a second single-funded -/// contract with `delta` added to (or removed from) the funded amount, and -/// settles the second contract. + fn splice_out(splicer: Party) -> Self { + Self { + splicer, + splice_in: false, + } + } +} + +/// Funds a contract of `shape`, replaces it once per round by splicing its +/// funding output into the next one, and settles whatever the last round +/// produced from `closer`. /// -/// `splicer` names the side of the first contract that offers the second. Both -/// sides can splice: the party that offers the replacement is credited with the -/// whole of the previous funding output, whichever side of that contract it -/// was. -async fn splice_and_close(label: &str, splice_in: bool, splicer: Party) { +/// Every contract in the chain has the shape the scenario named, over oracles +/// and an event of its own, so each round settles on its own attestation. Each +/// replacement is single-funded by the party that offers it: the splice input +/// carries the previous contract's whole funded amount, with +/// [`SPLICE_AMOUNT`] added from that party's wallet or returned to it. +async fn splice_chain_and_close( + label: &str, + shape: ContractShape, + rounds: &[SpliceRound], + closer: Party, +) { let ctx = ChainContext::new(label).await; - // The contract being spliced. - let previous_id = temporary_contract_id(&format!("{label}-previous")); - let previous_oracles = TestOracles::enums(1, 1, &format!("{label}-previous")).await; - let previous = fund_contract( + // The contract the first round splices. + let first_label = format!("{label}-0"); + let first_id = temporary_contract_id(&first_label); + let first = ShapedContract::new(shape, &first_label, OFFER_COLLATERAL, ACCEPT_COLLATERAL).await; + let mut previous = fund_contract( &ctx, ContractSetup::new( - enum_contract_info(&previous_oracles, TOTAL_COLLATERAL), + first.contract_info.clone(), OFFER_COLLATERAL, - previous_id, - TestParty::new(&ctx, PartySpec::new(Party::Offer, 41, 1), previous_id).await, - TestParty::new(&ctx, PartySpec::new(Party::Accept, 42, 2), previous_id).await, + first_id, + TestParty::new(&ctx, PartySpec::new(Party::Offer, 41, 1), first_id).await, + TestParty::new(&ctx, PartySpec::new(Party::Accept, 42, 2), first_id).await, ), ) .await; - let splice_serial_id = 900; - let splice = previous.splice_setup_by(splicer, splice_serial_id); - let delta = Amount::from_sat(100_000); - let collateral = if splice_in { - previous.fund_value() + delta - } else { - previous.fund_value() - delta - }; - - // The spliced contract is single-funded by the offering party: the splice - // input carries the previous contract's whole funded amount. - let spliced_id = temporary_contract_id(&format!("{label}-spliced")); - let spliced_oracles = TestOracles::enums(1, 1, &format!("{label}-spliced")).await; - let offer_spec = if splice_in { - PartySpec::new(Party::Offer, 43, 10) - } else { - PartySpec::unfunded(Party::Offer, 43) - }; - let spliced = fund_contract( - &ctx, - ContractSetup::new( - enum_contract_info(&spliced_oracles, collateral), - collateral, - spliced_id, - TestParty::new(&ctx, offer_spec, spliced_id).await, - TestParty::new(&ctx, PartySpec::unfunded(Party::Accept, 44), spliced_id).await, + let mut settled = None; + for (index, round) in rounds.iter().enumerate() { + let number = index + 1; + let round_label = format!("{label}-{number}"); + let collateral = if round.splice_in { + previous.fund_value() + SPLICE_AMOUNT + } else { + previous.fund_value() - SPLICE_AMOUNT + }; + // Only a numeric payout curve reads the split between the two sides; + // every shape locks their sum. + let offer_share = collateral / 2; + let spliced_shape = + ShapedContract::new(shape, &round_label, offer_share, collateral - offer_share).await; + + let spliced_id = temporary_contract_id(&round_label); + let seed_byte = 43 + index as u8 * 2; + let offer_spec = if round.splice_in { + PartySpec::new(Party::Offer, seed_byte, 20 + number as u64) + } else { + PartySpec::unfunded(Party::Offer, seed_byte) + }; + let spliced = fund_contract( + &ctx, + ContractSetup::new( + spliced_shape.contract_info.clone(), + collateral, + spliced_id, + TestParty::new(&ctx, offer_spec, spliced_id).await, + TestParty::new( + &ctx, + PartySpec::unfunded(Party::Accept, seed_byte + 1), + spliced_id, + ) + .await, + ) + .with_splice(previous.splice_setup_by(round.splicer, 900 + number as u64)), ) - .with_splice(splice), - ) - .await; + .await; - assert_splice(&ctx, &previous, &spliced, splice_in).await; + assert_splice(&ctx, &previous, &spliced, round.splice_in).await; - let attestations = spliced_oracles.attest_enum("a").await; - close_with_cet(&ctx, &spliced, Party::Offer, &attestations).await; + previous = spliced; + settled = Some(spliced_shape); + } + + let attestations = settled + .expect("a splice chain to have run a round") + .attest() + .await; + close_with_cet(&ctx, &previous, closer, &attestations).await; +} + +/// A single splice of a contract of `shape`, settled by `closer`. +async fn splice_and_close(label: &str, shape: ContractShape, round: SpliceRound, closer: Party) { + splice_chain_and_close(label, shape, &[round], closer).await; } /// Asserts that `spliced` replaced `previous`: it spends the previous funding @@ -542,124 +610,215 @@ async fn assert_splice( } } +// Enum contracts, one and several oracles. + #[tokio::test] #[ignore] -async fn splice_in_funds_and_closes() { - splice_and_close("splice_in_funds_and_closes", true, Party::Offer).await; +async fn splice_in_enum_single_oracle_closes() { + splice_and_close( + "splice_in_enum_single_oracle_closes", + ContractShape::enums(1, 1), + SpliceRound::splice_in(Party::Offer), + Party::Offer, + ) + .await; } #[tokio::test] #[ignore] -async fn splice_out_funds_and_closes() { - splice_and_close("splice_out_funds_and_closes", false, Party::Offer).await; +async fn splice_out_enum_single_oracle_closes() { + splice_and_close( + "splice_out_enum_single_oracle_closes", + ContractShape::enums(1, 1), + SpliceRound::splice_out(Party::Offer), + Party::Offer, + ) + .await; } -/// The side that *accepted* the contract can splice it too. -/// -/// This is the case the stateful manager got wrong: the two keys in a -/// [`DlcInput`] are ordered by who offers the splice, not by who offered the -/// contract being spliced, and the party that reads them has to agree. #[tokio::test] #[ignore] -async fn splice_in_by_accept_party_funds_and_closes() { +async fn splice_in_enum_three_of_three_oracles_closes() { splice_and_close( - "splice_in_by_accept_party_funds_and_closes", - true, + "splice_in_enum_three_of_three_oracles_closes", + ContractShape::enums(3, 3), + SpliceRound::splice_in(Party::Offer), + Party::Offer, + ) + .await; +} + +/// A threshold below the oracle count, settled by the accepting party. +#[tokio::test] +#[ignore] +async fn splice_out_enum_three_of_five_oracles_closes() { + splice_and_close( + "splice_out_enum_three_of_five_oracles_closes", + ContractShape::enums(5, 3), + SpliceRound::splice_out(Party::Offer), Party::Accept, ) .await; } +// Numeric contracts, with and without a tolerated oracle spread. + #[tokio::test] #[ignore] -async fn splice_out_by_accept_party_funds_and_closes() { +async fn splice_in_numeric_single_oracle_closes() { splice_and_close( - "splice_out_by_accept_party_funds_and_closes", - false, + "splice_in_numeric_single_oracle_closes", + ContractShape::numeric(1, 1), + SpliceRound::splice_in(Party::Offer), + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numeric_single_oracle_closes() { + splice_and_close( + "splice_out_numeric_single_oracle_closes", + ContractShape::numeric(1, 1), + SpliceRound::splice_out(Party::Offer), + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numeric_three_of_three_oracles_closes() { + splice_and_close( + "splice_in_numeric_three_of_three_oracles_closes", + ContractShape::numeric(3, 3), + SpliceRound::splice_in(Party::Offer), + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_numeric_with_difference_three_of_five_oracles_closes() { + splice_and_close( + "splice_in_numeric_with_difference_three_of_five_oracles_closes", + ContractShape::numeric_with_difference(5, 3), + SpliceRound::splice_in(Party::Offer), + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_numeric_with_difference_three_of_five_oracles_closes() { + splice_and_close( + "splice_out_numeric_with_difference_three_of_five_oracles_closes", + ContractShape::numeric_with_difference(5, 3), + SpliceRound::splice_out(Party::Offer), Party::Accept, ) .await; } -/// Splices a contract twice, with the given side offering each round, and -/// settles whatever the last round produced. -/// -/// Each round splices the contract the previous round produced, so the second -/// round proves a spliced contract is itself spliceable. -async fn splice_chain_and_close(label: &str, rounds: [(bool, Party); 2]) { - let ctx = ChainContext::new(label).await; +// Disjoint contracts: the spliced contract settles on whichever of its two +// events is attested. - let first_id = temporary_contract_id(&format!("{label}-0")); - let first_oracles = TestOracles::enums(1, 1, &format!("{label}-0")).await; - let mut previous = fund_contract( - &ctx, - ContractSetup::new( - enum_contract_info(&first_oracles, TOTAL_COLLATERAL), - OFFER_COLLATERAL, - first_id, - TestParty::new(&ctx, PartySpec::new(Party::Offer, 91, 1), first_id).await, - TestParty::new(&ctx, PartySpec::new(Party::Accept, 92, 2), first_id).await, - ), +#[tokio::test] +#[ignore] +async fn splice_in_disjoint_three_of_five_oracles_closes_on_the_enum_event() { + splice_and_close( + "splice_in_disjoint_three_of_five_oracles_closes_on_the_enum_event", + ContractShape::disjoint(5, 3, DisjointEvent::Enum), + SpliceRound::splice_in(Party::Offer), + Party::Offer, ) .await; +} - let delta = Amount::from_sat(100_000); - let mut oracles = None; +#[tokio::test] +#[ignore] +async fn splice_out_disjoint_three_of_five_oracles_closes_on_the_numeric_event() { + splice_and_close( + "splice_out_disjoint_three_of_five_oracles_closes_on_the_numeric_event", + ContractShape::disjoint(5, 3, DisjointEvent::Numeric), + SpliceRound::splice_out(Party::Offer), + Party::Accept, + ) + .await; +} - for (index, (splice_in, splicer)) in rounds.iter().enumerate() { - let round = index + 1; - let serial_id = 900 + round as u64; - let splice = previous.splice_setup_by(*splicer, serial_id); - let collateral = if *splice_in { - previous.fund_value() + delta - } else { - previous.fund_value() - delta - }; +// Splices offered by the accepting party. +// +// This is the case the stateful manager got wrong: the two keys in a +// [`DlcInput`] are ordered by who offers the splice, not by who offered the +// contract being spliced, and the party that reads them has to agree. - let spliced_id = temporary_contract_id(&format!("{label}-{round}")); - let spliced_oracles = TestOracles::enums(1, 1, &format!("{label}-{round}")).await; - let offer_spec = if *splice_in { - PartySpec::new(Party::Offer, 93 + round as u8 * 2, 20 + round as u64) - } else { - PartySpec::unfunded(Party::Offer, 93 + round as u8 * 2) - }; - let spliced = fund_contract( - &ctx, - ContractSetup::new( - enum_contract_info(&spliced_oracles, collateral), - collateral, - spliced_id, - TestParty::new(&ctx, offer_spec, spliced_id).await, - TestParty::new( - &ctx, - PartySpec::unfunded(Party::Accept, 94 + round as u8 * 2), - spliced_id, - ) - .await, - ) - .with_splice(splice), - ) - .await; +#[tokio::test] +#[ignore] +async fn splice_in_by_accept_party_closes() { + splice_and_close( + "splice_in_by_accept_party_closes", + ContractShape::enums(1, 1), + SpliceRound::splice_in(Party::Accept), + Party::Offer, + ) + .await; +} - assert_splice(&ctx, &previous, &spliced, *splice_in).await; +#[tokio::test] +#[ignore] +async fn splice_out_by_accept_party_closes() { + splice_and_close( + "splice_out_by_accept_party_closes", + ContractShape::enums(1, 1), + SpliceRound::splice_out(Party::Accept), + Party::Offer, + ) + .await; +} - previous = spliced; - oracles = Some(spliced_oracles); - } +#[tokio::test] +#[ignore] +async fn splice_in_enum_three_of_five_oracles_by_accept_party_closes() { + splice_and_close( + "splice_in_enum_three_of_five_oracles_by_accept_party_closes", + ContractShape::enums(5, 3), + SpliceRound::splice_in(Party::Accept), + Party::Accept, + ) + .await; +} - let attestations = oracles - .expect("a splice chain to have run a round") - .attest_enum("a") - .await; - close_with_cet(&ctx, &previous, Party::Offer, &attestations).await; +#[tokio::test] +#[ignore] +async fn splice_out_numeric_with_difference_three_of_five_oracles_by_accept_party_closes() { + splice_and_close( + "splice_out_numeric_with_difference_three_of_five_oracles_by_accept_party_closes", + ContractShape::numeric_with_difference(5, 3), + SpliceRound::splice_out(Party::Accept), + Party::Accept, + ) + .await; } +// Chains: each round splices the contract the previous round produced, so +// every round after the first proves a spliced contract is itself spliceable. + #[tokio::test] #[ignore] -async fn splice_chain_in_then_out_funds_and_closes() { +async fn splice_chain_in_out_in_enum_closes() { splice_chain_and_close( - "splice_chain_in_then_out_funds_and_closes", - [(true, Party::Offer), (false, Party::Offer)], + "splice_chain_in_out_in_enum_closes", + ContractShape::enums(1, 1), + &[ + SpliceRound::splice_in(Party::Offer), + SpliceRound::splice_out(Party::Offer), + SpliceRound::splice_in(Party::Offer), + ], + Party::Offer, ) .await; } @@ -667,10 +826,46 @@ async fn splice_chain_in_then_out_funds_and_closes() { /// A chain where the two sides take turns splicing. #[tokio::test] #[ignore] -async fn splice_chain_alternating_parties_funds_and_closes() { +async fn splice_chain_alternating_parties_closes() { splice_chain_and_close( - "splice_chain_alternating_parties_funds_and_closes", - [(true, Party::Offer), (false, Party::Accept)], + "splice_chain_alternating_parties_closes", + ContractShape::enums(1, 1), + &[ + SpliceRound::splice_in(Party::Offer), + SpliceRound::splice_out(Party::Accept), + SpliceRound::splice_in(Party::Accept), + ], + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_chain_multi_oracle_enum_closes() { + splice_chain_and_close( + "splice_chain_multi_oracle_enum_closes", + ContractShape::enums(3, 3), + &[ + SpliceRound::splice_in(Party::Offer), + SpliceRound::splice_out(Party::Accept), + ], + Party::Accept, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn splice_chain_numeric_closes() { + splice_chain_and_close( + "splice_chain_numeric_closes", + ContractShape::numeric(1, 1), + &[ + SpliceRound::splice_in(Party::Offer), + SpliceRound::splice_out(Party::Accept), + ], + Party::Accept, ) .await; } diff --git a/ddk/tests/stateless_utils.rs b/ddk/tests/stateless_utils.rs index 3fc7b88..189f574 100644 --- a/ddk/tests/stateless_utils.rs +++ b/ddk/tests/stateless_utils.rs @@ -583,6 +583,193 @@ pub fn difference_params() -> DifferenceParams { } } +/// The enum outcome a scenario settles on. Index 0 of [`enum_outcomes`], so it +/// pays the offering party the whole of the collateral. +pub const SETTLEMENT_OUTCOME: &str = "a"; + +/// The numeric outcome a scenario settles on. +/// +/// Well inside the payout curve, so the spread a contract with difference +/// params tolerates stays in range on both sides. +pub const SETTLEMENT_VALUE: i64 = 500; + +/// Which event of a disjoint contract settles it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisjointEvent { + Enum, + Numeric, +} + +/// A contract shape, with the collateral left out: which events settle it, over +/// how many oracles, and how many of them have to agree. +/// +/// A splice chain builds a contract per round, each with its own oracles and +/// its own collateral. A scenario names the shape once and every round +/// instantiates it through [`ShapedContract::new`]. +#[derive(Clone, Copy, Debug)] +pub enum ContractShape { + Enum { + nb_oracles: usize, + threshold: u16, + }, + Numeric { + nb_oracles: usize, + threshold: u16, + with_difference: bool, + }, + /// An enum event and a numeric event, either of which settles the contract; + /// `settle_on` names the one the scenario attests. + Disjoint { + nb_oracles: usize, + threshold: u16, + settle_on: DisjointEvent, + }, +} + +impl ContractShape { + pub fn enums(nb_oracles: usize, threshold: u16) -> Self { + ContractShape::Enum { + nb_oracles, + threshold, + } + } + + pub fn numeric(nb_oracles: usize, threshold: u16) -> Self { + ContractShape::Numeric { + nb_oracles, + threshold, + with_difference: false, + } + } + + /// A numeric contract that tolerates the oracles disagreeing by a bounded + /// amount, which only means anything above one oracle. + pub fn numeric_with_difference(nb_oracles: usize, threshold: u16) -> Self { + ContractShape::Numeric { + nb_oracles, + threshold, + with_difference: true, + } + } + + pub fn disjoint(nb_oracles: usize, threshold: u16, settle_on: DisjointEvent) -> Self { + ContractShape::Disjoint { + nb_oracles, + threshold, + settle_on, + } + } +} + +/// A [`ContractShape`] instantiated over its own oracles, for one collateral. +/// +/// It holds the oracles it announced with, so the scenario that funded the +/// contract can attest the event afterwards without tracking them itself. +pub struct ShapedContract { + pub contract_info: ContractInfo, + enum_oracles: Option, + numeric_oracles: Option, + with_difference: bool, + settle_on: DisjointEvent, +} + +impl ShapedContract { + /// Announces the events `shape` needs under event ids derived from `label`, + /// and builds the contract info locking + /// `offer_collateral + accept_collateral`. + /// + /// The split between the two collaterals only reaches the payout curve of a + /// numeric contract; every shape locks their sum. + pub async fn new( + shape: ContractShape, + label: &str, + offer_collateral: Amount, + accept_collateral: Amount, + ) -> Self { + match shape { + ContractShape::Enum { + nb_oracles, + threshold, + } => { + let oracles = TestOracles::enums(nb_oracles, threshold, label).await; + let contract_info = + enum_contract_info(&oracles, offer_collateral + accept_collateral); + Self { + contract_info, + enum_oracles: Some(oracles), + numeric_oracles: None, + with_difference: false, + settle_on: DisjointEvent::Enum, + } + } + ContractShape::Numeric { + nb_oracles, + threshold, + with_difference, + } => { + let oracles = TestOracles::numerics(nb_oracles, threshold, label).await; + let contract_info = numeric_contract_info( + &oracles, + offer_collateral, + accept_collateral, + with_difference.then(difference_params), + ); + Self { + contract_info, + enum_oracles: None, + numeric_oracles: Some(oracles), + with_difference, + settle_on: DisjointEvent::Numeric, + } + } + ContractShape::Disjoint { + nb_oracles, + threshold, + settle_on, + } => { + let enum_oracles = + TestOracles::enums(nb_oracles, threshold, &format!("{label}-enum")).await; + let numeric_oracles = + TestOracles::numerics(nb_oracles, threshold, &format!("{label}-numeric")).await; + let contract_info = disjoint_contract_info( + &enum_oracles, + &numeric_oracles, + offer_collateral, + accept_collateral, + ); + Self { + contract_info, + enum_oracles: Some(enum_oracles), + numeric_oracles: Some(numeric_oracles), + with_difference: false, + settle_on, + } + } + } + } + + /// Attests the event this contract settles on, with as many oracles as its + /// threshold asks for. + pub async fn attest(&self) -> Vec<(usize, OracleAttestation)> { + match self.settle_on { + DisjointEvent::Enum => { + self.enum_oracles + .as_ref() + .expect("a contract settling on an enum event to have enum oracles") + .attest_enum(SETTLEMENT_OUTCOME) + .await + } + DisjointEvent::Numeric => { + self.numeric_oracles + .as_ref() + .expect("a contract settling on a numeric event to have numeric oracles") + .attest_numeric(SETTLEMENT_VALUE, self.with_difference) + .await + } + } + } +} + /// A funding input together with the derivation index of the key controlling it. pub struct PartyInput { pub funding_input: FundingInput, From 4a990bdf5326674a3fe97512b44aa264fbbf1121 Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Wed, 5 Aug 2026 17:13:44 -0400 Subject: [PATCH 2/2] tests: share oracles and contract shapes between the DLC test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both suites built the same things by hand: oracles announcing an enum event over the same four outcomes and a digit decomposition event over the same base and digit count, an enum descriptor that pays alternating parties, a numeric descriptor rounded to the satoshi, and the contract those descriptors go into. What kept them apart is the layer each hands the contract to. `ddk-manager` takes a `ContractInput`, which names oracles by public key and event id. `ddk::contract` takes the wire `ContractInfo`, which carries the announcements themselves. `ddk_testenv::dlc::ContractLeg` builds either form from one descriptor: an announcement already carries the oracle public key and the event id, which is all the manager form needs. The new module holds the constants, the oracle mechanics — announce, sign, collect attestations — both descriptor builders, the two contract forms, and `SpliceDelta`. It sits behind the `dlc` feature of `ddk-testenv`, already a dev-dependency of both crates and of nothing else. That feature depends on `ddk`, which depends on `ddk-manager`, both of which depend on `ddk-testenv` in turn: a cycle cargo allows because theirs are dev-dependencies. Policy stays with each suite, because they differ on purpose. The manager tests pick a random outcome and a random oracle subset above the threshold; the stateless tests settle on a fixed outcome with the first `threshold` oracles. The payout curves differ too — the manager tests build polynomial and hyperbola pieces, the stateless tests take a straight line from `ddk-payouts` — so `numeric_descriptor` takes the curve instead of choosing one. One behaviour is kept deliberately. `attest_numeric_event` offers every candidate outcome to every oracle and lets the first one stand, because an oracle that already signed an event refuses the rest. The shared signing functions ignore that refusal for the same reason, and `attestations` asserts an oracle really signed before its attestation is used. The two harnesses drop 481 lines and add back 209 that call into the 436 line module they now share. Verified on a live regtest chain after the move: 7 manager execution paths (enum, disjoint with and without difference, numerical 3-of-3, single funded, refund, and a numerical splice), 4 stateless ones (enum, numeric with difference 3-of-5, disjoint settled on its numeric event, and a numeric splice), and the 31 offline stateless tests. --- Cargo.lock | 5 + ddk-manager/Cargo.toml | 2 +- ddk-manager/tests/test_utils.rs | 399 +++++++++------------------- ddk/Cargo.toml | 2 +- ddk/tests/stateless_execution.rs | 16 +- ddk/tests/stateless_utils.rs | 275 ++++++------------- testenv/Cargo.toml | 11 + testenv/src/dlc.rs | 436 +++++++++++++++++++++++++++++++ testenv/src/lib.rs | 2 + 9 files changed, 665 insertions(+), 483 deletions(-) create mode 100644 testenv/src/dlc.rs diff --git a/Cargo.lock b/Cargo.lock index 13a372e..db9cdc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1207,6 +1207,11 @@ dependencies = [ "bitcoin", "bitcoincore-rpc", "bitcoind", + "ddk", + "ddk-dlc", + "ddk-manager", + "ddk-messages", + "ddk-trie", "electrsd", "libc", "nostr-relay-builder", diff --git a/ddk-manager/Cargo.toml b/ddk-manager/Cargo.toml index d4b952a..cc67920 100644 --- a/ddk-manager/Cargo.toml +++ b/ddk-manager/Cargo.toml @@ -40,7 +40,7 @@ tracing = { workspace = true } [dev-dependencies] ddk = { workspace = true, features = ["manager"] } -ddk-testenv = { workspace = true } +ddk-testenv = { workspace = true, features = ["dlc"] } bitcoincore-rpc = { workspace = true } bitcoincore-rpc-json = { workspace = true } criterion = "0.8.2" diff --git a/ddk-manager/tests/test_utils.rs b/ddk-manager/tests/test_utils.rs index 7909fbc..4ce78bf 100644 --- a/ddk-manager/tests/test_utils.rs +++ b/ddk-manager/tests/test_utils.rs @@ -8,39 +8,41 @@ use bitcoincore_rpc::{Client, RpcApi}; use bitcoincore_rpc_json::AddressType; use ddk::{chain::EsploraClient, logger::Logger, wallet::DlcDevKitWallet}; use ddk::{oracle::memory::MemoryOracle, storage::memory::MemoryStorage}; -use ddk_dlc::{EnumerationPayout, Payout}; use ddk_manager::payout_curve::{ - PayoutFunction, PayoutFunctionPiece, PayoutPoint, PolynomialPayoutCurvePiece, RoundingInterval, - RoundingIntervals, + PayoutFunction, PayoutFunctionPiece, PayoutPoint, PolynomialPayoutCurvePiece, }; use ddk_manager::Oracle; use ddk_manager::Time; use ddk_manager::{ contract::{ - contract_input::{ContractInput, ContractInputInfo, OracleInput}, - enum_descriptor::EnumDescriptor, - numerical_descriptor::{DifferenceParams, NumericalDescriptor}, - ContractDescriptor, + contract_input::ContractInput, numerical_descriptor::DifferenceParams, ContractDescriptor, }, payout_curve::HyperbolaPayoutCurvePiece, }; +use ddk_messages::oracle_msgs::OracleAnnouncement; +use ddk_testenv::dlc::{self, ContractLeg}; use ddk_testenv::TestEnv; use ddk_trie::OracleNumericInfo; use secp256k1_zkp::rand::{seq::SliceRandom, thread_rng, Fill, RngCore}; use std::fmt::Write; use std::{cell::RefCell, sync::Arc}; -pub const NB_DIGITS: u32 = 10; -pub const MIN_SUPPORT_EXP: usize = 1; -pub const MAX_ERROR_EXP: usize = 2; -pub const BASE: u32 = 2; -pub const EVENT_MATURITY: u32 = 1623133104; +// Oracles, events and contract descriptors are the same in both DLC test +// suites, so they come from [`ddk_testenv::dlc`] rather than from here. +// +// Several test binaries include this module, and each uses a different part of +// it, so an item one of them leaves alone is not an unused import. +#[allow(unused_imports)] +pub use ddk_testenv::dlc::{ + enum_outcomes, max_value_from_digits, SpliceDelta, BASE, EVENT_MATURITY, MIN_SUPPORT_EXP, +}; + +pub const NB_DIGITS: u32 = dlc::NB_DIGITS as u32; pub const EVENT_ID: &str = "Test"; pub const OFFER_COLLATERAL: u64 = 90000000; pub const ACCEPT_COLLATERAL: u64 = 11000000; pub const TOTAL_COLLATERAL: Amount = Amount::from_sat(OFFER_COLLATERAL + ACCEPT_COLLATERAL); pub const MID_POINT: u64 = 5; -pub const ROUNDING_MOD: u64 = 1; #[macro_export] macro_rules! receive_loop { @@ -194,21 +196,8 @@ macro_rules! assert_channel_state_unlocked { }}; } -pub fn enum_outcomes() -> Vec { - vec![ - "a".to_owned(), - "b".to_owned(), - "c".to_owned(), - "d".to_owned(), - ] -} - pub fn max_value() -> u32 { - BASE.pow(NB_DIGITS) - 1 -} - -pub fn max_value_from_digits(nb_digits: usize) -> u32 { - BASE.pow(nb_digits as u32) - 1 + max_value_from_digits(NB_DIGITS as usize) as u32 } pub fn select_active_oracles(nb_oracles: usize, threshold: usize) -> Vec { @@ -231,42 +220,11 @@ pub struct TestParams { } pub fn get_difference_params() -> DifferenceParams { - DifferenceParams { - max_error_exp: MAX_ERROR_EXP, - min_support_exp: MIN_SUPPORT_EXP, - maximize_coverage: false, - } + dlc::difference_params() } pub fn get_enum_contract_descriptor() -> ContractDescriptor { - enum_contract_descriptor(TOTAL_COLLATERAL) -} - -/// An enum descriptor over [`enum_outcomes`] that pays the whole of -/// `total_collateral` to alternating parties. -pub fn enum_contract_descriptor(total_collateral: Amount) -> ContractDescriptor { - let outcome_payouts: Vec<_> = enum_outcomes() - .iter() - .enumerate() - .map(|(i, x)| { - let payout = if i % 2 == 0 { - Payout { - offer: total_collateral, - accept: Amount::ZERO, - } - } else { - Payout { - offer: Amount::ZERO, - accept: total_collateral, - } - }; - EnumerationPayout { - outcome: x.to_owned(), - payout, - } - }) - .collect(); - ContractDescriptor::Enum(EnumDescriptor { outcome_payouts }) + dlc::enum_descriptor(TOTAL_COLLATERAL) } pub async fn generate_blocks(nb_blocks: u32, electrs: Arc, sink: Arc) { @@ -293,27 +251,26 @@ pub async fn generate_blocks(nb_blocks: u32, electrs: Arc, sink: } pub async fn get_enum_oracle() -> MemoryOracle { - let oracle = MemoryOracle::default(); - - announce_enum_event(std::slice::from_ref(&oracle), EVENT_ID, EVENT_MATURITY).await; - - oracle + let oracles = dlc::new_oracles(1); + announce_enum_event(&oracles, EVENT_ID, EVENT_MATURITY).await; + oracles.into_iter().next().expect("one oracle") } /// Announces `event_id` on every oracle as an enum event over /// [`enum_outcomes`], maturing at `maturity`. -pub async fn announce_enum_event(oracles: &[MemoryOracle], event_id: &str, maturity: u32) { - for oracle in oracles { - oracle - .oracle - .create_enum_event(event_id.to_string(), enum_outcomes(), maturity) - .await - .unwrap(); - } +pub async fn announce_enum_event( + oracles: &[MemoryOracle], + event_id: &str, + maturity: u32, +) -> Vec { + dlc::announce_enum_event(oracles, event_id, maturity).await } /// Attests `event_id` on enough of `oracles` to satisfy `threshold`, and /// returns the outcome they signed. +/// +/// Which oracles sign is this suite's own policy: a random subset at or above +/// the threshold, on a random outcome. pub async fn attest_enum_event( oracles: &[MemoryOracle], event_id: &str, @@ -321,26 +278,15 @@ pub async fn attest_enum_event( ) -> String { let outcomes = enum_outcomes(); let outcome = outcomes[(thread_rng().next_u32() as usize) % outcomes.len()].clone(); - for index in select_active_oracles(oracles.len(), threshold) { - oracles[index] - .oracle - .sign_enum_event(event_id.to_string(), outcome.clone()) - .await - .unwrap(); - } - + let signers = select_active_oracles(oracles.len(), threshold); + dlc::sign_enum_event(oracles, event_id, &signers, &outcome).await; outcome } pub async fn get_enum_oracles(nb_oracles: usize, threshold: usize) -> Vec { - let mut oracles: Vec<_> = vec![]; - for _ in 0..nb_oracles { - let oracle = get_enum_oracle().await; - oracles.push(oracle); - } - + let oracles = dlc::new_oracles(nb_oracles); + announce_enum_event(&oracles, EVENT_ID, EVENT_MATURITY).await; attest_enum_event(&oracles, EVENT_ID, threshold).await; - oracles } @@ -354,48 +300,43 @@ pub async fn get_enum_test_params( None => get_enum_oracles(nb_oracles, threshold).await, }; - let contract_descriptor = get_enum_contract_descriptor(); - let contract_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: EVENT_ID.to_owned(), - threshold: threshold as u16, - }, - }; - - let contract_input = ContractInput { - offer_collateral: Amount::from_sat(OFFER_COLLATERAL), - accept_collateral: Amount::from_sat(ACCEPT_COLLATERAL), - fee_rate: 2, - contract_flags: 0, - contract_infos: vec![contract_info], - }; + let leg = ContractLeg::new( + get_enum_contract_descriptor(), + announcements(&oracles, EVENT_ID).await, + threshold as u16, + ); TestParams { + contract_input: contract_input(&[leg]), oracles, - contract_input, } } -/// How one splice round changes the collateral locked in a contract. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SpliceDelta { - /// Lock this much more, paid in from the splicing party's wallet. - In(Amount), - /// Release this much, paid back to the splicing party's change address. - Out(Amount), +/// The announcements `oracles` made for `event_id`. +/// +/// A [`ContractLeg`] is built from them: they name the oracles the manager has +/// to ask, and they are what the wire form of the same contract carries. +pub async fn announcements(oracles: &[MemoryOracle], event_id: &str) -> Vec { + let mut announcements = Vec::with_capacity(oracles.len()); + for oracle in oracles { + announcements.push( + oracle + .get_announcement(event_id) + .await + .expect("oracle announced this event"), + ); + } + announcements } -impl SpliceDelta { - /// The total collateral this round produces from a contract holding - /// `total`. - pub fn apply(self, total: Amount) -> Amount { - match self { - SpliceDelta::In(amount) => total + amount, - SpliceDelta::Out(amount) => total - amount, - } - } +/// The dual funded contract this suite offers, over `legs`. +fn contract_input(legs: &[ContractLeg]) -> ContractInput { + dlc::contract_input( + legs, + Amount::from_sat(OFFER_COLLATERAL), + Amount::from_sat(ACCEPT_COLLATERAL), + 2, + ) } /// The offering party's share of `total_collateral`, in the proportion the base @@ -422,7 +363,7 @@ pub async fn splice_test_params( total_collateral: Amount, maturity: u32, ) -> TestParams { - let mut contract_infos = Vec::with_capacity(base.contract_input.contract_infos.len()); + let mut legs = Vec::with_capacity(base.contract_input.contract_infos.len()); for (index, info) in base.contract_input.contract_infos.iter().enumerate() { let event_id = format!("{EVENT_ID}-splice-{round}-{index}"); @@ -440,15 +381,16 @@ pub async fn splice_test_params( }) .collect(); - let contract_descriptor = match &info.contract_descriptor { + let (contract_descriptor, announcements) = match &info.contract_descriptor { ContractDescriptor::Enum(_) => { - announce_enum_event(&oracles, &event_id, maturity).await; + let announcements = announce_enum_event(&oracles, &event_id, maturity).await; attest_enum_event(&oracles, &event_id, threshold).await; - enum_contract_descriptor(total_collateral) + (dlc::enum_descriptor(total_collateral), announcements) } ContractDescriptor::Numerical(numerical) => { let numeric_infos = &numerical.oracle_numeric_infos; - announce_numeric_event(&oracles, &event_id, numeric_infos, maturity).await; + let announcements = + announce_numeric_event(&oracles, &event_id, numeric_infos, maturity).await; attest_numeric_event( &oracles, &event_id, @@ -461,65 +403,44 @@ pub async fn splice_test_params( // The curve is rebuilt rather than scaled: a spliced contract is // a new contract, and only its family has to match the one it // replaces. - ContractDescriptor::Numerical(NumericalDescriptor { - payout_function: PayoutFunction::new(polynomial_payout_curve_pieces( + let descriptor = dlc::numeric_descriptor( + PayoutFunction::new(polynomial_payout_curve_pieces( numeric_infos.get_min_nb_digits(), offer_share(total_collateral), total_collateral, )) .unwrap(), - rounding_intervals: numerical.rounding_intervals.clone(), - oracle_numeric_infos: numeric_infos.clone(), - difference_params: numerical.difference_params.clone(), - }) + numeric_infos.clone(), + numerical.difference_params.clone(), + ); + (descriptor, announcements) } }; - contract_infos.push(ContractInputInfo { + legs.push(ContractLeg::new( contract_descriptor, - oracles: OracleInput { - public_keys: info.oracles.public_keys.clone(), - event_id, - threshold: info.oracles.threshold, - }, - }); + announcements, + info.oracles.threshold, + )); } TestParams { oracles: base.oracles.clone(), - contract_input: ContractInput { - offer_collateral: total_collateral, - accept_collateral: Amount::ZERO, - fee_rate: 2, - contract_flags: 0, - contract_infos, - }, + contract_input: dlc::contract_input(&legs, total_collateral, Amount::ZERO, 2), } } pub async fn get_single_funded_test_params(nb_oracles: usize, threshold: usize) -> TestParams { let oracles = get_enum_oracles(nb_oracles, threshold).await; - let contract_descriptor = get_enum_contract_descriptor(); - let contract_info = ContractInputInfo { - contract_descriptor, - oracles: OracleInput { - public_keys: oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: EVENT_ID.to_owned(), - threshold: 1, - }, - }; - - let contract_input = ContractInput { - offer_collateral: TOTAL_COLLATERAL, - accept_collateral: Amount::ZERO, - fee_rate: 5, - contract_flags: 0, - contract_infos: vec![contract_info], - }; + let leg = ContractLeg::new( + get_enum_contract_descriptor(), + announcements(&oracles, EVENT_ID).await, + 1, + ); TestParams { + contract_input: dlc::contract_input(&[leg], TOTAL_COLLATERAL, Amount::ZERO, 5), oracles, - contract_input, } } @@ -607,35 +528,17 @@ pub fn get_numerical_contract_descriptor( function_pieces: Vec, difference_params: Option, ) -> ContractDescriptor { - ContractDescriptor::Numerical(NumericalDescriptor { - payout_function: PayoutFunction::new(function_pieces).unwrap(), - rounding_intervals: RoundingIntervals { - intervals: vec![RoundingInterval { - begin_interval: 0, - rounding_mod: ROUNDING_MOD, - }], - }, + dlc::numeric_descriptor( + PayoutFunction::new(function_pieces).unwrap(), oracle_numeric_infos, difference_params, - }) + ) } pub async fn get_digit_decomposition_oracle(nb_digits: u16) -> MemoryOracle { - let oracle = MemoryOracle::default(); - - oracle - .oracle - .create_numeric_event( - EVENT_ID.to_string(), - nb_digits, - false, - 0, - "sats/sec".to_owned(), - EVENT_MATURITY, - ) - .await - .unwrap(); - oracle + let oracles = dlc::new_oracles(1); + dlc::announce_numeric_event(&oracles, EVENT_ID, &[nb_digits as usize], EVENT_MATURITY).await; + oracles.into_iter().next().expect("one oracle") } /// Announces `event_id` on every oracle as a digit decomposition event, sized @@ -645,21 +548,8 @@ pub async fn announce_numeric_event( event_id: &str, oracle_numeric_infos: &OracleNumericInfo, maturity: u32, -) { - for (oracle, nb_digits) in oracles.iter().zip(oracle_numeric_infos.nb_digits.iter()) { - oracle - .oracle - .create_numeric_event( - event_id.to_string(), - *nb_digits as u16, - false, - 0, - "sats/sec".to_owned(), - maturity, - ) - .await - .unwrap(); - } +) -> Vec { + dlc::announce_numeric_event(oracles, event_id, &oracle_numeric_infos.nb_digits, maturity).await } pub async fn get_digit_decomposition_oracles( @@ -668,10 +558,8 @@ pub async fn get_digit_decomposition_oracles( with_diff: bool, use_max_value: bool, ) -> Vec { - let mut oracles = vec![]; - for digit in &oracle_numeric_infos.nb_digits { - oracles.push(get_digit_decomposition_oracle(*digit as u16).await); - } + let oracles = dlc::new_oracles(oracle_numeric_infos.nb_digits.len()); + announce_numeric_event(&oracles, EVENT_ID, oracle_numeric_infos, EVENT_MATURITY).await; attest_numeric_event( &oracles, @@ -705,6 +593,7 @@ pub async fn attest_numeric_event( (thread_rng().next_u32() % max_value()) as usize }; let oracle_indexes = select_active_oracles(oracle_numeric_infos.nb_digits.len(), threshold); + let all_oracles: Vec = (0..oracles.len()).collect(); for (i, index) in oracle_indexes.iter().enumerate() { let cur_outcome: usize = if !use_max_value && (i == 0 || !with_diff) { @@ -736,12 +625,10 @@ pub async fn attest_numeric_event( } }; - for oracle in oracles { - let _sign_even_if_it_fails_spent_an_hour_tracking_ci_bug = oracle - .oracle - .sign_numeric_event(event_id.to_string(), cur_outcome as i64) - .await; - } + // Every oracle is offered every candidate outcome, and the one that + // takes it keeps it: an oracle that has already signed this event + // refuses the rest. + dlc::sign_numeric_event(oracles, event_id, &all_oracles, cur_outcome as i64).await; } } @@ -755,26 +642,15 @@ pub async fn get_numerical_test_params( let oracles = get_digit_decomposition_oracles(oracle_numeric_infos, threshold, with_diff, use_max_value) .await; - let contract_info = ContractInputInfo { - oracles: OracleInput { - public_keys: oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: EVENT_ID.to_owned(), - threshold: threshold as u16, - }, + let leg = ContractLeg::new( contract_descriptor, - }; - - let contract_input = ContractInput { - offer_collateral: Amount::from_sat(OFFER_COLLATERAL), - accept_collateral: Amount::from_sat(ACCEPT_COLLATERAL), - fee_rate: 2, - contract_flags: 0, - contract_infos: vec![contract_info], - }; + announcements(&oracles, EVENT_ID).await, + threshold as u16, + ); TestParams { + contract_input: contract_input(&[leg]), oracles, - contract_input, } } @@ -786,61 +662,40 @@ pub async fn get_enum_and_numerical_test_params( ) -> TestParams { let oracle_numeric_infos = get_same_num_digits_oracle_numeric_infos(nb_oracles); let enum_oracles = get_enum_oracles(nb_oracles, threshold).await; - let enum_contract_descriptor = get_enum_contract_descriptor(); - let enum_contract_info = ContractInputInfo { - oracles: OracleInput { - public_keys: enum_oracles.iter().map(|x| x.get_public_key()).collect(), - event_id: EVENT_ID.to_owned(), - threshold: threshold as u16, - }, - contract_descriptor: enum_contract_descriptor, - }; + let enum_leg = ContractLeg::new( + get_enum_contract_descriptor(), + announcements(&enum_oracles, EVENT_ID).await, + threshold as u16, + ); + let numerical_oracles = get_digit_decomposition_oracles(&oracle_numeric_infos, threshold, with_diff, false).await; - let numerical_contract_descriptor = get_numerical_contract_descriptor( - get_same_num_digits_oracle_numeric_infos(nb_oracles), - get_polynomial_payout_curve_pieces(oracle_numeric_infos.get_min_nb_digits()), - difference_params, + let numerical_leg = ContractLeg::new( + get_numerical_contract_descriptor( + get_same_num_digits_oracle_numeric_infos(nb_oracles), + get_polynomial_payout_curve_pieces(oracle_numeric_infos.get_min_nb_digits()), + difference_params, + ), + announcements(&numerical_oracles, EVENT_ID).await, + threshold as u16, ); - let numerical_contract_info = ContractInputInfo { - oracles: OracleInput { - public_keys: numerical_oracles - .iter() - .map(|x| x.get_public_key()) - .collect(), - event_id: EVENT_ID.to_owned(), - threshold: threshold as u16, - }, - contract_descriptor: numerical_contract_descriptor, - }; - let contract_infos = if thread_rng().next_u32() % 2 == 0 { - vec![enum_contract_info, numerical_contract_info] + // Which event comes first is not fixed: a disjoint contract settles on + // whichever attests, in whatever order it was offered. + let legs = if thread_rng().next_u32() % 2 == 0 { + [enum_leg, numerical_leg] } else { - vec![numerical_contract_info, enum_contract_info] - }; - - let contract_input = ContractInput { - offer_collateral: Amount::from_sat(OFFER_COLLATERAL), - accept_collateral: Amount::from_sat(ACCEPT_COLLATERAL), - fee_rate: 2, - contract_flags: 0, - contract_infos, + [numerical_leg, enum_leg] }; TestParams { + contract_input: contract_input(&legs), oracles: enum_oracles.into_iter().chain(numerical_oracles).collect(), - contract_input, } } pub fn get_same_num_digits_oracle_numeric_infos(nb_oracles: usize) -> OracleNumericInfo { - OracleNumericInfo { - nb_digits: std::iter::repeat(NB_DIGITS as usize) - .take(nb_oracles) - .collect(), - base: BASE as usize, - } + dlc::numeric_infos(nb_oracles) } pub fn get_variable_oracle_numeric_infos(nb_digits: &[usize]) -> OracleNumericInfo { diff --git a/ddk/Cargo.toml b/ddk/Cargo.toml index cf1012d..61852b1 100644 --- a/ddk/Cargo.toml +++ b/ddk/Cargo.toml @@ -96,7 +96,7 @@ dotenvy = { workspace = true } # The `nostr`/`postgres` backends are enabled unconditionally rather than from # ddk's own features of the same name: a feature can't forward to a path-only # dev-dependency, because cargo strips that dependency when packaging. -ddk-testenv = { workspace = true, features = ["nostr", "postgres"] } +ddk-testenv = { workspace = true, features = ["dlc", "nostr", "postgres"] } [[example]] name = "lightning" diff --git a/ddk/tests/stateless_execution.rs b/ddk/tests/stateless_execution.rs index fc7ad4e..e8a842b 100644 --- a/ddk/tests/stateless_execution.rs +++ b/ddk/tests/stateless_execution.rs @@ -467,21 +467,21 @@ struct SpliceRound { /// with the whole of the previous funding output, whichever side of that /// contract it was, so it also puts up the whole of the new collateral. splicer: Party, - splice_in: bool, + delta: SpliceDelta, } impl SpliceRound { fn splice_in(splicer: Party) -> Self { Self { splicer, - splice_in: true, + delta: SpliceDelta::In(SPLICE_AMOUNT), } } fn splice_out(splicer: Party) -> Self { Self { splicer, - splice_in: false, + delta: SpliceDelta::Out(SPLICE_AMOUNT), } } } @@ -523,11 +523,7 @@ async fn splice_chain_and_close( for (index, round) in rounds.iter().enumerate() { let number = index + 1; let round_label = format!("{label}-{number}"); - let collateral = if round.splice_in { - previous.fund_value() + SPLICE_AMOUNT - } else { - previous.fund_value() - SPLICE_AMOUNT - }; + let collateral = round.delta.apply(previous.fund_value()); // Only a numeric payout curve reads the split between the two sides; // every shape locks their sum. let offer_share = collateral / 2; @@ -536,7 +532,7 @@ async fn splice_chain_and_close( let spliced_id = temporary_contract_id(&round_label); let seed_byte = 43 + index as u8 * 2; - let offer_spec = if round.splice_in { + let offer_spec = if round.delta.is_in() { PartySpec::new(Party::Offer, seed_byte, 20 + number as u64) } else { PartySpec::unfunded(Party::Offer, seed_byte) @@ -559,7 +555,7 @@ async fn splice_chain_and_close( ) .await; - assert_splice(&ctx, &previous, &spliced, round.splice_in).await; + assert_splice(&ctx, &previous, &spliced, round.delta.is_in()).await; previous = spliced; settled = Some(spliced_shape); diff --git a/ddk/tests/stateless_utils.rs b/ddk/tests/stateless_utils.rs index 189f574..8a3134c 100644 --- a/ddk/tests/stateless_utils.rs +++ b/ddk/tests/stateless_utils.rs @@ -42,33 +42,24 @@ use ddk::storage::memory::MemoryStorage; use ddk::wallet::DlcDevKitWallet; use ddk_dlc::secp256k1_zkp::{All, PublicKey, Secp256k1, SecretKey}; use ddk_dlc::DlcTransactions; -use ddk_manager::contract::numerical_descriptor::{DifferenceParams, NumericalDescriptor}; -use ddk_manager::payout_curve::{RoundingInterval, RoundingIntervals}; -use ddk_manager::{Blockchain, Oracle}; -use ddk_messages::contract_msgs::{ - ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, DisjointContractInfo, - EnumeratedContractDescriptor, NumericOutcomeContractDescriptor, SingleContractInfo, -}; -use ddk_messages::oracle_msgs::{ - MultiOracleInfo, OracleAnnouncement, OracleAttestation, OracleInfo, OracleParams, - SingleOracleInfo, -}; +use ddk_manager::contract::numerical_descriptor::DifferenceParams; +use ddk_manager::Blockchain; +use ddk_messages::contract_msgs::ContractInfo; +use ddk_messages::oracle_msgs::{OracleAnnouncement, OracleAttestation}; use ddk_messages::{AcceptDlc, FundingInput, OfferDlc, SignDlc}; +use ddk_testenv::dlc; use ddk_testenv::TestEnv; -use ddk_trie::OracleNumericInfo; use std::str::FromStr; +// Oracles, events and contract descriptors are the same in both DLC test +// suites, so they come from [`ddk_testenv::dlc`] rather than from here. +pub use ddk_testenv::dlc::{ + difference_params, max_value as max_numeric_value, SpliceDelta, EVENT_MATURITY, NB_DIGITS, +}; + /// The chain every test runs against. pub const NETWORK: Network = Network::Regtest; -/// Oracle event maturity, deliberately in the past. -/// -/// CET and refund locktimes are derived from it, and regtest block timestamps -/// track the real wall clock, so both are already spendable the moment the -/// funding transaction confirms. This is the same trick the manager execution -/// tests use. -pub const EVENT_MATURITY: u32 = 1_623_133_104; - /// Distance between the oracle maturity and the refund locktime. pub const REFUND_DELAY: u32 = 604_800; @@ -76,11 +67,6 @@ pub const REFUND_DELAY: u32 = 604_800; pub const MIN_TIMEOUT_INTERVAL: u32 = REFUND_DELAY; pub const MAX_TIMEOUT_INTERVAL: u32 = REFUND_DELAY; -pub const BASE: u32 = 2; -pub const NB_DIGITS: u16 = 10; -pub const MIN_SUPPORT_EXP: usize = 1; -pub const MAX_ERROR_EXP: usize = 2; - pub const OFFER_COLLATERAL: Amount = Amount::from_sat(1_000_000); pub const ACCEPT_COLLATERAL: Amount = Amount::from_sat(1_000_000); pub const TOTAL_COLLATERAL: Amount = Amount::from_sat(2_000_000); @@ -323,18 +309,8 @@ pub struct TestOracles { impl TestOracles { /// Creates `nb_oracles` oracles announcing the same enum event. pub async fn enums(nb_oracles: usize, threshold: u16, event_id: &str) -> Self { - let mut oracles = Vec::with_capacity(nb_oracles); - let mut announcements = Vec::with_capacity(nb_oracles); - for _ in 0..nb_oracles { - let oracle = MemoryOracle::default(); - let announcement = oracle - .oracle - .create_enum_event(event_id.to_string(), enum_outcomes(), EVENT_MATURITY) - .await - .unwrap(); - announcements.push(announcement); - oracles.push(oracle); - } + let oracles = dlc::new_oracles(nb_oracles); + let announcements = dlc::announce_enum_event(&oracles, event_id, EVENT_MATURITY).await; Self { oracles, announcements, @@ -345,25 +321,14 @@ impl TestOracles { /// Creates `nb_oracles` oracles announcing the same digit decomposition event. pub async fn numerics(nb_oracles: usize, threshold: u16, event_id: &str) -> Self { - let mut oracles = Vec::with_capacity(nb_oracles); - let mut announcements = Vec::with_capacity(nb_oracles); - for _ in 0..nb_oracles { - let oracle = MemoryOracle::default(); - let announcement = oracle - .oracle - .create_numeric_event( - event_id.to_string(), - NB_DIGITS, - false, - 0, - "sats".to_string(), - EVENT_MATURITY, - ) - .await - .unwrap(); - announcements.push(announcement); - oracles.push(oracle); - } + let oracles = dlc::new_oracles(nb_oracles); + let announcements = dlc::announce_numeric_event( + &oracles, + event_id, + &vec![NB_DIGITS as usize; nb_oracles], + EVENT_MATURITY, + ) + .await; Self { oracles, announcements, @@ -372,28 +337,23 @@ impl TestOracles { } } - /// Attests `outcome` with the first `threshold` oracles and returns the - /// attestations paired with their index in [`Self::announcements`]. + /// The oracles that settle a contract: the first `threshold` of them. + /// + /// Which oracles sign is this suite's own policy — the manager tests pick a + /// random subset above the threshold instead. + fn signers(&self) -> Vec { + (0..self.threshold as usize).collect() + } + + /// Attests `outcome` with the settling oracles and returns the attestations + /// paired with their index in [`Self::announcements`]. pub async fn attest_enum(&self, outcome: &str) -> Vec<(usize, OracleAttestation)> { - let mut attestations = Vec::new(); - for index in 0..self.threshold as usize { - self.oracles[index] - .oracle - .sign_enum_event(self.event_id.clone(), outcome.to_string()) - .await - .unwrap(); - attestations.push(( - index, - self.oracles[index] - .get_attestation(&self.event_id) - .await - .unwrap(), - )); - } - attestations + let signers = self.signers(); + dlc::sign_enum_event(&self.oracles, &self.event_id, &signers, outcome).await; + dlc::attestations(&self.oracles, &self.event_id, &signers).await } - /// Attests a numeric outcome with the first `threshold` oracles. + /// Attests a numeric outcome with the settling oracles. /// /// When `spread` is set the oracles after the first alternate one unit /// either side of `outcome`, which is the disagreement a contract with @@ -403,9 +363,9 @@ impl TestOracles { outcome: i64, spread: bool, ) -> Vec<(usize, OracleAttestation)> { - let mut attestations = Vec::new(); - for index in 0..self.threshold as usize { - let signed_outcome = if spread && index > 0 { + let signers = self.signers(); + for index in &signers { + let signed_outcome = if spread && *index > 0 { if index % 2 == 0 { outcome + 1 } else { @@ -414,74 +374,31 @@ impl TestOracles { } else { outcome }; - self.oracles[index] - .oracle - .sign_numeric_event(self.event_id.clone(), signed_outcome) - .await - .unwrap(); - attestations.push(( - index, - self.oracles[index] - .get_attestation(&self.event_id) - .await - .unwrap(), - )); + dlc::sign_numeric_event( + &self.oracles, + &self.event_id, + std::slice::from_ref(index), + signed_outcome, + ) + .await; } - attestations + dlc::attestations(&self.oracles, &self.event_id, &signers).await } - fn oracle_info(&self, oracle_params: Option) -> OracleInfo { - if self.announcements.len() == 1 && oracle_params.is_none() { - OracleInfo::Single(SingleOracleInfo { - oracle_announcement: self.announcements[0].clone(), - }) - } else { - OracleInfo::Multi(MultiOracleInfo { - threshold: self.threshold, - oracle_announcements: self.announcements.clone(), - oracle_params, - }) - } + /// One event of a contract: `descriptor`, settled by these oracles. + fn leg(&self, descriptor: ddk_manager::contract::ContractDescriptor) -> dlc::ContractLeg { + dlc::ContractLeg::new(descriptor, self.announcements.clone(), self.threshold) } } -pub fn enum_outcomes() -> Vec { - vec![ - "a".to_string(), - "b".to_string(), - "c".to_string(), - "d".to_string(), - ] -} - -pub fn max_numeric_value() -> u64 { - (BASE as u64).pow(NB_DIGITS as u32) - 1 -} - -/// A two-outcome-per-side enum descriptor over [`enum_outcomes`]. -fn enum_descriptor(total_collateral: Amount) -> ContractDescriptor { - ContractDescriptor::EnumeratedContractDescriptor(EnumeratedContractDescriptor { - payouts: enum_outcomes() - .into_iter() - .enumerate() - .map(|(index, outcome)| ContractOutcome { - outcome, - offer_payout: if index % 2 == 0 { - total_collateral - } else { - Amount::ZERO - }, - }) - .collect(), - }) -} - +/// The payout curve every numeric contract in this suite runs on: a straight +/// line from nothing to the whole collateral over the first 900 outcomes. fn numeric_descriptor( - nb_oracles: usize, + oracles: &TestOracles, offer_collateral: Amount, accept_collateral: Amount, difference_params: Option, -) -> ContractDescriptor { +) -> ddk_manager::contract::ContractDescriptor { let payout_function = ddk_payouts::generate_payout_curve( 0, 900, @@ -491,34 +408,19 @@ fn numeric_descriptor( max_numeric_value(), ) .unwrap(); - let numerical = NumericalDescriptor { + dlc::numeric_descriptor( payout_function, - rounding_intervals: RoundingIntervals { - intervals: vec![RoundingInterval { - begin_interval: 0, - rounding_mod: 1, - }], - }, + dlc::numeric_infos(oracles.announcements.len()), difference_params, - oracle_numeric_infos: OracleNumericInfo { - base: BASE as usize, - nb_digits: vec![NB_DIGITS as usize; nb_oracles], - }, - }; - ContractDescriptor::NumericOutcomeContractDescriptor(NumericOutcomeContractDescriptor::from( - &numerical, - )) + ) } /// A single-event enum contract. pub fn enum_contract_info(oracles: &TestOracles, total_collateral: Amount) -> ContractInfo { - ContractInfo::SingleContractInfo(SingleContractInfo { + dlc::contract_info( + &[oracles.leg(dlc::enum_descriptor(total_collateral))], total_collateral, - contract_info: ContractInfoInner { - contract_descriptor: enum_descriptor(total_collateral), - oracle_info: oracles.oracle_info(None), - }, - }) + ) } /// A single-event numeric contract, optionally tolerating oracle disagreement. @@ -528,23 +430,16 @@ pub fn numeric_contract_info( accept_collateral: Amount, difference_params: Option, ) -> ContractInfo { - let oracle_params = difference_params.as_ref().map(|params| OracleParams { - max_error_exp: params.max_error_exp as u16, - min_fail_exp: params.min_support_exp as u16, - maximize_coverage: params.maximize_coverage, - }); - ContractInfo::SingleContractInfo(SingleContractInfo { - total_collateral: offer_collateral + accept_collateral, - contract_info: ContractInfoInner { - contract_descriptor: numeric_descriptor( - oracles.announcements.len(), - offer_collateral, - accept_collateral, - difference_params, - ), - oracle_info: oracles.oracle_info(oracle_params), - }, - }) + let descriptor = numeric_descriptor( + oracles, + offer_collateral, + accept_collateral, + difference_params, + ); + dlc::contract_info( + &[oracles.leg(descriptor)], + offer_collateral + accept_collateral, + ) } /// A disjoint contract: either the enum event or the numeric event can settle it. @@ -555,32 +450,14 @@ pub fn disjoint_contract_info( accept_collateral: Amount, ) -> ContractInfo { let total_collateral = offer_collateral + accept_collateral; - ContractInfo::DisjointContractInfo(DisjointContractInfo { - total_collateral, - contract_infos: vec![ - ContractInfoInner { - contract_descriptor: enum_descriptor(total_collateral), - oracle_info: enum_oracles.oracle_info(None), - }, - ContractInfoInner { - contract_descriptor: numeric_descriptor( - numeric_oracles.announcements.len(), - offer_collateral, - accept_collateral, - None, - ), - oracle_info: numeric_oracles.oracle_info(None), - }, + let numeric = numeric_descriptor(numeric_oracles, offer_collateral, accept_collateral, None); + dlc::contract_info( + &[ + enum_oracles.leg(dlc::enum_descriptor(total_collateral)), + numeric_oracles.leg(numeric), ], - }) -} - -pub fn difference_params() -> DifferenceParams { - DifferenceParams { - max_error_exp: MAX_ERROR_EXP, - min_support_exp: MIN_SUPPORT_EXP, - maximize_coverage: false, - } + total_collateral, + ) } /// The enum outcome a scenario settles on. Index 0 of [`enum_outcomes`], so it diff --git a/testenv/Cargo.toml b/testenv/Cargo.toml index a345019..c1d810a 100644 --- a/testenv/Cargo.toml +++ b/testenv/Cargo.toml @@ -17,6 +17,11 @@ default = [] nostr = ["dep:nostr-relay-builder"] # Embedded PostgreSQL server, for the `postgres` storage tests. postgres = ["dep:postgresql_embedded"] +# Oracles, events and contract shapes, shared by the `ddk` and `ddk-manager` +# test suites. This depends on `ddk`, which depends on `ddk-manager`, both of +# which depend on this crate in turn — a cycle cargo allows because theirs are +# dev-dependencies. +dlc = ["dep:ddk", "dep:ddk-dlc", "dep:ddk-manager", "dep:ddk-messages", "dep:ddk-trie"] [dependencies] bitcoin = { workspace = true, features = ["std"] } @@ -25,6 +30,12 @@ bitcoind = { workspace = true } electrsd = { workspace = true } libc = "0.2" +ddk = { workspace = true, features = ["manager"], optional = true } +ddk-dlc = { workspace = true, optional = true } +ddk-manager = { workspace = true, features = ["manager"], optional = true } +ddk-messages = { workspace = true, optional = true } +ddk-trie = { workspace = true, optional = true } + nostr-relay-builder = { version = "0.44.1", optional = true } # Pinned to 0.19: 0.20+ raise the MSRV to 1.94, above this workspace's toolchain. postgresql_embedded = { version = "0.19.0", optional = true } diff --git a/testenv/src/dlc.rs b/testenv/src/dlc.rs new file mode 100644 index 0000000..32c1921 --- /dev/null +++ b/testenv/src/dlc.rs @@ -0,0 +1,436 @@ +//! Oracles, events and contracts for the DLC test suites. +//! +//! `ddk`'s stateless tests and `ddk-manager`'s manager tests need the same +//! things: oracles that announce an event and later attest it, an enum +//! descriptor over a fixed outcome set, a numeric descriptor over a payout +//! curve, and a contract built from those descriptors. Both suites used to +//! build all of it themselves. +//! +//! What they do not share is the layer they hand the contract to. +//! `ddk-manager` takes a [`ContractInput`], which names oracles by public key +//! and event id and lets the manager collect the announcements. +//! `ddk::contract` takes the wire [`ContractInfo`], which carries the +//! announcements itself. A [`ContractLeg`] produces either form from one +//! descriptor. +//! +//! Policy stays with each suite: which oracles sign, what they sign, and what +//! payout curve a numeric contract runs on. The manager tests randomize all +//! three; the stateless tests fix them. + +use bitcoin::{Amount, XOnlyPublicKey}; +use ddk::oracle::memory::MemoryOracle; +use ddk_dlc::{EnumerationPayout, Payout}; +use ddk_manager::contract::contract_input::{ContractInput, ContractInputInfo, OracleInput}; +use ddk_manager::contract::enum_descriptor::EnumDescriptor; +use ddk_manager::contract::numerical_descriptor::{DifferenceParams, NumericalDescriptor}; +use ddk_manager::contract::ContractDescriptor; +use ddk_manager::payout_curve::{PayoutFunction, RoundingInterval, RoundingIntervals}; +use ddk_manager::Oracle; +use ddk_messages::contract_msgs::{ + ContractInfo, ContractInfoInner, DisjointContractInfo, SingleContractInfo, +}; +use ddk_messages::oracle_msgs::{ + MultiOracleInfo, OracleAnnouncement, OracleAttestation, OracleInfo, OracleParams, + SingleOracleInfo, +}; +use ddk_trie::OracleNumericInfo; + +/// Oracle event maturity, deliberately in the past. +/// +/// CET and refund locktimes are derived from it, and regtest block timestamps +/// track the real wall clock, so both are already spendable the moment the +/// funding transaction confirms. +pub const EVENT_MATURITY: u32 = 1_623_133_104; + +/// The base a numeric event decomposes its outcome into. +pub const BASE: u32 = 2; + +/// How many digits a numeric event announces. +pub const NB_DIGITS: u16 = 10; + +/// The exponent bounding the oracle spread a contract with difference params +/// supports. +pub const MIN_SUPPORT_EXP: usize = 1; + +/// The exponent bounding the oracle error such a contract tolerates. +pub const MAX_ERROR_EXP: usize = 2; + +/// Payouts are rounded to whole satoshis. +pub const ROUNDING_MOD: u64 = 1; + +/// The unit a numeric event reports its outcome in. +const NUMERIC_UNIT: &str = "sats"; + +/// The outcomes of every enum event these suites announce. +pub fn enum_outcomes() -> Vec { + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ] +} + +/// The largest outcome an event of `nb_digits` digits can attest. +pub fn max_value_from_digits(nb_digits: usize) -> u64 { + (BASE as u64).pow(nb_digits as u32) - 1 +} + +/// The largest outcome an event of [`NB_DIGITS`] digits can attest. +pub fn max_value() -> u64 { + max_value_from_digits(NB_DIGITS as usize) +} + +/// The oracle disagreement a numeric contract can be built to tolerate. +pub fn difference_params() -> DifferenceParams { + DifferenceParams { + max_error_exp: MAX_ERROR_EXP, + min_support_exp: MIN_SUPPORT_EXP, + maximize_coverage: false, + } +} + +/// `nb_oracles` oracles that all decompose an outcome the same way. +pub fn numeric_infos(nb_oracles: usize) -> OracleNumericInfo { + variable_numeric_infos(&vec![NB_DIGITS as usize; nb_oracles]) +} + +/// Oracles that decompose an outcome into a digit count each, which do not +/// have to match. +pub fn variable_numeric_infos(nb_digits: &[usize]) -> OracleNumericInfo { + OracleNumericInfo { + base: BASE as usize, + nb_digits: nb_digits.to_vec(), + } +} + +// --- Oracles --------------------------------------------------------------- + +/// Creates `nb_oracles` oracles that have announced nothing. +pub fn new_oracles(nb_oracles: usize) -> Vec { + (0..nb_oracles).map(|_| MemoryOracle::default()).collect() +} + +/// The public keys of `oracles`, in order. +pub fn public_keys(oracles: &[MemoryOracle]) -> Vec { + oracles + .iter() + .map(|oracle| oracle.get_public_key()) + .collect() +} + +/// Announces `event_id` on every oracle as an enum event over +/// [`enum_outcomes`], maturing at `maturity`. +pub async fn announce_enum_event( + oracles: &[MemoryOracle], + event_id: &str, + maturity: u32, +) -> Vec { + let mut announcements = Vec::with_capacity(oracles.len()); + for oracle in oracles { + announcements.push( + oracle + .oracle + .create_enum_event(event_id.to_string(), enum_outcomes(), maturity) + .await + .expect("oracle could not announce the enum event"), + ); + } + announcements +} + +/// Announces `event_id` on every oracle as a digit decomposition event, sized +/// from that oracle's entry in `nb_digits`. +pub async fn announce_numeric_event( + oracles: &[MemoryOracle], + event_id: &str, + nb_digits: &[usize], + maturity: u32, +) -> Vec { + assert_eq!( + oracles.len(), + nb_digits.len(), + "every oracle needs a digit count" + ); + let mut announcements = Vec::with_capacity(oracles.len()); + for (oracle, digits) in oracles.iter().zip(nb_digits) { + announcements.push( + oracle + .oracle + .create_numeric_event( + event_id.to_string(), + *digits as u16, + false, + 0, + NUMERIC_UNIT.to_string(), + maturity, + ) + .await + .expect("oracle could not announce the numeric event"), + ); + } + announcements +} + +/// Signs `outcome` on the oracles `signers` names by index. +/// +/// An oracle that already signed this event is left as it is, rather than +/// failing: a caller may offer several candidate outcomes and let the first +/// one stand. +pub async fn sign_enum_event( + oracles: &[MemoryOracle], + event_id: &str, + signers: &[usize], + outcome: &str, +) { + for index in signers { + let _already_signed = oracles[*index] + .oracle + .sign_enum_event(event_id.to_string(), outcome.to_string()) + .await; + } +} + +/// Signs `value` on the oracles `signers` names by index, under the same rule +/// as [`sign_enum_event`]. +pub async fn sign_numeric_event( + oracles: &[MemoryOracle], + event_id: &str, + signers: &[usize], + value: i64, +) { + for index in signers { + let _already_signed = oracles[*index] + .oracle + .sign_numeric_event(event_id.to_string(), value) + .await; + } +} + +/// The attestations of the oracles `signers` names, paired with their index. +/// +/// An oracle that never signed the event has nothing to attest, and asking for +/// its attestation is a test bug rather than a settlement failure, so it is +/// caught here. +pub async fn attestations( + oracles: &[MemoryOracle], + event_id: &str, + signers: &[usize], +) -> Vec<(usize, OracleAttestation)> { + let mut attestations = Vec::with_capacity(signers.len()); + for index in signers { + let attestation = oracles[*index] + .get_attestation(event_id) + .await + .expect("oracle could not produce an attestation"); + assert!( + !attestation.signatures.is_empty(), + "oracle {index} did not sign {event_id}" + ); + attestations.push((*index, attestation)); + } + attestations +} + +// --- Contracts ------------------------------------------------------------- + +/// An enum descriptor over [`enum_outcomes`] that pays the whole of +/// `total_collateral` to alternating parties. +pub fn enum_descriptor(total_collateral: Amount) -> ContractDescriptor { + let outcome_payouts = enum_outcomes() + .into_iter() + .enumerate() + .map(|(index, outcome)| { + let payout = if index % 2 == 0 { + Payout { + offer: total_collateral, + accept: Amount::ZERO, + } + } else { + Payout { + offer: Amount::ZERO, + accept: total_collateral, + } + }; + EnumerationPayout { outcome, payout } + }) + .collect(); + ContractDescriptor::Enum(EnumDescriptor { outcome_payouts }) +} + +/// A numeric descriptor over `payout_function`, rounded to [`ROUNDING_MOD`]. +/// +/// The curve is the caller's: the two suites build different ones, and which +/// one a contract runs on is part of what those tests cover. +pub fn numeric_descriptor( + payout_function: PayoutFunction, + oracle_numeric_infos: OracleNumericInfo, + difference_params: Option, +) -> ContractDescriptor { + ContractDescriptor::Numerical(NumericalDescriptor { + payout_function, + rounding_intervals: RoundingIntervals { + intervals: vec![RoundingInterval { + begin_interval: 0, + rounding_mod: ROUNDING_MOD, + }], + }, + oracle_numeric_infos, + difference_params, + }) +} + +/// One event of a contract: what it pays out, and the oracles that settle it. +/// +/// The announcements carry what both contract forms need — the oracle public +/// keys and the event id for a [`ContractInput`], the announcements themselves +/// for the wire [`ContractInfo`]. +pub struct ContractLeg { + pub descriptor: ContractDescriptor, + pub announcements: Vec, + pub threshold: u16, +} + +impl ContractLeg { + pub fn new( + descriptor: ContractDescriptor, + announcements: Vec, + threshold: u16, + ) -> Self { + assert!( + !announcements.is_empty(), + "a contract leg needs at least one oracle" + ); + assert!( + threshold as usize <= announcements.len(), + "a threshold cannot ask for more oracles than the leg has" + ); + Self { + descriptor, + announcements, + threshold, + } + } + + /// The event every oracle in this leg announced. + pub fn event_id(&self) -> &str { + &self.announcements[0].oracle_event.event_id + } + + /// The oracle spread this leg tolerates, if it is a numeric one built to + /// tolerate any. + fn difference_params(&self) -> Option<&DifferenceParams> { + match &self.descriptor { + ContractDescriptor::Numerical(numeric) => numeric.difference_params.as_ref(), + ContractDescriptor::Enum(_) => None, + } + } + + /// The wire oracle info: a single announcement, or several with the + /// threshold and the spread the contract tolerates. + pub fn oracle_info(&self) -> OracleInfo { + let params = self.difference_params().map(|params| OracleParams { + max_error_exp: params.max_error_exp as u16, + min_fail_exp: params.min_support_exp as u16, + maximize_coverage: params.maximize_coverage, + }); + if self.announcements.len() == 1 && params.is_none() { + OracleInfo::Single(SingleOracleInfo { + oracle_announcement: self.announcements[0].clone(), + }) + } else { + OracleInfo::Multi(MultiOracleInfo { + threshold: self.threshold, + oracle_announcements: self.announcements.clone(), + oracle_params: params, + }) + } + } + + /// This leg in wire form. + pub fn contract_info_inner(&self) -> ContractInfoInner { + ContractInfoInner { + contract_descriptor: (&self.descriptor).into(), + oracle_info: self.oracle_info(), + } + } + + /// This leg as the manager takes it. + pub fn contract_input_info(&self) -> ContractInputInfo { + ContractInputInfo { + contract_descriptor: self.descriptor.clone(), + oracles: OracleInput { + public_keys: self + .announcements + .iter() + .map(|announcement| announcement.oracle_public_key) + .collect(), + event_id: self.event_id().to_string(), + threshold: self.threshold, + }, + } + } +} + +/// A contract over `legs`, locking `total_collateral`, in wire form. +/// +/// One leg makes a single contract; several make a disjoint one, which any of +/// them can settle. +pub fn contract_info(legs: &[ContractLeg], total_collateral: Amount) -> ContractInfo { + let mut contract_infos: Vec = + legs.iter().map(ContractLeg::contract_info_inner).collect(); + if contract_infos.len() == 1 { + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral, + contract_info: contract_infos.remove(0), + }) + } else { + ContractInfo::DisjointContractInfo(DisjointContractInfo { + total_collateral, + contract_infos, + }) + } +} + +/// The manager's input for a contract over `legs`. +pub fn contract_input( + legs: &[ContractLeg], + offer_collateral: Amount, + accept_collateral: Amount, + fee_rate: u64, +) -> ContractInput { + ContractInput { + offer_collateral, + accept_collateral, + fee_rate, + contract_flags: 0, + contract_infos: legs.iter().map(ContractLeg::contract_input_info).collect(), + } +} + +// --- Splices --------------------------------------------------------------- + +/// How one splice round changes the collateral locked in a contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpliceDelta { + /// Lock this much more, paid in from the splicing party's wallet. + In(Amount), + /// Release this much, paid back to the splicing party. + Out(Amount), +} + +impl SpliceDelta { + /// The total collateral this round produces from a contract holding + /// `total`. + pub fn apply(self, total: Amount) -> Amount { + match self { + SpliceDelta::In(amount) => total + amount, + SpliceDelta::Out(amount) => total - amount, + } + } + + /// Whether this round adds collateral. + pub fn is_in(self) -> bool { + matches!(self, SpliceDelta::In(_)) + } +} diff --git a/testenv/src/lib.rs b/testenv/src/lib.rs index 2b89c7f..0846fd2 100644 --- a/testenv/src/lib.rs +++ b/testenv/src/lib.rs @@ -37,6 +37,8 @@ pub use bitcoincore_rpc; pub use bitcoind; pub use electrsd; +#[cfg(feature = "dlc")] +pub mod dlc; #[cfg(feature = "nostr")] pub mod nostr; #[cfg(feature = "postgres")]