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/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-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 402f7c8..e8a842b 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,131 @@ 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, + delta: SpliceDelta, +} -/// 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. +impl SpliceRound { + fn splice_in(splicer: Party) -> Self { + Self { + splicer, + delta: SpliceDelta::In(SPLICE_AMOUNT), + } + } + + fn splice_out(splicer: Party) -> Self { + Self { + splicer, + delta: SpliceDelta::Out(SPLICE_AMOUNT), + } + } +} + +/// 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 = 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; + 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.delta.is_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.delta.is_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 +606,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 +822,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_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_alternating_parties_funds_and_closes", - [(true, Party::Offer), (false, Party::Accept)], + "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..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,31 +450,200 @@ pub fn disjoint_contract_info( accept_collateral: Amount, ) -> ContractInfo { let total_collateral = offer_collateral + accept_collateral; - ContractInfo::DisjointContractInfo(DisjointContractInfo { + 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), + ], 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(), + ) +} + +/// 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, - None, - ), - oracle_info: numeric_oracles.oracle_info(None), - }, - ], - }) -} + 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, + } + } + } + } -pub fn difference_params() -> DifferenceParams { - DifferenceParams { - max_error_exp: MAX_ERROR_EXP, - min_support_exp: MIN_SUPPORT_EXP, - maximize_coverage: false, + /// 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 + } + } } } 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")]