From a603e9528dffeedb796752af5aab877ce8b4ee00 Mon Sep 17 00:00:00 2001 From: bc1cindy Date: Mon, 1 Jun 2026 01:12:21 -0300 Subject: [PATCH] Integrate dense-subset-sum into btsim (exploration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the dense-subset-sum 4-path subset-sum/coinjoin-mapping counts (brute, radix, sparse, Sasamoto) into btsim two ways: - As agent cost: WLowerBoundMetric (W lower bound via brute/sparse) and RadixMappingMetric (k×m! denomination mappings, gated by radix density) plug into the existing PrivacyBundle. With a subset_sum_threshold / radix_threshold set in ScorerConfig, each candidate plan is scored by a linear deficit to the threshold, so agents favor coinjoins with more subset-sum ambiguity. - As measurement: four_counts runs all four paths over the sim's confirmed txs recording CPU, peak RAM (alloc-probe feature), and density regime (kappa via the L bracket). Plus a correctness benchmark against the dep's oracle, a kappa/L sweep, a switch_sweep locating the sparse->Sasamoto handoff, and a CJA comparison (W vs Maurer sub-transaction mappings). Adds denominated funding (standard-denomination UTXOs in a band, via mine_denominated) so the sim can produce dense coinjoins, and fixes a latent over-spend in coin selection that the dense funding exposed (select_all now returns None unless inputs cover the target; session contribution is gated on coverage). --- btsim/Cargo.lock | 234 ++++++++- btsim/Cargo.toml | 7 + btsim/dense_large.toml | 32 ++ btsim/dense_small.toml | 29 ++ btsim/src/actions.rs | 135 ++++- btsim/src/alloc_probe.rs | 64 +++ btsim/src/blocks.rs | 67 +++ btsim/src/coin_selection.rs | 70 ++- btsim/src/config.rs | 142 +++++ btsim/src/correctness.rs | 887 ++++++++++++++++++++++++++++++++ btsim/src/counts.rs | 454 ++++++++++++++++ btsim/src/denominate.rs | 85 +++ btsim/src/lib.rs | 324 +++++++++++- btsim/src/lower_bound_metric.rs | 314 +++++++++++ btsim/src/main.rs | 11 + btsim/src/subset_sum.rs | 14 + btsim/src/tx_contruction.rs | 5 + perf.data | Bin 344920 -> 0 bytes 18 files changed, 2832 insertions(+), 42 deletions(-) create mode 100644 btsim/dense_large.toml create mode 100644 btsim/dense_small.toml create mode 100644 btsim/src/alloc_probe.rs create mode 100644 btsim/src/correctness.rs create mode 100644 btsim/src/counts.rs create mode 100644 btsim/src/denominate.rs create mode 100644 btsim/src/lower_bound_metric.rs create mode 100644 btsim/src/subset_sum.rs delete mode 100644 perf.data diff --git a/btsim/Cargo.lock b/btsim/Cargo.lock index 300951d..b18a283 100644 --- a/btsim/Cargo.lock +++ b/btsim/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.21" @@ -91,6 +100,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -119,6 +139,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "bit-vec" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b4ff8b16e6076c3e14220b39fbc1fabb6737522281a388998046859400895f" + [[package]] name = "bitcoin" version = "0.32.7" @@ -214,6 +240,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bloom" +version = "0.2.0" +source = "git+https://github.com/maufl/bloom-rs#e62b157a08e9f40b2eacc1c484373104c6aa8424" +dependencies = [ + "bit-vec", + "rand 0.3.23", +] + [[package]] name = "btsim" version = "0.1.0" @@ -221,8 +256,9 @@ dependencies = [ "bdk_coin_select", "bitcoin", "bitcoin-units", - "clap", + "clap 4.5.51", "colorous", + "dense-subset-sum", "env_logger", "graphalgs", "graphviz-rust", @@ -271,6 +307,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clap" version = "4.5.51" @@ -290,7 +341,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -311,6 +362,24 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "coinjoin_analyzer" +version = "0.1.1" +source = "git+https://github.com/payjoin/cja?rev=48ad16c13a05d902933638ee77b6b53a4da3de95#48ad16c13a05d902933638ee77b6b53a4da3de95" +dependencies = [ + "bit-vec", + "bloom", + "clap 2.34.0", + "nom", + "num", + "rand 0.3.23", + "rayon", + "rmp-serde", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -385,6 +454,18 @@ dependencies = [ "typenum", ] +[[package]] +name = "dense-subset-sum" +version = "0.1.0" +source = "git+https://github.com/bc1cindy/dense-subset-sum?rev=b1e01d4dcae0bd8c0b6c49ee4b0db38b4dd7d26b#b1e01d4dcae0bd8c0b6c49ee4b0db38b4dd7d26b" +dependencies = [ + "coinjoin_analyzer", + "rand 0.8.5", + "rmp-serde", + "serde", + "serde_json", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -521,6 +602,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "fxhash" version = "0.2.1" @@ -615,6 +702,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex-conservative" version = "0.2.1" @@ -796,15 +892,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - [[package]] name = "nalgebra" version = "0.33.2" @@ -848,6 +935,26 @@ dependencies = [ "rayon", ] +[[package]] +name = "nom" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf51a729ecf40266a2368ad335a5fdde43471f545a967109cd62146ecf8b66ff" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -876,6 +983,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -1061,6 +1179,29 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c" +dependencies = [ + "libc", + "rand 0.4.6", +] + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + [[package]] name = "rand" version = "0.8.5" @@ -1102,6 +1243,21 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + [[package]] name = "rand_core" version = "0.6.4" @@ -1185,6 +1341,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -1223,6 +1388,25 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1436,6 +1620,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -1477,6 +1667,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "toml" version = "0.8.23" @@ -1526,7 +1725,6 @@ dependencies = [ "bitcoin-block-index", "bitcoin_slices", "log", - "memmap2", "sled", ] @@ -1548,12 +1746,24 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.4" diff --git a/btsim/Cargo.toml b/btsim/Cargo.toml index 7f81acc..9227f0c 100644 --- a/btsim/Cargo.toml +++ b/btsim/Cargo.toml @@ -9,6 +9,12 @@ edition = "2021" name = "btsim" path = "src/lib.rs" +[features] +# Install the counting global allocator so the four_counts report can measure +# per-primitive peak RAM. Off by default: otherwise every allocation in the +# simulator pays for atomic accounting it does not need. +alloc-probe = [] + [[bin]] name = "btsim" path = "src/main.rs" @@ -35,3 +41,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" tx-indexer-primitives = { path = "../src/crates/primitives" } +dense-subset-sum = { git = "https://github.com/bc1cindy/dense-subset-sum", rev = "b1e01d4dcae0bd8c0b6c49ee4b0db38b4dd7d26b" } diff --git a/btsim/dense_large.toml b/btsim/dense_large.toml new file mode 100644 index 0000000..07e85b8 --- /dev/null +++ b/btsim/dense_large.toml @@ -0,0 +1,32 @@ +[simulation] +max_timestep = 60 +num_payment_obligations = 40 +denominate_change = true + +[simulation.denominated_funding] +band_min = 131072 +band_max = 2097152 +utxos_per_wallet = 8 + +[[wallet_types]] +name = "participant" +count = 20 +strategies = ["MultipartyStrategy"] +script_type = "P2tr" + +[wallet_types.scorer] +privacy_weight = 1000.0 +payment_obligation_weight = 1.0 +min_fallback_plans = 0 +subset_sum_threshold = 100 +radix_threshold = 50 + +[[wallet_types]] +name = "aggregator" +count = 1 +strategies = ["AggregatorStrategy"] +script_type = "P2tr" + +[wallet_types.scorer] +privacy_weight = 0.0 +payment_obligation_weight = 1.0 diff --git a/btsim/dense_small.toml b/btsim/dense_small.toml new file mode 100644 index 0000000..3480b39 --- /dev/null +++ b/btsim/dense_small.toml @@ -0,0 +1,29 @@ +[simulation] +seed = 42 +max_timestep = 30 +num_payment_obligations = 6 +denominate_change = true +[simulation.denominated_funding] +band_min = 131072 +band_max = 2097152 +utxos_per_wallet = 4 + +[[wallet_types]] +name = "participants" +count = 4 +script_type = "p2tr" +strategies = ["MultipartyStrategy", "UnilateralSpender"] +[wallet_types.scorer] +privacy_weight = 1000.0 +payment_obligation_weight = 1.0 +subset_sum_threshold = 100 +radix_threshold = 50 + +[[wallet_types]] +name = "aggregator" +count = 1 +script_type = "p2tr" +strategies = ["AggregatorStrategy"] +[wallet_types.scorer] +privacy_weight = 0.0 +payment_obligation_weight = 0.0 diff --git a/btsim/src/actions.rs b/btsim/src/actions.rs index 5f37023..6d6e572 100644 --- a/btsim/src/actions.rs +++ b/btsim/src/actions.rs @@ -343,14 +343,19 @@ fn target_for_obligations(pos: &[PaymentObligationData], wallet: &WalletHandle) } } -/// Compute pre-selected change outputs for a `ContributeOutputsToSession` action. -/// If the session has pre-selected inputs (from the aggregator), uses those exactly. -/// Otherwise falls back to full BNB / spend-all selection over all wallet UTXOs. +/// Change outputs for contributing `pos` to a session, or `None` when the wallet's committed +/// session inputs cannot cover those obligations (so it must not contribute them — doing so +/// would make the joint tx spend more than its inputs). +/// +/// WHY the `None` path exists: the session path previously contributed *all* pending obligations +/// regardless of coverage, the joint-transaction analogue of the `select_all` over-spend. Like +/// that bug it only surfaces under the dense-subset-sum funding (small denominated balances), where +/// a wallet's session inputs can be below its obligations. fn change_for_session_contribution( bb_id: &BulletinBoardId, pos: &[PaymentObligationData], wallet: &WalletHandle, -) -> Vec { +) -> Option> { let session = wallet .info() .active_multi_party_payjoins @@ -359,16 +364,25 @@ fn change_for_session_contribution( let session_input_outpoints: Vec = session.inputs.iter().map(|i| i.outpoint).collect(); let target = target_for_obligations(pos, wallet); - if session_input_outpoints.is_empty() { + let change: Vec = if session_input_outpoints.is_empty() { let candidates = wallet.coin_candidates(); - if let Some((_, change)) = select_bnb(&candidates, target) { - return change; + match select_bnb(&candidates, target) { + Some((_, change)) => change, + None => select_all(&candidates, target).map(|(_, c)| c)?, } - select_all(&candidates, target).1 } else { let candidates = wallet.coin_candidates_for(&session_input_outpoints); - select_all(&candidates, target).1 - } + select_all(&candidates, target).map(|(_, c)| c)? + }; + + Some(if wallet.sim.denominate_change_enabled() { + change + .into_iter() + .flat_map(crate::denominate::denominate) + .collect() + } else { + change + }) } /// Enumerate every `Action::UnilateralPayments` that a wallet could perform unilaterally, @@ -400,8 +414,7 @@ fn enumerate_unilateral_actions(wallet: &WalletHandle) -> Vec { if let Some((inputs, change)) = select_bnb(&candidates, target) { actions.push(Action::UnilateralPayments(po_ids.clone(), inputs, change)); } - let (all_inputs, change) = select_all(&candidates, target); - if !all_inputs.is_empty() { + if let Some((all_inputs, change)) = select_all(&candidates, target) { actions.push(Action::UnilateralPayments(po_ids, all_inputs, change)); } } @@ -427,8 +440,7 @@ impl Strategy for UnilateralSpender { if let Some((inputs, change)) = select_bnb(&candidates, target) { actions.push(Action::UnilateralPayments(vec![po.id], inputs, change)); } - let (all_inputs, change) = select_all(&candidates, target); - if !all_inputs.is_empty() { + if let Some((all_inputs, change)) = select_all(&candidates, target) { actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change)); } } @@ -453,8 +465,7 @@ impl Strategy for Consolidator { let mut actions = Vec::new(); for po in wallet.unhandled_payment_obligations().iter() { let target = target_for_obligations(std::slice::from_ref(po), wallet); - let (all_inputs, change) = select_all(&candidates, target); - if !all_inputs.is_empty() { + if let Some((all_inputs, change)) = select_all(&candidates, target) { actions.push(Action::UnilateralPayments(vec![po.id], all_inputs, change)); } } @@ -484,8 +495,7 @@ impl Strategy for BatchSpender { if let Some((inputs, change)) = select_bnb(&candidates, target) { actions.push(Action::UnilateralPayments(po_ids.clone(), inputs, change)); } - let (all_inputs, change) = select_all(&candidates, target); - if !all_inputs.is_empty() { + if let Some((all_inputs, change)) = select_all(&candidates, target) { actions.push(Action::UnilateralPayments(po_ids, all_inputs, change)); } if actions.is_empty() { @@ -541,8 +551,13 @@ impl Strategy for MultipartyStrategy { { let po_ids: Vec = payment_obligations.iter().map(|po| po.id).collect(); - let change = change_for_session_contribution(bb_id, &payment_obligations, wallet); - actions.push(Action::ContributeOutputsToSession(*bb_id, po_ids, change)); + // Only contribute obligations the wallet's committed session inputs can cover; + // otherwise the joint tx would spend more than its inputs. + if let Some(change) = + change_for_session_contribution(bb_id, &payment_obligations, wallet) + { + actions.push(Action::ContributeOutputsToSession(*bb_id, po_ids, change)); + } } } @@ -799,6 +814,11 @@ mod tests { privacy_weight: 0.0, payment_obligation_weight: 0.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2tr, }], @@ -1089,6 +1109,9 @@ mod tests { #[test] fn test_multiparty_contributes_outputs_when_session_awaiting() { let mut sim = test_sim(); + // Fund the wallet so its inputs can cover the obligation — otherwise the wallet + // correctly declines to contribute (contributing uncovered outputs would over-spend). + sim.build_universe(); // Create a bulletin board and accept an invitation, advancing session to AcceptedProposal let bb_id = sim.create_bulletin_board(); @@ -1196,4 +1219,76 @@ mod tests { Action::ContinueParticipateInCospend(id) if id == bb_active )); } + + #[test] + fn denominate_change_makes_coinjoin_outputs_standard() { + use crate::config::{ScorerConfig, WalletTypeConfig}; + use crate::script_type::ScriptType; + use crate::SimulationBuilder; + use dense_subset_sum::is_standard_denom; + + let wallets = vec![ + WalletTypeConfig { + name: "participant".into(), + count: 4, + strategies: vec!["MultipartyStrategy".into()], + scorer: ScorerConfig { + privacy_weight: 1.0, + payment_obligation_weight: 2.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2wpkh, + }, + WalletTypeConfig { + name: "aggregator".into(), + count: 1, + strategies: vec!["AggregatorStrategy".into()], + scorer: ScorerConfig { + privacy_weight: 0.0, + payment_obligation_weight: 0.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2wpkh, + }, + ]; + + let run = |denominate: bool| -> (usize, usize) { + let mut sim = SimulationBuilder::new(42, wallets.clone(), 15, 1, 5) + .denominate_change(denominate) + .build(); + sim.build_universe(); + let result = sim.run(); + let txs = result.dss_transactions(); + // Payment amounts at seed 42 are non-standard, so more standard outputs means + // denominated change is working. + let total: usize = txs.iter().map(|t| t.outputs.len()).sum(); + let standard: usize = txs + .iter() + .flat_map(|t| t.outputs.iter()) + .filter(|&&v| is_standard_denom(v)) + .count(); + (standard, total) + }; + + let (std_off, total_off) = run(false); + let (std_on, total_on) = run(true); + + // Both runs must produce coinjoin outputs, else the comparison is vacuous. + assert!(total_off > 0, "off-run should produce coinjoin outputs"); + assert!(total_on > 0, "on-run should produce coinjoin outputs"); + assert!( + std_on > std_off, + "denomination should increase standard-denom outputs: on={std_on}/{total_on} off={std_off}/{total_off}" + ); + } } diff --git a/btsim/src/alloc_probe.rs b/btsim/src/alloc_probe.rs new file mode 100644 index 0000000..7a8ea9b --- /dev/null +++ b/btsim/src/alloc_probe.rs @@ -0,0 +1,64 @@ +//! Peak-RAM probe for the four_counts harness. The counting global allocator is only +//! installed under the `alloc-probe` feature; without it `System` runs unwrapped and +//! `peak_bytes()` stays at zero. Per-primitive peak is only meaningful single-threaded. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CURRENT: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); +static BASELINE: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "alloc-probe")] +pub(crate) struct CountingAllocator; + +#[cfg(feature = "alloc-probe")] +unsafe impl std::alloc::GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 { + let ptr = std::alloc::System.alloc(layout); + if !ptr.is_null() { + let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size(); + PEAK.fetch_max(now, Ordering::Relaxed); + } + ptr + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) { + CURRENT.fetch_sub(layout.size(), Ordering::Relaxed); + std::alloc::System.dealloc(ptr, layout); + } +} + +/// `peak_bytes()` is relative to a preceding `reset_peak()`: pre-startup frees can wrap +/// `CURRENT`, so the raw totals are not absolute. +pub(crate) fn reset_peak() { + let cur = CURRENT.load(Ordering::Relaxed); + BASELINE.store(cur, Ordering::Relaxed); + PEAK.store(cur, Ordering::Relaxed); +} + +pub(crate) fn peak_bytes() -> usize { + PEAK.load(Ordering::Relaxed) + .saturating_sub(BASELINE.load(Ordering::Relaxed)) +} + +#[cfg(all(test, feature = "alloc-probe"))] +mod tests { + use super::*; + + // The allocator counter is process-global, so parallel test threads can perturb any + // single reading. Retry a few times and require one attempt to observe the live 8 MiB. + #[test] + fn peak_tracks_a_large_live_allocation() { + for _ in 0..8 { + reset_peak(); + let v: Vec = vec![7u8; 8 << 20]; + let peak = peak_bytes(); + assert_eq!(v[0], 7); + drop(v); + if peak >= (1 << 20) { + return; // observed our allocation's growth + } + } + panic!("peak_bytes never reflected an 8 MiB live allocation across 8 attempts"); + } +} diff --git a/btsim/src/blocks.rs b/btsim/src/blocks.rs index dec9430..9b89260 100644 --- a/btsim/src/blocks.rs +++ b/btsim/src/blocks.rs @@ -423,6 +423,73 @@ impl<'a> BlockTemplate { }, ) } + + /// Like `mine`, but the coinbase pays one output per entry in `amounts` (the denominated values) + /// to `rewards_to`, instead of the block subsidy — used by the denominated funding mode to seed + /// dense, denominated wallet UTXOs. There is no coinbase-conservation invariant, and the + /// block-acceptance scan registers every output, so a multi-output coinbase is sound. + pub(crate) fn mine_denominated( + self, + rewards_to: AddressId, + amounts: &[u64], + sim: &'a mut Simulation, + ) -> BlockHandle<'a> { + let parent_block = self.parent.with(sim); + let height = 1 + parent_block.info().height; + + let mut confirmed_txs = OrdSet::from(&self.txs); + + let amounts: Vec = amounts.iter().map(|&a| Amount::from_sat(a)).collect(); + let coinbase_tx = sim.new_tx(|tx, _| { + for &amount in &amounts { + tx.outputs.push(Output { + address_id: rewards_to, + amount, + }); + } + }); + + confirmed_txs.insert(coinbase_tx); + + let parent_block = self.parent.with(sim); + let all_confirmed_txs = parent_block + .info() + .all_confirmed_txs + .clone() + .union(confirmed_txs.clone()); + + let rewards_wallet = rewards_to.with(sim).wallet().id; + sim.wallet_data[rewards_wallet.0] + .own_transactions + .push(coinbase_tx); + + let mut utxos = self.utxos; + let mut created = self.created; + for index in 0..amounts.len() { + let outpoint = Outpoint { + txid: coinbase_tx, + index, + }; + utxos.insert(outpoint); + created.insert(outpoint); + } + + sim.new_block( + BlockData { + parent: Some(self.parent), + coinbase_tx, + confirmed_txs: self.txs, + }, + BlockInfo { + height, + utxos, + created, + spent: self.spent, + confirmed_txs, + all_confirmed_txs, + }, + ) + } } impl BlockData { diff --git a/btsim/src/coin_selection.rs b/btsim/src/coin_selection.rs index 8de8e68..5f1a41f 100644 --- a/btsim/src/coin_selection.rs +++ b/btsim/src/coin_selection.rs @@ -82,17 +82,81 @@ pub(crate) fn select_bnb( } /// Select all candidates (consolidation / spend-all strategy). -/// Returns (selected_inputs, change_outputs). +/// +/// Returns None when even selecting every candidate cannot cover the target (value + fee) — +/// spend-all is the most inputs available, so falling short means no valid tx exists. +/// +/// WHY this guard exists: `select_all` previously returned the inputs unconditionally, so a +/// wallet whose whole balance is below an obligation would still build a tx with outputs > inputs +/// and trip the value-conservation invariant in `TxInfo::new`. This never surfaced with the old +/// arbitrary-amount funding (balances dwarfed obligations), but the dense-subset-sum work funds +/// wallets with small standard-denomination UTXOs, where obligations routinely exceed the balance. +/// Mirrors `select_bnb`, which already returns None on insufficient funds. +/// Returns (selected_inputs, change_outputs) otherwise. pub(crate) fn select_all( candidates: &[CoinCandidate], target: Target, -) -> (Vec, Vec) { +) -> Option<(Vec, Vec)> { let bdk = bdk_candidates(candidates); let mut coin_selector = CoinSelector::new(&bdk); coin_selector.select_all(); + if !coin_selector.is_target_met(target) { + return None; + } let change_policy = change_policy_for(target); let inputs = candidates.iter().map(|c| c.outpoint).collect(); let change = drain_to_change(coin_selector.drain(target, change_policy)); - (inputs, change) + Some((inputs, change)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transaction::{Outpoint, TxId}; + use bdk_coin_select::{Target, TargetFee, TargetOutputs}; + + fn cand(sats: u64, i: usize) -> CoinCandidate { + CoinCandidate { + outpoint: Outpoint { + txid: TxId(i), + index: 0, + }, + amount_sats: sats, + weight_wu: 272, + is_segwit: true, + } + } + + fn target(value: u64) -> Target { + Target { + fee: TargetFee { + rate: bdk_coin_select::FeeRate::from_sat_per_vb(1.0), + replace: None, + }, + outputs: TargetOutputs { + value_sum: value, + weight_sum: 124, + n_outputs: 1, + }, + } + } + + #[test] + fn select_all_none_when_inputs_cannot_cover_target() { + let candidates = vec![cand(1000, 0), cand(1000, 1)]; // total 2000 + assert!( + select_all(&candidates, target(5000)).is_none(), + "must not select when total < target" + ); + } + + #[test] + fn select_all_some_when_inputs_cover_target() { + let candidates = vec![cand(5000, 0), cand(5000, 1)]; + assert!( + select_all(&candidates, target(6000)).is_some(), + "must select when total covers target" + ); + } } diff --git a/btsim/src/config.rs b/btsim/src/config.rs index 68cb9a2..bfaf4f9 100644 --- a/btsim/src/config.rs +++ b/btsim/src/config.rs @@ -1,8 +1,44 @@ +use bitcoin::Amount; use serde::Deserialize; use std::fs; +use crate::metrics::PrivacyBundle; use crate::script_type::ScriptType; +fn default_subset_sum_max_size() -> usize { + 6 +} + +fn default_brute_max_terms() -> usize { + 15 +} + +fn default_radix_density_floor() -> f64 { + 0.5 +} + +pub(crate) fn build_privacy_bundle(scorer: &ScorerConfig) -> PrivacyBundle { + let mut metrics: Vec> = vec![]; + if let Some(threshold) = scorer.subset_sum_threshold { + metrics.push(Box::new(crate::lower_bound_metric::WLowerBoundMetric { + max_size: scorer.subset_sum_max_size, + brute_max_terms: scorer.brute_max_terms, + threshold: u128::from(threshold), + })); + } + if let Some(radix_threshold) = scorer.radix_threshold { + metrics.push(Box::new(crate::lower_bound_metric::RadixMappingMetric { + max_size: scorer.subset_sum_max_size, + threshold: u128::from(radix_threshold), + density_floor: scorer.radix_density_floor, + })); + } + PrivacyBundle { + metrics, + budget: Amount::from_sat(scorer.privacy_weight as u64), + } +} + #[derive(Debug, Clone, Deserialize)] pub struct Config { pub simulation: SimulationConfig, @@ -14,6 +50,20 @@ pub struct SimulationConfig { pub seed: Option, pub max_timestep: u64, pub num_payment_obligations: usize, + #[serde(default)] + pub denominate_change: bool, + #[serde(default)] + pub denominated_funding: Option, +} + +/// Endows each wallet with `utxos_per_wallet` standard-denomination UTXOs drawn from the narrow band +/// `[band_min, band_max]`, instead of the default ~50 BTC coinbase — so a multiparty coinjoin of them +/// is dense (small κ). `None` = default funding. +#[derive(Debug, Clone, Deserialize)] +pub struct DenominatedFunding { + pub band_min: u64, + pub band_max: u64, + pub utxos_per_wallet: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -36,6 +86,19 @@ pub struct ScorerConfig { /// multiparty session. 0 = no restriction (default). #[serde(default)] pub min_fallback_plans: usize, + /// Penalize plans whose best subset-sum lower bound is below this. `None` = metric inactive. + #[serde(default)] + pub subset_sum_threshold: Option, + #[serde(default = "default_subset_sum_max_size")] + pub subset_sum_max_size: usize, + #[serde(default = "default_brute_max_terms")] + pub brute_max_terms: usize, + /// Credit radix k×m! mappings below this. `None` = radix metric inactive. + #[serde(default)] + pub radix_threshold: Option, + /// Minimum `radix_density` of outputs for the radix metric to credit (gate). Default 0.5. + #[serde(default = "default_radix_density_floor")] + pub radix_density_floor: f64, } impl Config { @@ -75,3 +138,82 @@ impl Config { self.wallet_types.iter().map(|wt| wt.count).sum() } } + +#[cfg(test)] +mod bundle_tests { + use super::*; + + fn cfg(threshold: Option) -> ScorerConfig { + ScorerConfig { + privacy_weight: 1000.0, + payment_obligation_weight: 0.0, + min_fallback_plans: 0, + subset_sum_threshold: threshold, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + } + } + + #[test] + fn no_threshold_yields_empty_bundle() { + assert_eq!( + crate::config::build_privacy_bundle(&cfg(None)) + .metrics + .len(), + 0 + ); + } + + #[test] + fn threshold_yields_one_metric() { + assert_eq!( + crate::config::build_privacy_bundle(&cfg(Some(1000))) + .metrics + .len(), + 1 + ); + } + + #[test] + fn threshold_parses_from_toml_and_activates_metric() { + let toml = r#" + privacy_weight = 1000.0 + payment_obligation_weight = 1.0 + subset_sum_threshold = 100 + "#; + let scorer: ScorerConfig = toml::from_str(toml).expect("ScorerConfig must parse from TOML"); + assert_eq!(scorer.subset_sum_threshold, Some(100)); + assert_eq!(build_privacy_bundle(&scorer).metrics.len(), 1); + } + + #[test] + fn radix_threshold_parses_and_adds_a_second_metric() { + let toml = r#" + privacy_weight = 1000.0 + payment_obligation_weight = 1.0 + subset_sum_threshold = 100 + radix_threshold = 50 + "#; + let scorer: ScorerConfig = toml::from_str(toml).expect("parses"); + assert_eq!(scorer.radix_threshold, Some(50)); + assert_eq!(scorer.radix_density_floor, 0.5); // default + assert_eq!(build_privacy_bundle(&scorer).metrics.len(), 2); // W + radix + } + + #[test] + fn radix_threshold_alone_yields_one_metric() { + let scorer = ScorerConfig { + privacy_weight: 1000.0, + payment_obligation_weight: 0.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: Some(10), + radix_density_floor: 0.5, + }; + assert_eq!(build_privacy_bundle(&scorer).metrics.len(), 1); // radix only + } +} diff --git a/btsim/src/correctness.rs b/btsim/src/correctness.rs new file mode 100644 index 0000000..7594915 --- /dev/null +++ b/btsim/src/correctness.rs @@ -0,0 +1,887 @@ +//! Correctness-axis benchmark: synthetic ladders measured for oracle agreement + Sasamoto error +//! (via the dep's vs_oracle harness) and cost (via crate::counts::four_counts). + +use std::num::NonZeroUsize; + +use rand::Rng; +use rand_pcg::rand_core::SeedableRng; +use rand_pcg::Pcg64; + +use crate::counts::{four_counts, CountRow, Limits, Status}; +use dense_subset_sum::harness::vs_cja::{compare_w_vs_mappings, MappingComparison}; +use dense_subset_sum::harness::vs_oracle::{compare, compare_dp_ground_truth, compare_monte_carlo}; +use dense_subset_sum::Transaction; +use dense_subset_sum::{ + is_standard_denom, standard_denoms_in_range, Ambiguity, DEFAULT_MAX_DENOM_SATS, + DEFAULT_MIN_DENOM_SATS, +}; + +const LADDER_MULT: usize = 3; +const RADIX_MAX_SIZE: usize = 6; // radix decomposition depth (kept small => fast) +const SPARSE_MEM_BUDGET: usize = 4096; // entries; from calibration (visible RAM flip) +const DP_MAX: usize = 4_000_000; // DP cells; from calibration (dp survives flip) +const MIN_W: u64 = 2; +const LOOKUP_K: usize = 6; +const MC_SAMPLES: u64 = 200_000; +const MC_SEED: u64 = 42; + +const RANDOM_DENSE_L_MAX: u64 = 16; // from calibration: dense (κ ≤ 0.5) across N=8..24 +const RANDOM_DENSE_SEED: u64 = 100; // base seed (per-rung: + n) +const RANDOM_DENSE_SPARSE_BUDGET: usize = 512; // budget where sparse flips Exact→LowerBound at N=12→14 +#[cfg(test)] +const SASA_ERR_BOUND: f64 = 0.10; // dense-rung (N≥14) Sasamoto error stays under this (test gate) + +const BRUTE: usize = 0; +const RADIX: usize = 1; +const SPARSE: usize = 2; +const SASAMOTO: usize = 3; + +/// brute+sparse get a full counting cap (max_size = n) so sparse is Exact until RAM truncation; +/// radix gets RADIX_MAX_SIZE so its DFS stays fast. +fn ladder_limits(n: usize) -> Limits { + Limits { + brute_max_terms: 15, + max_size: n, + radix_max_size: Some(RADIX_MAX_SIZE), + sparse_mem_budget: NonZeroUsize::new(SPARSE_MEM_BUDGET).expect("budget > 0"), + subsum_max_outputs: 20, + } +} + +/// One power of 2 (>=2^1) repeated `mult` times, for `n_powers` consecutive powers. +/// gcd is 2, so DP ground truth (Σa/gcd) stays reachable as N grows. +fn pow2_set(n_powers: u32, mult: usize) -> Vec { + let mut v = Vec::with_capacity(n_powers as usize * mult); + for k in 1..=n_powers { + for _ in 0..mult { + v.push(1u64 << k); + } + } + v.sort_unstable(); + v +} + +/// N values uniform in [1, l_max], seeded. A narrow l_max keeps κ = log2(l_max)/N small (dense) +/// while the values are randomly sampled — the regime where Sasamoto's asymptotic is stated to hold. +fn random_dense_set(n: usize, l_max: u64, seed: u64) -> Vec { + let mut rng = Pcg64::seed_from_u64(seed); + let mut v: Vec = (0..n).map(|_| rng.random_range(1..=l_max)).collect(); + v.sort_unstable(); + v +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum AmbiguityKind { + Exact, + LowerBound, + LogApprox, + Unknown, +} + +impl AmbiguityKind { + fn of(a: &Ambiguity) -> Self { + match a { + Ambiguity::Exact(_) => Self::Exact, + Ambiguity::LowerBound(_) => Self::LowerBound, + Ambiguity::LogApprox(_) => Self::LogApprox, + Ambiguity::Unknown => Self::Unknown, + _ => Self::Unknown, + } + } +} + +pub(crate) struct Rung { + pub family: &'static str, + pub set: Vec, +} + +/// pow2 rungs: consecutive powers of 2 with multiplicity, growing N. Dense by construction. +fn pow2_ladder() -> Vec { + (3..=14u32) + .map(|n_powers| Rung { + family: "pow2", + set: pow2_set(n_powers, LADDER_MULT), + }) + .collect() +} + +/// radix rungs: the first `d` standard denoms repeated `LADDER_MULT` times, growing d. +fn radix_ladder() -> Vec { + let denoms = standard_denoms_in_range(DEFAULT_MIN_DENOM_SATS, DEFAULT_MAX_DENOM_SATS); + (3..=10usize) + .filter(|&d| d <= denoms.len()) + .map(|d| { + let mut set: Vec = denoms[..d] + .iter() + .flat_map(|&v| std::iter::repeat_n(v, LADDER_MULT)) + .collect(); + set.sort_unstable(); + Rung { + family: "radix", + set, + } + }) + .collect() +} + +/// arbitrary rungs: deterministic non-denominated values (skip any that land on a standard denom). +fn arbitrary_ladder() -> Vec { + (3..=12usize) + .map(|n| { + let mut set: Vec = (0..n as u64) + .map(|i| 1_000_003 + i * 999_983) + .filter(|&v| !is_standard_denom(v)) + .collect(); + set.sort_unstable(); + Rung { + family: "arbitrary", + set, + } + }) + .collect() +} + +/// random_dense rungs: seeded uniform-in-[1,L_MAX] sets of growing N. κ = log2(L_MAX)/N shrinks as +/// N grows (denser); the L225-faithful Sasamoto test bed (randomly sampled, dense). +fn random_dense_ladder() -> Vec { + (8..=24usize) + .step_by(2) + .map(|n| Rung { + family: "random_dense", + set: random_dense_set(n, RANDOM_DENSE_L_MAX, RANDOM_DENSE_SEED + n as u64), + }) + .collect() +} + +/// random_dense needs a smaller sparse budget than the default to exhibit the RAM-driven flip +/// (calibration). Other families keep the default `ladder_limits` budget. +fn ladder_limits_for(family: &str, n: usize) -> Limits { + let mut limits = ladder_limits(n); + if family == "random_dense" { + limits.sparse_mem_budget = + NonZeroUsize::new(RANDOM_DENSE_SPARSE_BUDGET).expect("budget > 0"); + } + limits +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum GroundTruth { + Exhaustive, + Dp, + MonteCarlo, + None, +} + +/// Picks the ground-truth path by N (the crossover engine). Returns the ground-truth kind and the +/// Sasamoto error/spearman vs that truth (None when there are no comparable points). +fn run_oracle(set: &[u64]) -> (GroundTruth, Option, Option) { + if set.is_empty() { + return (GroundTruth::None, None, None); + } + let (gt, report) = if set.len() <= 22 { + ( + GroundTruth::Exhaustive, + compare(set, MIN_W, LOOKUP_K, DP_MAX, "btsim"), + ) + } else { + match compare_dp_ground_truth(set, MIN_W, LOOKUP_K, DP_MAX, "btsim") { + Ok(r) => (GroundTruth::Dp, r), + Err(_) => ( + GroundTruth::MonteCarlo, + compare_monte_carlo( + set, MIN_W, LOOKUP_K, DP_MAX, "btsim", MC_SAMPLES, 0, MC_SEED, + ), + ), + } + }; + let s = &report.sasamoto; + if s.n_points > 0 { + (gt, Some(s.median_error), Some(s.spearman)) + } else { + (gt, None, None) + } +} + +/// Whether the internal lower-bound invariant (sparse ≤ exact brute) held. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Consistency { + Ok, + Violated, +} + +pub(crate) struct CostRow { + pub kind: [AmbiguityKind; 4], + pub cpu_us: [u128; 4], + pub peak_bytes: [usize; 4], + pub status: [Status; 4], + pub consistency: Consistency, +} + +/// Runs four_counts on a few-outputs instance (inputs = set, outputs = [Σset/2]) so the input +/// sumset RAM — not the output-count guard — is the binding cost. Extracts per-method kind/cost +/// and the internal lower-bound consistency. +fn run_cost(set: &[u64], limits: &Limits) -> CostRow { + let target = set.iter().copied().fold(0u64, u64::saturating_add) / 2; + let tx = Transaction::new(set.to_vec(), vec![target.max(1)]); + let rows = four_counts(&tx, limits); + CostRow { + kind: std::array::from_fn(|i| AmbiguityKind::of(&rows[i].ambiguity)), + cpu_us: std::array::from_fn(|i| rows[i].cpu.as_micros()), + peak_bytes: std::array::from_fn(|i| rows[i].peak_bytes), + status: std::array::from_fn(|i| rows[i].status), + consistency: consistency(&rows), + } +} + +/// When brute is Exact it equals the true aggregate W = Σ_E Σ_m W(m,E). Sparse computes the SAME +/// aggregate (possibly truncated), so `sparse <= brute` must hold. Radix is deliberately NOT checked: +/// `radix_mappings` counts a different object (Σ k×m! output-denomination mappings), not a bound on +/// this W, and can legitimately exceed it. When brute aborted/non-exact, there is no exact reference. +fn consistency(rows: &[CountRow; 4]) -> Consistency { + if !rows[BRUTE].ambiguity.is_exact() { + return Consistency::Ok; + } + let Some(exact) = rows[BRUTE].ambiguity.lower_bound_count() else { + return Consistency::Ok; + }; + match rows[SPARSE].ambiguity.lower_bound_count() { + Some(v) if v > exact => Consistency::Violated, + _ => Consistency::Ok, + } +} + +pub(crate) struct CorrectnessRow { + pub family: &'static str, + pub n: usize, + pub regime: String, + pub radix_dense: bool, + pub ground_truth: GroundTruth, + pub sasamoto_err: Option, + pub spearman: Option, + pub kind: [AmbiguityKind; 4], + pub cpu_us: [u128; 4], + pub peak_bytes: [usize; 4], + pub status: [Status; 4], + pub consistency: Consistency, +} + +fn row_for(rung: &Rung) -> CorrectnessRow { + let target = rung.set.iter().copied().fold(0u64, u64::saturating_add) / 2; + let tx = Transaction::new(rung.set.clone(), vec![target.max(1)]); + let bracket = crate::counts::regime_for_tx(&tx); + let regime = bracket.map_or_else(|| "(infeasible)".to_string(), |b| b.to_string()); + let radix_dense = dense_subset_sum::radix_density(&rung.set) >= 0.5; + let (ground_truth, sasamoto_err, spearman) = run_oracle(&rung.set); + let cost = run_cost(&rung.set, &ladder_limits_for(rung.family, rung.set.len())); + CorrectnessRow { + family: rung.family, + n: rung.set.len(), + regime, + radix_dense, + ground_truth, + sasamoto_err, + spearman, + kind: cost.kind, + cpu_us: cost.cpu_us, + peak_bytes: cost.peak_bytes, + status: cost.status, + consistency: cost.consistency, + } +} + +pub(crate) fn evaluate_ladders() -> Vec { + pow2_ladder() + .iter() + .chain(radix_ladder().iter()) + .chain(arbitrary_ladder().iter()) + .chain(random_dense_ladder().iter()) + .map(row_for) + .collect() +} + +fn gt_label(gt: GroundTruth) -> &'static str { + match gt { + GroundTruth::Exhaustive => "exhaustive", + GroundTruth::Dp => "dp", + GroundTruth::MonteCarlo => "monte_carlo", + GroundTruth::None => "none", + } +} + +fn kind_label(k: AmbiguityKind) -> &'static str { + match k { + AmbiguityKind::Exact => "Exact", + AmbiguityKind::LowerBound => "LowerBnd", + AmbiguityKind::LogApprox => "LogApprox", + AmbiguityKind::Unknown => "Unknown", + } +} + +pub(crate) struct KappaRow { + pub kappa: f64, + pub regime: String, + pub sasamoto_err: Option, + pub spearman: Option, +} + +/// At fixed N, sweep the value spread (l_max) from narrow (dense, small κ) to wide (sparse, large κ), +/// measuring Sasamoto error vs the oracle at each κ. Locates the validity boundary empirically +/// (transcript L204). N must be ≤ 22 so the exhaustive `compare` oracle is usable. +pub(crate) fn kappa_sweep(n: usize, seed: u64) -> Vec { + use dense_subset_sum::kappa; + // l_max capped at 256: `compare`'s exact oracle runs a per-target DP whose cost grows with Σa, + // so wide l_max is intractable. This range spans dense (κ≈0.15) to the boundary onset (κ≈0.40, + // where Sasamoto error approaches the bound) with exact truth — enough to locate where it degrades. + [8u64, 16, 32, 64, 128, 256] + .iter() + .map(|&l_max| { + let set = random_dense_set(n, l_max, seed); + let report = compare(&set, MIN_W, LOOKUP_K, DP_MAX, "kappa-sweep"); + let target = set.iter().copied().fold(0u64, u64::saturating_add) / 2; + let regime = + crate::counts::regime_for_tx(&Transaction::new(set.clone(), vec![target.max(1)])) + .map_or_else(|| "(infeasible)".to_string(), |b| b.to_string()); + let s = &report.sasamoto; + KappaRow { + kappa: kappa(l_max, n).unwrap_or(f64::NAN), + regime, + sasamoto_err: (s.n_points > 0).then_some(s.median_error), + spearman: (s.n_points > 0).then_some(s.spearman), + } + }) + .collect() +} + +pub(crate) struct SwitchRow { + pub n: usize, + pub kappa: f64, + pub sparse_kind: AmbiguityKind, + pub sparse_cpu_us: u128, + pub sparse_peak_bytes: usize, + pub sasamoto_err: Option, + pub spearman: Option, +} + +/// Empirically locate the sparse→Sasamoto switch instead of hard-coding a cap. At a fixed dense +/// value spread, sweep the problem size N and measure, per N, whether the exact sparse convolution +/// still fits the memory budget (Exact) or has outgrown it (LowerBound) plus its CPU, alongside the +/// Sasamoto approximation's error vs the oracle. The switch is the N where sparse stops being Exact; +/// the sweep confirms Sasamoto remains a good estimate (low error) there — the transcript's "find +/// where [sparse] becomes too computationally costly and make sure that at that point the Sasamoto +/// estimate is a good estimate" (L1163), measured rather than assumed. `budget` is the sparse memory +/// budget the switch is measured against — the real knob a fixed output cap only stands in for. +/// (Peak RAM is recorded only under the `alloc-probe` feature; CPU and kind are always measured.) +pub(crate) fn switch_sweep(l_max: u64, seed: u64, budget: usize) -> Vec { + use dense_subset_sum::kappa; + (8..=22usize) + .step_by(2) + .map(|n| { + let limits = Limits { + sparse_mem_budget: NonZeroUsize::new(budget).expect("budget > 0"), + ..ladder_limits(n) + }; + let set = random_dense_set(n, l_max, seed + n as u64); + let cost = run_cost(&set, &limits); + let report = compare(&set, MIN_W, LOOKUP_K, DP_MAX, "switch-sweep"); + let s = &report.sasamoto; + SwitchRow { + n, + kappa: kappa(l_max, n).unwrap_or(f64::NAN), + sparse_kind: cost.kind[SPARSE], + sparse_cpu_us: cost.cpu_us[SPARSE], + sparse_peak_bytes: cost.peak_bytes[SPARSE], + sasamoto_err: (s.n_points > 0).then_some(s.median_error), + spearman: (s.n_points > 0).then_some(s.spearman), + } + }) + .collect() +} + +/// CJA comparison (transcript L1405: "testing ... with CJA"): for small coinjoins, compares the W / +/// Sasamoto lower-bound side against the enumerated sub-transaction MAPPINGS side (Maurer/Boltzmann +/// entropy). Both are anonymity facets (§2.7: "everything is anonymity") — this surfaces them side by +/// side. Mapping enumeration is exponential, so only small instances (≤ max_coins) yield a comparison. +pub(crate) fn cja_comparison() -> Vec { + let knee = dense_subset_sum::KNEE; + let max_coins = 20; + let txs = [ + dense_subset_sum::fixtures::maurer_fig2(), + Transaction::new(vec![512, 512, 512, 1024], vec![512, 512, 1024, 512]), + Transaction::new(vec![1000, 1000, 2000], vec![1000, 1000, 2000]), + ]; + txs.iter() + .enumerate() + .filter_map(|(i, tx)| compare_w_vs_mappings(tx, &format!("cja_{i}"), knee, max_coins)) + .collect() +} + +pub fn print_correctness_report() { + let rows = evaluate_ladders(); + println!( + "(sparse_mem_budget={} entries, dp_max={} cells, radix_max_size={})", + SPARSE_MEM_BUDGET, DP_MAX, RADIX_MAX_SIZE + ); + println!( + "family | N | ground_truth | sparse_kind | sasamoto_err% | spearman | brute_us | radix_us | sparse_us | sasa_us | peakB | lb_ok | aborts | radix_dense | regime" + ); + for r in &rows { + let err = r + .sasamoto_err + .map_or_else(|| "-".to_string(), |e| format!("{:.1}", e * 100.0)); + let sp = r + .spearman + .map_or_else(|| "-".to_string(), |s| format!("{:.3}", s)); + let aborts: String = [BRUTE, RADIX, SPARSE, SASAMOTO] + .iter() + .filter(|&&i| matches!(r.status[i], Status::Aborted(_))) + .map(|&i| ["b", "r", "s", "z"][i]) + .collect::>() + .join(","); + let aborts = if aborts.is_empty() { + "-".to_string() + } else { + aborts + }; + let lb_ok = match r.consistency { + Consistency::Ok => "ok", + Consistency::Violated => "BAD", + }; + println!( + "{:>9} | {:>2} | {:>11} | {:>8} | {:>12} | {:>8} | {:>8} | {:>8} | {:>9} | {:>7} | {:>9} | {:>5} | {:>8} | {:>11} | {}", + r.family, r.n, gt_label(r.ground_truth), kind_label(r.kind[SPARSE]), err, sp, + r.cpu_us[BRUTE], r.cpu_us[RADIX], r.cpu_us[SPARSE], r.cpu_us[SASAMOTO], + r.peak_bytes[SPARSE], lb_ok, aborts, if r.radix_dense { "yes" } else { "no" }, r.regime, + ); + } + + println!("\nkappa sweep @ N=20 (Sasamoto error vs oracle as density falls):"); + println!("kappa | regime | sasamoto_err% | spearman"); + for r in kappa_sweep(20, RANDOM_DENSE_SEED) { + let err = r + .sasamoto_err + .map_or_else(|| "-".to_string(), |e| format!("{:.1}", e * 100.0)); + let sp = r + .spearman + .map_or_else(|| "-".to_string(), |s| format!("{:.3}", s)); + println!( + "{:>5.2} | {:>11} | {:>12} | {:>8}", + r.kappa, r.regime, err, sp + ); + } + + println!("\nswitch sweep @ l_max=16, sparse budget=512 (where sparse stops being exact, Sasamoto takes over):"); + println!(" N | kappa | sparse_kind | sparse_us | sparse_peakB | sasamoto_err% | spearman"); + for r in switch_sweep(16, RANDOM_DENSE_SEED, 512) { + let err = r + .sasamoto_err + .map_or_else(|| "-".to_string(), |e| format!("{:.1}", e * 100.0)); + let sp = r + .spearman + .map_or_else(|| "-".to_string(), |s| format!("{:.3}", s)); + println!( + "{:>3} | {:>5.2} | {:>11} | {:>9} | {:>12} | {:>13} | {:>8}", + r.n, + r.kappa, + kind_label(r.sparse_kind), + r.sparse_cpu_us, + r.sparse_peak_bytes, + err, + sp, + ); + } + + println!("\nCJA: W vs sub-transaction mappings (both anonymity facets) on small coinjoins:"); + println!("label | n_in | n_out | non_derived | entropy_bits | sasamoto_log | w_lookup_log"); + for c in cja_comparison() { + println!( + "{:>6} | {:>4} | {:>5} | {:>11} | {:>12.2} | {:>12.2} | {:>12.2}", + c.label, + c.n_inputs, + c.n_outputs, + c.n_non_derived, + c.entropy, + c.max_log_sasamoto_approx, + c.max_log_w_lookup, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::OnceLock; + + fn ladder_rows() -> &'static Vec { + static ROWS: OnceLock> = OnceLock::new(); + ROWS.get_or_init(evaluate_ladders) + } + + #[test] + fn pow2_set_is_ascending_with_right_multiplicity() { + let s = pow2_set(3, 3); + assert_eq!(s, vec![2, 2, 2, 4, 4, 4, 8, 8, 8]); + assert!(s.windows(2).all(|w| w[0] <= w[1]), "must be ascending"); + } + + use crate::counts::{four_counts, Limits, Status}; + use dense_subset_sum::harness::vs_oracle::compare_dp_ground_truth; + use dense_subset_sum::is_standard_denom; + use dense_subset_sum::{Ambiguity, Transaction}; + + #[test] + fn sasamoto_sharpens_toward_dense() { + let rows = kappa_sweep(20, 7); + assert!(rows.len() >= 4, "need several kappa points"); + assert!( + rows.windows(2).all(|w| w[0].kappa <= w[1].kappa), + "kappa ascends (dense -> sparse)" + ); + let dense_err = rows.first().unwrap().sasamoto_err; + assert!( + dense_err.is_some_and(|e| e < SASA_ERR_BOUND), + "densest point: Sasamoto good (< bound)" + ); + // the densest point must not be worse than the sparsest measured point + let sparse_err = rows.iter().rev().find_map(|r| r.sasamoto_err); + if let (Some(d), Some(s)) = (dense_err, sparse_err) { + assert!(d <= s, "Sasamoto error should not be worse at the dense end ({d:.3}) than the sparse end ({s:.3})"); + } + } + + #[test] + fn random_dense_set_is_deterministic_and_in_range() { + let a = random_dense_set(20, 64, 7); + let b = random_dense_set(20, 64, 7); + assert_eq!(a, b, "same seed => same set"); + assert_eq!(a.len(), 20); + assert!(a.iter().all(|&v| (1..=64).contains(&v))); + assert!(a.windows(2).all(|w| w[0] <= w[1])); + } + + #[test] + fn cja_comparison_relates_w_and_mappings() { + let comps = cja_comparison(); + assert!( + !comps.is_empty(), + "CJA comparison must produce at least one small-tx result" + ); + // mappings side: at least one tx has sub-transaction mappings (anonymity via Maurer/Boltzmann) + assert!( + comps.iter().any(|c| c.n_non_derived > 0), + "a tx must have sub-transaction mappings" + ); + // both facets present: mapping entropy (bits) is finite where mappings exist + assert!( + comps + .iter() + .filter(|c| c.n_non_derived > 0) + .all(|c| c.entropy.is_finite()), + "mapping entropy must be finite where mappings exist" + ); + } + + // cargo test --lib correctness::tests::calibrate_random_dense -- --ignored --nocapture + #[test] + #[ignore] + fn calibrate_random_dense() { + use crate::counts::{four_counts, Limits, Status}; + use dense_subset_sum::harness::vs_oracle::{compare, compare_dp_ground_truth}; + use dense_subset_sum::{kappa, Ambiguity, Transaction}; + let l_max = 16u64; + let sparse_budget = 512usize; + let dp_max = 4_000_000usize; + println!("\nN | kappa | sparse_kind | dp | sasa_err% | brute"); + for n in (8..=24usize).step_by(2) { + let set = random_dense_set(n, l_max, 100 + n as u64); + let target = set.iter().copied().fold(0u64, u64::saturating_add) / 2; + let tx = Transaction::new(set.clone(), vec![target.max(1)]); + let limits = Limits { + brute_max_terms: 15, + max_size: n, + radix_max_size: Some(6), + sparse_mem_budget: NonZeroUsize::new(sparse_budget).unwrap(), + subsum_max_outputs: 20, + }; + let rows = four_counts(&tx, &limits); + let sk = match rows[2].ambiguity { + Ambiguity::Exact(_) => "Exact", + Ambiguity::LowerBound(_) => "LowerBound", + _ => "other", + }; + let (dp, err) = if n <= 22 { + let r = compare(&set, 2, 6, dp_max, "cal"); + ( + "exh", + if r.sasamoto.n_points > 0 { + r.sasamoto.median_error * 100.0 + } else { + -1.0 + }, + ) + } else { + match compare_dp_ground_truth(&set, 2, 6, dp_max, "cal") { + Ok(r) => ( + "dp", + if r.sasamoto.n_points > 0 { + r.sasamoto.median_error * 100.0 + } else { + -1.0 + }, + ), + Err(_) => ("ERR", -1.0), + } + }; + let brute = if matches!(rows[0].status, Status::Aborted(_)) { + "abort" + } else { + "ok" + }; + let k = kappa(l_max, n).unwrap_or(f64::NAN); + println!("{n:>2} | {k:>5.2} | {sk:>10} | {dp:>3} | {err:>6.1} | {brute}"); + } + } + + // cargo test --lib correctness::tests::switch_sweep_locates_handoff -- --nocapture + #[test] + fn switch_sweep_locates_handoff() { + // Dense spread (l_max=16) so Sasamoto is in its valid regime; small budget so the sparse + // exact→degraded switch falls within the N range. + let rows = switch_sweep(16, RANDOM_DENSE_SEED, 512); + println!("\n N | kappa | sparse_kind | sparse_us | sparse_peakB | sasa_err% | spearman"); + for r in &rows { + println!( + "{:>3} | {:>5.2} | {:>11} | {:>9} | {:>12} | {:>9} | {:>8}", + r.n, + r.kappa, + kind_label(r.sparse_kind), + r.sparse_cpu_us, + r.sparse_peak_bytes, + r.sasamoto_err + .map(|e| format!("{:.1}", e * 100.0)) + .unwrap_or_else(|| "-".into()), + r.spearman + .map(|s| format!("{s:.3}")) + .unwrap_or_else(|| "-".into()), + ); + } + // The sweep must straddle the switch: sparse exact at small N, degraded (budget exceeded) + // at large N — i.e. the handoff point is located by measurement, not assumed. + assert!( + rows.iter().any(|r| r.sparse_kind == AmbiguityKind::Exact), + "expected small-N rungs where sparse is still Exact" + ); + assert!( + rows.iter() + .any(|r| r.sparse_kind == AmbiguityKind::LowerBound), + "expected large-N rungs where sparse degrades (the switch to relying on Sasamoto)" + ); + // At the switch, Sasamoto must still be a good estimate (else the handoff is unsafe). + let degraded_err = rows + .iter() + .filter(|r| r.sparse_kind == AmbiguityKind::LowerBound) + .filter_map(|r| r.sasamoto_err) + .fold(0.0_f64, f64::max); + assert!( + degraded_err < SASA_ERR_BOUND, + "Sasamoto error {:.1}% at/after the switch exceeds the {:.0}% bound", + degraded_err * 100.0, + SASA_ERR_BOUND * 100.0 + ); + } + + #[test] + fn ambiguity_kind_projects_all_variants() { + assert_eq!( + AmbiguityKind::of(&Ambiguity::Exact(3)), + AmbiguityKind::Exact + ); + assert_eq!( + AmbiguityKind::of(&Ambiguity::LowerBound(3)), + AmbiguityKind::LowerBound + ); + assert_eq!( + AmbiguityKind::of(&Ambiguity::LogApprox(1.0)), + AmbiguityKind::LogApprox + ); + assert_eq!( + AmbiguityKind::of(&Ambiguity::Unknown), + AmbiguityKind::Unknown + ); + } + + #[test] + fn run_cost_extracts_kinds_and_consistency_for_small_set() { + // N=9: brute is exact => no lower bound may exceed it. + let cost = run_cost(&pow2_set(3, 3), &ladder_limits(9)); + assert_eq!(cost.kind.len(), 4); + assert_eq!( + cost.consistency, + Consistency::Ok, + "no lower bound may exceed the exact brute aggregate" + ); + } + + #[test] + fn random_dense_ladder_is_dense_and_grows() { + let rungs = random_dense_ladder(); + assert!(rungs.len() >= 4); + assert!(rungs.iter().all(|r| r.family == "random_dense")); + let ns: Vec = rungs.iter().map(|r| r.set.len()).collect(); + assert!( + ns.windows(2).all(|w| w[0] <= w[1]), + "N grows along the ladder" + ); + } + + #[test] + fn radix_ladder_values_are_standard_denoms() { + let rungs = radix_ladder(); + assert!(!rungs.is_empty()); + for r in &rungs { + assert_eq!(r.family, "radix"); + assert!( + r.set.iter().all(|&v| is_standard_denom(v)), + "radix ladder must use standard denoms" + ); + } + } + + #[test] + fn arbitrary_ladder_values_are_not_standard_denoms() { + let rungs = arbitrary_ladder(); + assert!(!rungs.is_empty()); + for r in &rungs { + assert_eq!(r.family, "arbitrary"); + assert!( + r.set.iter().all(|&v| !is_standard_denom(v)), + "arbitrary ladder must avoid denoms" + ); + } + } + + #[test] + fn run_oracle_exhaustive_for_small_dense_set() { + // small pow2 set: N<=22 => Exhaustive ground truth, Sasamoto measured in the dense regime + let (gt, err, spearman) = run_oracle(&pow2_set(4, 3)); // N=12 + assert!(matches!(gt, GroundTruth::Exhaustive)); + assert!( + err.is_some(), + "exact ground truth must yield a Sasamoto error" + ); + assert!(spearman.is_some()); + assert!(err.unwrap() >= 0.0); + } + + #[test] + fn pow2_ladder_is_dense_by_construction() { + let rungs = pow2_ladder(); + assert!(rungs.len() >= 4, "need several rungs to span the crossover"); + assert!(rungs.iter().all(|r| r.family == "pow2")); + } + + #[test] + fn evaluate_ladders_covers_all_three_families() { + let rows = ladder_rows(); + for fam in ["pow2", "radix", "arbitrary"] { + assert!(rows.iter().any(|r| r.family == fam), "missing family {fam}"); + } + assert!(rows.iter().all(|r| r.n > 0 && !r.regime.is_empty())); + } + + #[test] + fn correctness_invariants_hold() { + let rows = ladder_rows(); + + for r in rows.iter() { + assert_eq!( + r.consistency, + Consistency::Ok, + "lower-bound monotonicity must hold for {} N={}", + r.family, + r.n + ); + // Hard Sasamoto bounds only on random_dense (L225-faithful: randomly sampled, dense) AND + // only on the rungs where Sasamoto is actually USED — where sparse has truncated + // (kind[SPARSE] == LowerBound) and exact truth still exists. Calibration showed Sasamoto + // is only accurate once N is large/dense enough; the small-N rungs (sparse still Exact, + // so Sasamoto is not needed) are noisy and not asserted. pow2 is a radix-density + // illustration only — "powers of 2 dense by construction" (L1158) is the RADIX rationale, + // not Sasamoto's. + let used_here = r.family == "random_dense" + && r.kind[SPARSE] == AmbiguityKind::LowerBound + && matches!(r.ground_truth, GroundTruth::Exhaustive | GroundTruth::Dp); + if used_here { + let err = r + .sasamoto_err + .expect("overlap rung must have a Sasamoto error"); + assert!( + err < SASA_ERR_BOUND, + "Sasamoto error {:.3} must stay < {:.2} at the crossover ({} N={})", + err, + SASA_ERR_BOUND, + r.family, + r.n + ); + let sp = r.spearman.expect("overlap rung must have spearman"); + assert!( + sp > 0.90, + "Sasamoto spearman {:.3} must stay > 0.9 at the crossover ({} N={})", + sp, + r.family, + r.n + ); + } + } + + // overlap band answering L1163, now on the faithful family: + assert!( + rows.iter().any(|r| r.family == "random_dense" + && r.kind[SPARSE] == AmbiguityKind::LowerBound + && matches!(r.ground_truth, GroundTruth::Exhaustive | GroundTruth::Dp) + && r.sasamoto_err.is_some()), + "random_dense ladder must have an overlap rung (sparse=LowerBound AND exact truth AND Sasamoto measured); \ + re-calibrate RANDOM_DENSE params (Task 4) if this fails" + ); + } + + // Run with: cargo test --lib correctness::tests::calibrate_overlap -- --ignored --nocapture + #[test] + #[ignore] + fn calibrate_overlap() { + const MULT: usize = 3; + const RADIX_MAX_SIZE: usize = 6; + let candidate_sparse_budget = 4096usize; + let candidate_dp_max = 4_000_000usize; + println!("\nN | sparse_kind | dp_ground_truth | brute_status"); + for n_powers in 3..=14u32 { + let set = pow2_set(n_powers, MULT); + let n = set.len(); + let target = set.iter().copied().fold(0u64, u64::saturating_add) / 2; + let tx = Transaction::new(set.clone(), vec![target.max(1)]); + let limits = Limits { + brute_max_terms: 15, + max_size: n, + radix_max_size: Some(RADIX_MAX_SIZE), + sparse_mem_budget: NonZeroUsize::new(candidate_sparse_budget).unwrap(), + subsum_max_outputs: 20, + }; + let rows = four_counts(&tx, &limits); + let sparse_kind = match rows[2].ambiguity { + Ambiguity::Exact(_) => "Exact", + Ambiguity::LowerBound(_) => "LowerBound", + _ => "other", + }; + let dp = match compare_dp_ground_truth(&set, 2, RADIX_MAX_SIZE, candidate_dp_max, "cal") + { + Ok(_) => "Ok", + Err(_) => "Err", + }; + let brute = if matches!(rows[0].status, Status::Aborted(_)) { + "aborted" + } else { + "computed" + }; + println!("{:>2} | {:>10} | {:>3} | {}", n, sparse_kind, dp, brute); + } + } +} diff --git a/btsim/src/counts.rs b/btsim/src/counts.rs new file mode 100644 index 0000000..5a6e219 --- /dev/null +++ b/btsim/src/counts.rs @@ -0,0 +1,454 @@ +use std::num::NonZeroUsize; +use std::time::{Duration, Instant}; + +use dense_subset_sum::{ + radix_mappings, w_brute, w_sasamoto, w_sparse, Ambiguity, Bracket, Transaction, + DEFAULT_MEMORY_BUDGET, +}; + +use crate::subset_sum::{brute_feasible, subsum_feasible, SUBSUM_MAX_OUTPUTS}; +use crate::transaction::TxId; +use crate::Simulation; + +pub(crate) fn sim_tx_to_dss(sim: &Simulation, txid: TxId) -> Option { + let handle = txid.with(sim); + if handle.is_coinbase() { + return None; + } + let outputs: Vec = handle.outputs().map(|o| o.data().amount.to_sat()).collect(); + let inputs: Vec = handle + .inputs() + .map(|i| i.prevout().data().amount.to_sat()) + .collect(); + Some(Transaction::new(inputs, outputs)) +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Limits { + pub(crate) brute_max_terms: usize, + pub(crate) max_size: usize, + pub(crate) radix_max_size: Option, + pub(crate) sparse_mem_budget: NonZeroUsize, + pub(crate) subsum_max_outputs: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + brute_max_terms: 15, + max_size: 6, + radix_max_size: None, + sparse_mem_budget: DEFAULT_MEMORY_BUDGET, + subsum_max_outputs: SUBSUM_MAX_OUTPUTS, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Status { + Computed, + Aborted(&'static str), +} + +#[derive(Debug, Clone)] +pub(crate) struct CountRow { + pub(crate) name: &'static str, + pub(crate) ambiguity: Ambiguity, + pub(crate) is_lower_bound: bool, + pub(crate) status: Status, + pub(crate) cpu: Duration, + pub(crate) peak_bytes: usize, +} + +fn measure Ambiguity>(f: F) -> (Ambiguity, Duration, usize) { + crate::alloc_probe::reset_peak(); + let t = Instant::now(); + let a = f(); + let cpu = t.elapsed(); + let peak = crate::alloc_probe::peak_bytes(); + (a, cpu, peak) +} + +pub(crate) fn four_counts(tx: &Transaction, limits: &Limits) -> [CountRow; 4] { + let (inputs, outputs) = (&tx.inputs, &tx.outputs); + + let brute = if brute_feasible(inputs.len(), outputs.len(), limits.brute_max_terms) { + let (a, cpu, peak) = measure(|| w_brute(inputs, outputs, limits.max_size)); + CountRow { + name: "brute", + ambiguity: a, + is_lower_bound: true, + status: Status::Computed, + cpu, + peak_bytes: peak, + } + } else { + CountRow { + name: "brute", + ambiguity: Ambiguity::Unknown, + is_lower_bound: true, + status: Status::Aborted("N+M over brute cap"), + cpu: Duration::ZERO, + peak_bytes: 0, + } + }; + + let (a, cpu, peak) = + measure(|| radix_mappings(outputs, limits.radix_max_size.unwrap_or(limits.max_size))); + let radix = CountRow { + name: "radix", + ambiguity: a, + is_lower_bound: true, + status: Status::Computed, + cpu, + peak_bytes: peak, + }; + + let sparse = if subsum_feasible(outputs.len(), limits.subsum_max_outputs) { + let (a, cpu, peak) = + measure(|| w_sparse(inputs, outputs, limits.max_size, limits.sparse_mem_budget)); + CountRow { + name: "sparse", + ambiguity: a, + is_lower_bound: true, + status: Status::Computed, + cpu, + peak_bytes: peak, + } + } else { + CountRow { + name: "sparse", + ambiguity: Ambiguity::Unknown, + is_lower_bound: true, + status: Status::Aborted("too many outputs for subsum enumeration"), + cpu: Duration::ZERO, + peak_bytes: 0, + } + }; + + let sasamoto = if subsum_feasible(outputs.len(), limits.subsum_max_outputs) { + let (a, cpu, peak) = measure(|| w_sasamoto(inputs, outputs)); + CountRow { + name: "sasamoto", + ambiguity: a, + is_lower_bound: false, + status: Status::Computed, + cpu, + peak_bytes: peak, + } + } else { + CountRow { + name: "sasamoto", + ambiguity: Ambiguity::Unknown, + is_lower_bound: false, + status: Status::Aborted("too many outputs for subsum enumeration"), + cpu: Duration::ZERO, + peak_bytes: 0, + } + }; + + [brute, radix, sparse, sasamoto] +} + +#[derive(Debug, Clone)] +pub(crate) struct PrimitiveBench { + pub(crate) name: &'static str, + pub(crate) n_txs: usize, + pub(crate) n_aborted: usize, + pub(crate) n_with_count: usize, + pub(crate) median_cpu_us: u128, + pub(crate) max_cpu_us: u128, + pub(crate) median_peak_bytes: usize, + pub(crate) max_peak_bytes: usize, +} + +impl PrimitiveBench { + pub(crate) fn header() -> &'static str { + "primitive | runs | aborted | with_count | med_cpu_us | max_cpu_us | med_peak_B | max_peak_B" + } + + pub(crate) fn row(&self) -> String { + format!( + "{:>8} | {:>4} | {:>7} | {:>10} | {:>10} | {:>10} | {:>10} | {:>9}", + self.name, + self.n_txs, + self.n_aborted, + self.n_with_count, + self.median_cpu_us, + self.max_cpu_us, + self.median_peak_bytes, + self.max_peak_bytes, + ) + } +} + +fn median_u128(mut v: Vec) -> u128 { + if v.is_empty() { + return 0; + } + v.sort_unstable(); + v[v.len() / 2] +} + +pub(crate) fn bench_four_counts(txs: &[Transaction], limits: &Limits) -> [PrimitiveBench; 4] { + let names = ["brute", "radix", "sparse", "sasamoto"]; + let mut cpu: [Vec; 4] = std::array::from_fn(|_| Vec::new()); + let mut peak: [Vec; 4] = std::array::from_fn(|_| Vec::new()); + let mut aborted = [0usize; 4]; + let mut with_count = [0usize; 4]; + for tx in txs { + for (i, r) in four_counts(tx, limits).iter().enumerate() { + cpu[i].push(r.cpu.as_micros()); + peak[i].push(r.peak_bytes as u128); + if matches!(r.status, Status::Aborted(_)) { + aborted[i] += 1; + } + if r.ambiguity.lower_bound_count().is_some() { + with_count[i] += 1; + } + } + } + std::array::from_fn(|i| PrimitiveBench { + name: names[i], + n_txs: txs.len(), + n_aborted: aborted[i], + n_with_count: with_count[i], + median_cpu_us: median_u128(cpu[i].clone()), + max_cpu_us: cpu[i].iter().copied().max().unwrap_or(0), + median_peak_bytes: median_u128(peak[i].clone()) as usize, + max_peak_bytes: peak[i].iter().copied().max().unwrap_or(0) as usize, + }) +} + +fn midpoint_target(inputs: &[u64]) -> u64 { + inputs.iter().copied().fold(0u64, u64::saturating_add) / 2 +} + +pub(crate) fn regime_for_tx(tx: &Transaction) -> Option { + Bracket::new(tx.inputs.iter().copied(), midpoint_target(&tx.inputs)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_four_named_rows() { + let tx = Transaction::new(vec![1, 2, 4, 8], vec![3, 12]); + let rows = four_counts(&tx, &Limits::default()); + let names: Vec<&str> = rows.iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["brute", "radix", "sparse", "sasamoto"]); + } + + #[test] + fn bench_four_counts_summarizes_cost_and_coverage() { + use dense_subset_sum::fixtures::wasabi2_positive as wp; + let txs = vec![ + fixtures::maurer_fig2(), + fixtures::equal_denominations(), + Transaction::new(vec![500, 500, 500], vec![500, 1000]), + wp::wasabi2_pos_4d1424ce_20in22out(), + wp::wasabi2_pos_03b4bd61_20in34out(), + ]; + let bench = bench_four_counts(&txs, &Limits::default()); + eprintln!("\n{}", PrimitiveBench::header()); + for b in &bench { + eprintln!("{}", b.row()); + } + assert_eq!(bench[1].name, "radix"); + assert_eq!(bench[1].n_aborted, 0, "radix never aborts"); + assert_eq!( + bench[1].n_with_count, + txs.len(), + "radix always yields a count" + ); + assert!( + bench[0].n_aborted >= 2, + "brute aborts on the large coinjoins" + ); + assert!( + bench[2].n_aborted >= 2, + "sparse aborts above subsum_max_outputs" + ); + assert!( + bench[3].n_aborted >= 2, + "sasamoto aborts above subsum_max_outputs" + ); + } + + #[test] + fn l_sweep_shifts_regime_and_max_money_is_conservative() { + use dense_subset_sum::{kappa, regime_at_l, L}; + let tx = dense_subset_sum::fixtures::wasabi2_positive::wasabi2_pos_03b4bd61_20in34out(); + let n = tx.inputs.len(); + let e = midpoint_target(&tx.inputs); + eprintln!("\nL sweep @ E={e}, N={n}:"); + for &l in L::all() { + let lv = l.value(&tx.inputs); + eprintln!( + " L={:<12} (={lv}) -> κ={:?} regime={:?}", + l.to_string(), + kappa(lv, n), + regime_at_l(&tx.inputs, e, l) + ); + } + let k_max = kappa(L::Max.value(&tx.inputs), n).unwrap(); + let k_money = kappa(L::MaxMoney.value(&tx.inputs), n).unwrap(); + assert!( + k_max <= k_money, + "MAX_MONEY (larger L) must give κ ≥ max(A): {k_max} vs {k_money}" + ); + assert!( + regime_at_l(&tx.inputs, e, L::Max).is_some(), + "regime computable at L=max(A)" + ); + } + + #[test] + fn regime_for_tx_brackets_a_real_coinjoin() { + let tx = dense_subset_sum::fixtures::wasabi2_positive::wasabi2_pos_03b4bd61_20in34out(); + let bracket = regime_for_tx(&tx).expect("bracket computable for a non-empty coinjoin"); + eprintln!("\nregime_for_tx(wasabi 20in34out): {bracket}"); + assert!(bracket.kappa().best().is_finite() && bracket.kappa().worst().is_finite()); + } + + #[test] + fn large_coinjoin_aborts_explosive_paths_and_radix_carries() { + let tx = dense_subset_sum::fixtures::wasabi2_positive::wasabi2_pos_03b4bd61_20in34out(); + let rows = four_counts(&tx, &Limits::default()); // [brute, radix, sparse, sasamoto] + assert!( + matches!(rows[0].status, Status::Aborted(_)), + "brute should abort on N+M>15" + ); + assert!( + matches!(rows[2].status, Status::Aborted(_)), + "sparse should abort above subsum_max_outputs" + ); + assert!( + matches!(rows[3].status, Status::Aborted(_)), + "sasamoto should abort above subsum_max_outputs" + ); + let radix = rows[1] + .ambiguity + .lower_bound_count() + .expect("radix yields an exact count"); + assert!( + radix > 0, + "radix should produce a positive mapping count on a real coinjoin, got 0" + ); + } + + #[test] + fn sasamoto_is_not_a_lower_bound() { + let tx = Transaction::new(vec![1, 2, 4, 8], vec![3, 12]); + let rows = four_counts(&tx, &Limits::default()); + assert!(!rows[3].is_lower_bound); + } + + use dense_subset_sum::fixtures; + + #[test] + fn sparse_lower_bound_does_not_exceed_brute() { + let tx = Transaction::new(vec![500, 500, 500], vec![500, 1000]); + let rows = four_counts(&tx, &Limits::default()); + let brute = rows[0] + .ambiguity + .lower_bound_count() + .expect("brute is Exact on small tx"); + assert!( + brute > 0, + "brute count must be > 0 for a meaningful comparison" + ); + let sparse = rows[2] + .ambiguity + .lower_bound_count() + .expect("sparse is Exact on small tx"); + assert!( + sparse <= brute, + "sparse lower bound {} exceeded brute {}", + sparse, + brute + ); + } + + #[test] + fn radix_is_exact_on_denominated_outputs() { + let tx = Transaction::new(vec![1_000, 10_000], vec![1_000, 10_000]); + let rows = four_counts(&tx, &Limits::default()); + assert!( + rows[1].ambiguity.is_exact(), + "radix should be Exact, got {:?}", + rows[1].ambiguity + ); + let n = rows[1].ambiguity.lower_bound_count().unwrap(); + assert!( + n > 0, + "radix should produce nonzero mappings on standard denominations, got 0" + ); + } + + #[test] + fn sasamoto_never_yields_a_lower_bound_count() { + let tx = fixtures::maurer_fig2(); + let rows = four_counts(&tx, &Limits::default()); + assert!( + rows[3].ambiguity.lower_bound_count().is_none(), + "sasamoto should never expose a lower-bound count, got {:?}", + rows[3].ambiguity + ); + } + + #[test] + fn sasamoto_is_a_good_estimate_in_regime() { + let a: Vec = (1..=20).collect(); + let report = + dense_subset_sum::harness::vs_oracle::compare(&a, 100, 10, 1_000_000, "btsim-sasamoto"); + assert!(report.sasamoto.n_points > 0, "no comparable points"); + assert!( + report.sasamoto.median_error < 0.10, + "sasamoto median error {:.1}% exceeds 10%", + report.sasamoto.median_error * 100.0 + ); + } + + use crate::config::{ScorerConfig, WalletTypeConfig}; + use crate::script_type::ScriptType; + use crate::SimulationBuilder; + + #[test] + fn confirmed_tx_to_dss_extracts_amounts() { + let mut sim = SimulationBuilder::new( + 42, + vec![WalletTypeConfig { + name: "t".into(), + count: 2, + strategies: vec!["UnilateralSpender".into()], + scorer: ScorerConfig { + privacy_weight: 0.0, + payment_obligation_weight: 1.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2tr, + }], + 20, + 1, + 4, + ) + .build(); + sim.build_universe(); + let result = sim.run(); + let any = result + .dss_transactions() + .into_iter() + .find(|t| !t.inputs.is_empty()); + if let Some(tx) = any { + assert!(tx.inputs.iter().all(|a| *a > 0)); + assert!(!tx.outputs.is_empty()); + } + } +} diff --git a/btsim/src/denominate.rs b/btsim/src/denominate.rs new file mode 100644 index 0000000..aacb805 --- /dev/null +++ b/btsim/src/denominate.rs @@ -0,0 +1,85 @@ +//! Greedy is used instead of the dep's exact `radix_decompose` + `radix_sumset` because +//! building the full sumset at k=6 takes >200s; greedy is O(MAX_K · |denoms|) per call. +//! The dropped remainder (amount − sum) becomes fee because fee = inputs − outputs in the sim. + +use std::sync::LazyLock; + +use bitcoin::Amount; +use dense_subset_sum::{ + standard_denoms_in_range, DEFAULT_MAX_COMBINATION_SIZE, DEFAULT_MAX_DENOM_SATS, + DEFAULT_MIN_DENOM_SATS, +}; + +const MAX_K: usize = DEFAULT_MAX_COMBINATION_SIZE; // 6 + +static DENOMS: LazyLock> = LazyLock::new(|| { + let mut d = standard_denoms_in_range(DEFAULT_MIN_DENOM_SATS, DEFAULT_MAX_DENOM_SATS); + d.sort_unstable_by(|a, b| b.cmp(a)); + d +}); + +pub(crate) fn denominate(amount: Amount) -> Vec { + let denoms = &*DENOMS; + let mut remaining = amount.to_sat(); + let mut out = Vec::with_capacity(MAX_K); + for _ in 0..MAX_K { + match denoms.iter().copied().find(|&d| d <= remaining) { + Some(d) => { + out.push(Amount::from_sat(d)); + remaining -= d; + } + None => break, + } + } + if out.is_empty() { + vec![amount] + } else { + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dense_subset_sum::is_standard_denom; + + #[test] + fn denominable_amount_is_below_and_standard() { + let amount = Amount::from_sat(1_234_567); + let out = denominate(amount); + let sum: u64 = out.iter().map(|a| a.to_sat()).sum(); + let smallest = *DENOMS.last().expect("denom set non-empty"); + let remainder = amount.to_sat() - sum; + assert!(sum <= amount.to_sat(), "must approximate from below"); + assert!(out.len() <= MAX_K, "at most MAX_K denominations"); + // Greedy stops when nothing smaller fits OR the MAX_K cap is hit; it does not + // guarantee remainder < dust when the cap is reached — the leftover is dropped to fee. + assert!( + remainder < smallest || out.len() == MAX_K, + "remainder {remainder} must be < smallest denom ({smallest}) unless MAX_K reached" + ); + for a in &out { + assert!( + is_standard_denom(a.to_sat()), + "{} should be a standard denom", + a.to_sat() + ); + } + } + + #[test] + fn exact_denomination_returns_itself() { + let out = denominate(Amount::from_sat(1000)); + let sum: u64 = out.iter().map(|a| a.to_sat()).sum(); + assert_eq!(sum, 1000); + assert!(out.iter().all(|a| is_standard_denom(a.to_sat()))); + } + + #[test] + fn below_smallest_denom_falls_back() { + assert_eq!( + denominate(Amount::from_sat(100)), + vec![Amount::from_sat(100)] + ); + } +} diff --git a/btsim/src/lib.rs b/btsim/src/lib.rs index dc04503..3939fff 100644 --- a/btsim/src/lib.rs +++ b/btsim/src/lib.rs @@ -34,6 +34,12 @@ use crate::{ }, }; +mod alloc_probe; + +#[cfg(feature = "alloc-probe")] +#[global_allocator] +static GLOBAL: alloc_probe::CountingAllocator = alloc_probe::CountingAllocator; + #[macro_use] mod macros; mod actions; @@ -41,11 +47,17 @@ mod blocks; mod bulletin_board; mod coin_selection; pub mod config; +mod correctness; mod cospend; +mod counts; +mod denominate; mod economic_graph; mod graphviz; +mod lower_bound_metric; mod message; pub mod metrics; +mod subset_sum; +pub use correctness::print_correctness_report; pub mod script_type; mod transaction; mod tx_contruction; @@ -160,6 +172,8 @@ pub struct SimulationBuilder { block_interval: u64, /// Number of payment obligations to create num_payment_obligations: usize, + denominate_change: bool, + denominated_funding: Option, } impl SimulationBuilder { @@ -178,9 +192,24 @@ impl SimulationBuilder { max_timestep: TimeStep(max_timestep), block_interval, num_payment_obligations, + denominate_change: false, + denominated_funding: None, } } + pub fn denominate_change(mut self, enabled: bool) -> Self { + self.denominate_change = enabled; + self + } + + pub fn denominated_funding( + mut self, + funding: Option, + ) -> Self { + self.denominated_funding = funding; + self + } + pub fn build(self) -> Simulation { let mut prng_factory = PrngFactory::new(self.seed); let economic_graph_prng = prng_factory.generate_prng(); @@ -210,6 +239,8 @@ impl SimulationBuilder { max_timestep: self.max_timestep, block_interval: self.block_interval, num_payment_obligations: self.num_payment_obligations, + denominate_change: self.denominate_change, + denominated_funding: self.denominated_funding, }, }; @@ -247,7 +278,7 @@ impl SimulationBuilder { // Create wallets according to their type configurations for wallet_type in &self.wallet_types { let scorer = CompositeScorer { - privacy_bundle: crate::metrics::PrivacyBundle::default(), + privacy_bundle: crate::config::build_privacy_bundle(&wallet_type.scorer), payment_obligation_weight: wallet_type.scorer.payment_obligation_weight, min_fallback_plans: wallet_type.scorer.min_fallback_plans, }; @@ -281,6 +312,8 @@ struct SimulationConfig { max_timestep: TimeStep, block_interval: u64, num_payment_obligations: usize, + denominate_change: bool, + denominated_funding: Option, } /// all entities are numbered sequentially @@ -317,6 +350,10 @@ pub struct Simulation { } impl<'a> Simulation { + pub(crate) fn denominate_change_enabled(&self) -> bool { + self.config.denominate_change + } + pub fn build_universe(&mut self) { let mut prng = self.prng_factory.generate_prng(); let wallet_ids: Vec = self.wallet_data.iter().map(|w| w.id).collect(); @@ -325,20 +362,44 @@ impl<'a> Simulation { .map(|&id| id.with_mut(self).new_address()) .collect::>(); - // For now we just mine a coinbase transaction for each wallet + let funding = self.config.denominated_funding.clone(); let mut i = 0; - for address in addresses.iter() { - for _ in 0..prng.random_range(5..10) { + if let Some(funding) = funding { + // Denominated funding: each wallet gets utxos_per_wallet standard-denom UTXOs from a narrow + // band, so a multiparty coinjoin of them is dense by construction. + let denoms = + dense_subset_sum::standard_denoms_in_range(funding.band_min, funding.band_max); + assert!( + !denoms.is_empty(), + "denominated funding band has no standard denoms" + ); + for address in addresses.iter() { + let amounts: Vec = (0..funding.utxos_per_wallet) + .map(|j| denoms[j % denoms.len()]) + .collect(); let _ = BroadcastSetHandleMut { id: BroadcastSetId(i), sim: self, } .construct_block_template(Weight::MAX_BLOCK) - .mine(*address, self); - + .mine_denominated(*address, &amounts, self); self.assert_invariants(); i += 1; } + } else { + // For now we just mine a coinbase transaction for each wallet + for address in addresses.iter() { + for _ in 0..prng.random_range(5..10) { + let _ = BroadcastSetHandleMut { + id: BroadcastSetId(i), + sim: self, + } + .construct_block_template(Weight::MAX_BLOCK) + .mine(*address, self); + self.assert_invariants(); + i += 1; + } + } } // We'll set up some payment obligations @@ -912,6 +973,58 @@ impl SimulationResult { let file = std::fs::File::create(path).unwrap(); serde_json::to_writer_pretty(file, &result).unwrap(); } + pub fn dss_transactions(&self) -> Vec { + (0..self.sim.tx_data.len()) + .map(crate::transaction::TxId) + .filter_map(|txid| crate::counts::sim_tx_to_dss(&self.sim, txid)) + .collect() + } + + pub fn print_four_counts_report(&self) { + let limits = crate::counts::Limits::default(); + println!( + "tx_index | primitive | log2W/count | lower-bound? | status | cpu_us | peak_bytes" + ); + for (i, tx) in self.dss_transactions().into_iter().enumerate() { + for row in crate::counts::four_counts(&tx, &limits) { + let value = row + .ambiguity + .log() + .map(|l| format!("{:.2}", l / std::f64::consts::LN_2)) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:>8} | {:>8} | {:>11} | {:>12} | {:?} | {:>7} | {:>10}", + i, + row.name, + value, + row.is_lower_bound, + row.status, + row.cpu.as_micros(), + row.peak_bytes, + ); + } + } + } + + pub fn print_four_counts_summary(&self) { + let txs = self.dss_transactions(); + let bench = crate::counts::bench_four_counts(&txs, &crate::counts::Limits::default()); + println!("{}", crate::counts::PrimitiveBench::header()); + for b in &bench { + println!("{}", b.row()); + } + } + + pub fn print_density_regime(&self) { + println!("tx_index | density regime (best/worst-L bracket at midpoint E)"); + for (i, tx) in self.dss_transactions().into_iter().enumerate() { + match crate::counts::regime_for_tx(&tx) { + Some(bracket) => println!("{i:>8} | {bracket}"), + None => println!("{i:>8} | (infeasible)"), + } + } + } + // TODO: anon set metrics } @@ -945,6 +1058,11 @@ mod tests { privacy_weight: 2.0, payment_obligation_weight: 1.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2tr, }]; @@ -987,6 +1105,11 @@ mod tests { privacy_weight: 1.0, payment_obligation_weight: 2.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2wpkh, }, @@ -998,6 +1121,11 @@ mod tests { privacy_weight: 0.0, payment_obligation_weight: 0.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2wpkh, }, @@ -1027,6 +1155,11 @@ mod tests { privacy_weight: 2.0, payment_obligation_weight: 1.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2tr, }]; @@ -1119,7 +1252,8 @@ mod tests { let candidates = alice.with(&sim).coin_candidates(); let (selected_outpoints, change_amounts) = crate::coin_selection::select_bnb(&candidates, target) - .unwrap_or_else(|| crate::coin_selection::select_all(&candidates, target)); + .or_else(|| crate::coin_selection::select_all(&candidates, target)) + .expect("alice can cover the payment"); let spend = alice .with_mut(&mut sim) @@ -1234,6 +1368,11 @@ mod tests { privacy_weight: 2.0, payment_obligation_weight: 1.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type, }]; @@ -1292,6 +1431,11 @@ mod tests { privacy_weight: 0.0, payment_obligation_weight: 1.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2tr, }, @@ -1303,6 +1447,11 @@ mod tests { privacy_weight: 0.0, payment_obligation_weight: 1.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: ScriptType::P2tr, }, @@ -1369,3 +1518,164 @@ mod tests { ); } } + +#[cfg(test)] +mod sp1_config_tests { + use crate::config::{ScorerConfig, WalletTypeConfig}; + use crate::script_type::ScriptType; + use crate::SimulationBuilder; + + fn wt() -> WalletTypeConfig { + WalletTypeConfig { + name: "t".into(), + count: 2, + strategies: vec!["UnilateralSpender".into()], + scorer: ScorerConfig { + privacy_weight: 0.0, + payment_obligation_weight: 1.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2tr, + } + } + + #[test] + fn denominate_change_defaults_off_and_can_be_enabled() { + let off = SimulationBuilder::new(42, vec![wt()], 5, 1, 0).build(); + assert!(!off.denominate_change_enabled()); + let on = SimulationBuilder::new(42, vec![wt()], 5, 1, 0) + .denominate_change(true) + .build(); + assert!(on.denominate_change_enabled()); + } + + // Feasibility proof: denominated funding seeds each wallet with denominated UTXOs and build_universe + // (which calls assert_invariants) holds with a multi-output coinbase. + #[test] + fn denominated_funding_seeds_denominated_utxos_and_holds_invariants() { + use crate::config::DenominatedFunding; + use dense_subset_sum::is_standard_denom; + let mut sim = SimulationBuilder::new(42, vec![wt()], 5, 1, 0) + .denominated_funding(Some(DenominatedFunding { + band_min: 512, + band_max: 8192, + utxos_per_wallet: 4, + })) + .build(); + sim.build_universe(); // panics via assert_invariants if the denominated coinbase breaks anything + + let amounts: Vec = sim + .wallet_info + .iter() + .flat_map(|w| w.confirmed_utxos.iter().copied().collect::>()) + .map(|op| op.with(&sim).data().amount.to_sat()) + .collect(); + + // 2 wallets (wt count) × 4 UTXOs each = 8 — proves ALL coinbase outputs got registered. + assert_eq!( + amounts.len(), + 8, + "every denominated coinbase output must be registered, got {amounts:?}" + ); + assert!( + amounts.iter().all(|&a| is_standard_denom(a)), + "funded UTXOs must be standard denoms: {amounts:?}" + ); + assert!( + amounts.iter().all(|&a| (512..=8192).contains(&a)), + "funded UTXOs must be in band: {amounts:?}" + ); + } + + /// A denominated multiparty scenario: `participants` wallets (+1 aggregator) funded with + /// `upw` standard-denomination UTXOs in `[band_min, band_max]`, paying `num_obl` obligations + /// over `max_ts` ticks. Used to exercise emergent coinjoin formation and the counting paths. + #[allow(clippy::too_many_arguments)] + fn dense_scenario( + seed: u64, + participants: usize, + max_ts: u64, + num_obl: usize, + band_min: u64, + band_max: u64, + upw: usize, + denom_change: bool, + ) -> SimulationBuilder { + use crate::config::DenominatedFunding; + let participant = WalletTypeConfig { + name: "participant".into(), + count: participants, + strategies: vec!["MultipartyStrategy".into()], + scorer: ScorerConfig { + privacy_weight: 1000.0, + payment_obligation_weight: 1.0, + min_fallback_plans: 0, + subset_sum_threshold: Some(100), + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: Some(50), + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2tr, + }; + let aggregator = WalletTypeConfig { + name: "aggregator".into(), + count: 1, + strategies: vec!["AggregatorStrategy".into()], + scorer: ScorerConfig { + privacy_weight: 0.0, + payment_obligation_weight: 1.0, + min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, + }, + script_type: ScriptType::P2tr, + }; + SimulationBuilder::new(seed, vec![participant, aggregator], max_ts, 1, num_obl) + .denominate_change(denom_change) + .denominated_funding(Some(DenominatedFunding { + band_min, + band_max, + utxos_per_wallet: upw, + })) + } + + // Regression for the over-spend fix. A small denominated band funds wallets with balances + // far below their (Geometric) obligations; the planner previously selected all inputs without + // checking coverage and built txs with outputs > inputs, tripping transaction.rs's + // value-conservation assert. The sim must now complete — wallets defer unaffordable + // obligations instead of over-spending. + #[test] + fn small_band_denominated_scenario_completes_without_overspend() { + let mut sim = dense_scenario(7, 20, 40, 40, 512, 8192, 4, true).build(); + sim.build_universe(); + let _ = sim.run(); + } + + // Exploration fixture (ignored): the larger denominated scenario forms a multiparty coinjoin. + // Emergent throughput is low (≈one large session per run) — see + // docs/superpowers/specs/2026-05-31-fix-sim-coordination-for-dense-coinjoins-design.md. This + // is the fixture for the switch-discovery sweep, not a pass/fail assertion of the full goal. + #[test] + #[ignore] + fn dense_large_forms_coinjoin() { + let mut sim = dense_scenario(7, 20, 60, 40, 131_072, 2_097_152, 8, true).build(); + sim.build_universe(); + let result = sim.run(); + assert!( + result + .dss_transactions() + .iter() + .any(|t| t.inputs.len() >= 2), + "expected at least one multiparty coinjoin to form" + ); + } +} diff --git a/btsim/src/lower_bound_metric.rs b/btsim/src/lower_bound_metric.rs new file mode 100644 index 0000000..1fe6bfa --- /dev/null +++ b/btsim/src/lower_bound_metric.rs @@ -0,0 +1,314 @@ +use bitcoin::Amount; +use dense_subset_sum::{ + radix_density, radix_mappings, w_brute, w_sparse, Ambiguity, DEFAULT_MEMORY_BUDGET, +}; + +use crate::actions::{ActionCost, CostMode, Plan}; +use crate::metrics::{ErasedPrivacyMetric, IntoCost, PrivacyMetric}; +use crate::subset_sum::{brute_feasible, subsum_feasible, SUBSUM_MAX_OUTPUTS}; + +/// Best guaranteed lower bound on W via the brute/sparse ladder. Sasamoto is excluded (an +/// approximation, never the sole basis in the critical path); radix is a different object (it counts +/// k×m! mappings, not W) and lives in its own metric. +#[derive(Debug)] +pub(crate) struct WLowerBound { + pub(crate) best: u128, + pub(crate) threshold: u128, +} + +impl IntoCost for WLowerBound { + fn into_cost(&self, budget: Amount) -> ActionCost { + if self.best >= self.threshold { + ActionCost(0.0) + } else { + let deficit = (self.threshold - self.best) as f64 / self.threshold as f64; + ActionCost(deficit * budget.to_sat() as f64) + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct WLowerBoundMetric { + pub(crate) max_size: usize, + pub(crate) brute_max_terms: usize, + pub(crate) threshold: u128, +} + +impl PrivacyMetric for WLowerBoundMetric { + type Output = WLowerBound; + + fn evaluate(&self, plan: &Plan, mode: CostMode) -> WLowerBound { + if mode.external == 0.0 { + return WLowerBound { + best: u128::MAX, + threshold: self.threshold, + }; + } + + let inputs: Vec = plan + .my_inputs + .iter() + .chain(plan.their_inputs.iter()) + .map(|(_, a)| a.to_sat()) + .collect(); + let outputs: Vec = plan + .my_outputs + .iter() + .chain(plan.their_outputs.iter()) + .map(|a| a.to_sat()) + .collect(); + + let brute = if brute_feasible(inputs.len(), outputs.len(), self.brute_max_terms) { + w_brute(&inputs, &outputs, self.max_size) + } else { + Ambiguity::Unknown + }; + let sparse = if subsum_feasible(outputs.len(), SUBSUM_MAX_OUTPUTS) { + w_sparse(&inputs, &outputs, self.max_size, DEFAULT_MEMORY_BUDGET) + } else { + Ambiguity::Unknown + }; + + let best = [brute, sparse] + .iter() + .filter_map(Ambiguity::lower_bound_count) + .max() + .unwrap_or(0); + + WLowerBound { + best, + threshold: self.threshold, + } + } +} + +impl ErasedPrivacyMetric for WLowerBoundMetric { + fn evaluate_erased(&self, plan: &Plan, mode: CostMode) -> Box { + Box::new(self.evaluate(plan, mode)) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Radix special-case anonymity facet: Σ k×m! equivalent mappings over the outputs. A DIFFERENT +/// object from W (it ignores inputs), so it is its own metric — never maxed into the W bound. Only +/// credited when the outputs are denomination-dense enough (`radix_density ≥ density_floor`) for the +/// k×m! count to be reliable. +#[derive(Debug)] +pub(crate) struct RadixMapping { + pub(crate) count: u128, + pub(crate) threshold: u128, +} + +impl IntoCost for RadixMapping { + fn into_cost(&self, budget: Amount) -> ActionCost { + if self.count >= self.threshold { + ActionCost(0.0) + } else { + let deficit = (self.threshold - self.count) as f64 / self.threshold as f64; + ActionCost(deficit * budget.to_sat() as f64) + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RadixMappingMetric { + pub(crate) max_size: usize, + pub(crate) threshold: u128, + pub(crate) density_floor: f64, +} + +impl PrivacyMetric for RadixMappingMetric { + type Output = RadixMapping; + + fn evaluate(&self, plan: &Plan, mode: CostMode) -> RadixMapping { + if mode.external == 0.0 { + return RadixMapping { + count: u128::MAX, + threshold: self.threshold, + }; + } + let outputs: Vec = plan + .my_outputs + .iter() + .chain(plan.their_outputs.iter()) + .map(|a| a.to_sat()) + .collect(); + let count = if radix_density(&outputs) >= self.density_floor { + radix_mappings(&outputs, self.max_size) + .lower_bound_count() + .unwrap_or(0) + } else { + 0 + }; + RadixMapping { + count, + threshold: self.threshold, + } + } +} + +impl ErasedPrivacyMetric for RadixMappingMetric { + fn evaluate_erased(&self, plan: &Plan, mode: CostMode) -> Box { + Box::new(self.evaluate(plan, mode)) + } + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::WalletResidue; + use crate::transaction::{Outpoint, TxId}; + + #[test] + fn threshold_met_is_zero_cost() { + let lb = WLowerBound { + best: 1000, + threshold: 1000, + }; + assert_eq!(lb.into_cost(Amount::from_sat(500)), ActionCost(0.0)); + } + + #[test] + fn zero_best_is_full_penalty() { + let lb = WLowerBound { + best: 0, + threshold: 1000, + }; + assert_eq!(lb.into_cost(Amount::from_sat(500)), ActionCost(500.0)); + } + + #[test] + fn partial_is_proportional_deficit() { + let lb = WLowerBound { + best: 250, + threshold: 1000, + }; + assert_eq!(lb.into_cost(Amount::from_sat(400)), ActionCost(300.0)); + } + + fn plan(my_in: &[u64], their_in: &[u64], my_out: &[u64], their_out: &[u64]) -> Plan { + let ot = |i: usize| Outpoint { + txid: TxId(i), + index: 0, + }; + Plan { + my_inputs: my_in + .iter() + .enumerate() + .map(|(i, &a)| (ot(i), Amount::from_sat(a))) + .collect(), + their_inputs: their_in + .iter() + .enumerate() + .map(|(i, &a)| (ot(1000 + i), Amount::from_sat(a))) + .collect(), + my_outputs: my_out.iter().map(|&a| Amount::from_sat(a)).collect(), + their_outputs: their_out.iter().map(|&a| Amount::from_sat(a)).collect(), + wallet_residue: WalletResidue { + utxos: vec![], + payment_obligations: vec![], + }, + } + } + + fn metric() -> WLowerBoundMetric { + WLowerBoundMetric { + max_size: 6, + brute_max_terms: 15, + threshold: 2, + } + } + + #[test] + fn external_off_is_best_case() { + let out = metric().evaluate( + &plan(&[1000], &[], &[900], &[]), + CostMode::EXTERNAL_PENALTIES_OFF, + ); + assert_eq!(out.best, u128::MAX); + } + + #[test] + fn cospend_equal_amounts_has_a_bound() { + let out = metric().evaluate( + &plan(&[500], &[500], &[500], &[500]), + CostMode::EXTERNAL_PENALTIES_ON, + ); + assert!( + out.best >= 1, + "expected a positive lower bound, got {}", + out.best + ); + } + + fn radix_metric() -> RadixMappingMetric { + RadixMappingMetric { + max_size: 6, + threshold: 2, + density_floor: 0.5, + } + } + + #[test] + fn radix_gate_credits_denominated_outputs() { + // outputs all standard denoms (512 = 2^9): density 1.0 >= floor => count = k×m! > 0 + let out = radix_metric().evaluate( + &plan(&[1536], &[], &[512, 512, 512], &[]), + CostMode::EXTERNAL_PENALTIES_ON, + ); + assert!( + out.count >= 1, + "denominated outputs must yield a positive radix mapping count, got {}", + out.count + ); + } + + #[test] + fn radix_gate_rejects_arbitrary_outputs() { + // non-denominated outputs: density 0 < floor => no credit (count 0) + let out = radix_metric().evaluate( + &plan(&[2_000_006], &[], &[1_000_003, 1_000_003], &[]), + CostMode::EXTERNAL_PENALTIES_ON, + ); + assert_eq!( + out.count, 0, + "arbitrary (non-denom) outputs must not be credited, got {}", + out.count + ); + } + + #[test] + fn radix_external_off_is_best_case() { + let out = radix_metric().evaluate( + &plan(&[1536], &[], &[512, 512, 512], &[]), + CostMode::EXTERNAL_PENALTIES_OFF, + ); + assert_eq!(out.count, u128::MAX); + } + + #[test] + fn radix_into_cost_threshold_and_deficit() { + assert_eq!( + RadixMapping { + count: 10, + threshold: 10 + } + .into_cost(Amount::from_sat(500)), + ActionCost(0.0) + ); + assert_eq!( + RadixMapping { + count: 0, + threshold: 1000 + } + .into_cost(Amount::from_sat(500)), + ActionCost(500.0) + ); + } +} diff --git a/btsim/src/main.rs b/btsim/src/main.rs index 0b1b2ec..76d3f23 100644 --- a/btsim/src/main.rs +++ b/btsim/src/main.rs @@ -14,6 +14,10 @@ fn main() { env_logger::init(); let args = Args::parse(); + if std::env::var_os("BTSIM_CORRECTNESS").is_some() { + btsim::print_correctness_report(); + } + // Read config file path from environment or use default let config_path = env::var("CONFIG_FILE").unwrap_or_else(|_| "config.toml".to_string()); @@ -28,10 +32,17 @@ fn main() { 1, // TODO: hardcoded block interval for now. If we change this we need to ensure payment obligations are not being double handled. config.simulation.num_payment_obligations, ) + .denominate_change(config.simulation.denominate_change) + .denominated_funding(config.simulation.denominated_funding.clone()) .build(); sim.build_universe(); let result = sim.run(); + if std::env::var_os("BTSIM_FOUR_COUNTS").is_some() { + result.print_four_counts_report(); + result.print_four_counts_summary(); + result.print_density_regime(); + } if let Some(dir) = args.artifacts_dir.as_ref() { std::fs::create_dir_all(dir).unwrap(); let graph_path = dir.join("graph.svg"); diff --git a/btsim/src/subset_sum.rs b/btsim/src/subset_sum.rs new file mode 100644 index 0000000..c1024ae --- /dev/null +++ b/btsim/src/subset_sum.rs @@ -0,0 +1,14 @@ +//! Feasibility guards shared by the cost metric and the measurement harness. + +/// Sparse and Sasamoto enumerate all `2^#outputs` output subset sums (the dep +/// allocates `HashSet::with_capacity(1< bool { + n_inputs + n_outputs <= max_terms +} + +pub(crate) fn subsum_feasible(n_outputs: usize, max_outputs: usize) -> bool { + n_outputs <= max_outputs +} diff --git a/btsim/src/tx_contruction.rs b/btsim/src/tx_contruction.rs index aa462a5..24a30b7 100644 --- a/btsim/src/tx_contruction.rs +++ b/btsim/src/tx_contruction.rs @@ -158,6 +158,11 @@ mod tests { privacy_weight: 0.0, payment_obligation_weight: 0.0, min_fallback_plans: 0, + subset_sum_threshold: None, + subset_sum_max_size: 6, + brute_max_terms: 15, + radix_threshold: None, + radix_density_floor: 0.5, }, script_type: crate::script_type::ScriptType::P2tr, }]; diff --git a/perf.data b/perf.data deleted file mode 100644 index 640953f903633509b5f27020efa4e3426ac7095e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 344920 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