Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Repository Guidelines

## Project Structure & Module Organization
- `src/` contains all Rust sources. The core simulation logic lives in `src/lib.rs`, while the CLI entry point is `src/main.rs`.
- Domain modules are split by concern, such as `src/transaction.rs`, `src/wallet.rs`, and `src/economic_graph.rs`.
- Example configuration lives at `config.toml.example`; the running config is read from `config.toml` by default.
- Generated artifacts include `graph.svg` produced after a simulation run.

## Build, Test, and Development Commands
- `cargo build` compiles the library and binary.
- `cargo run` runs the simulation using `config.toml` (or set `CONFIG_FILE=path/to/config.toml`).
- `cargo test` runs unit tests embedded in the source files.
- Optional: `cargo fmt` applies standard Rust formatting if available.

## Coding Style & Naming Conventions
- Use Rust 2021 edition conventions with 4-space indentation.
- Naming: `snake_case` for functions/variables, `CamelCase` for types/traits, and `SCREAMING_SNAKE_CASE` for constants.
- Keep modules focused; prefer adding new files in `src/` rather than growing monolithic modules.

## Testing Guidelines
- Tests are defined inline in source files with `#[test]` functions.
- Place module-specific tests near the code they cover (e.g., `src/actions.rs` and `src/lib.rs` already contain tests).
- Run `cargo test` before submitting changes; add tests for new behaviors or bug fixes.

## Commit & Pull Request Guidelines
- Commit messages follow short, imperative summaries (e.g., “Add unit tests for payment strategies”).
- PRs should include a concise description, steps to validate (`cargo test` or `cargo run`), and any relevant output artifacts (e.g., updated `graph.svg` when behavior changes).

## Configuration & Outputs
- Copy `config.toml.example` to `config.toml` and customize settings such as `simulation.seed` and `wallet_types`.
- The binary writes a transaction graph to `graph.svg`; include it in reviews when output structure changes.
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ num_payment_obligations = 15
[[wallet_types]]
name = "Payjoiner"
count = 3
script_type = "p2tr"
strategies = ["UnilateralSpender", "PayjoinStrategy"]
[wallet_types.scorer]
fee_savings_weight = 1.0
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use serde::Deserialize;
use std::fs;

use crate::script_type::ScriptType;

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub simulation: SimulationConfig,
Expand All @@ -20,6 +22,8 @@ pub struct WalletTypeConfig {
pub count: usize,
pub strategies: Vec<String>,
pub scorer: ScorerConfig,
#[serde(default)]
pub script_type: ScriptType,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
Expand Down
86 changes: 82 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use crate::{
config::WalletTypeConfig,
economic_graph::EconomicGraph,
message::{MessageData, MessageId},
script_type::ScriptType,
transaction::{InputId, Outpoint, TxData, TxHandle, TxId, TxInfo},
wallet::{
AddressData, AddressId, PaymentObligationData, PaymentObligationId, WalletData,
Expand All @@ -41,6 +42,7 @@ pub mod config;
mod economic_graph;
mod graphviz;
mod message;
pub mod script_type;
mod transaction;
mod tx_contruction;
mod wallet;
Expand Down Expand Up @@ -290,7 +292,11 @@ impl SimulationBuilder {
}
}
}
let wallet_id = sim.new_wallet(CompositeStrategy { strategies }, scorer.clone());
let wallet_id = sim.new_wallet(
CompositeStrategy { strategies },
scorer.clone(),
wallet_type.script_type,
);
sim.economic_graph.grow(wallet_id);
}
}
Expand Down Expand Up @@ -492,7 +498,12 @@ impl<'a> Simulation {
}
}

fn new_wallet(&mut self, strategies: CompositeStrategy, scorer: CompositeScorer) -> WalletId {
fn new_wallet(
&mut self,
strategies: CompositeStrategy,
scorer: CompositeScorer,
script_type: ScriptType,
) -> WalletId {
// TODO wallet_handle?
let last_wallet_info_id = WalletInfoId(self.wallet_info.len());
self.wallet_info.push(WalletInfo {
Expand Down Expand Up @@ -524,6 +535,7 @@ impl<'a> Simulation {
messages_processed: OrdSet::<MessageId>::default(),
strategies,
scorer,
script_type,
});
id
}
Expand Down Expand Up @@ -769,6 +781,7 @@ mod tests {
payment_obligation_weight: 1.0,
coordination_weight: 0.0,
},
script_type: ScriptType::P2tr,
}];
let mut sim = SimulationBuilder::new(42, wallet_types, 20, 1, 10).build();
sim.assert_invariants();
Expand Down Expand Up @@ -810,6 +823,7 @@ mod tests {
payment_obligation_weight: 1.0,
coordination_weight: 0.0,
},
script_type: ScriptType::P2tr,
}];
let mut sim = SimulationBuilder::new(42, wallet_types, 20, 1, 10).build();
sim.assert_invariants();
Expand All @@ -836,13 +850,15 @@ mod tests {
strategies: alice_strategies,
},
default_scorer.clone(),
ScriptType::P2tr,
);
sim.assert_invariants();
let bob = sim.new_wallet(
CompositeStrategy {
strategies: bob_strategies,
},
default_scorer,
ScriptType::P2tr,
);
sim.assert_invariants();

Expand Down Expand Up @@ -893,7 +909,7 @@ mod tests {
},
outputs: TargetOutputs {
value_sum: payment.amount.to_sat(),
weight_sum: 34, // TODO use payment.to to derive an address, payment.into() ?
weight_sum: ScriptType::P2tr.output_weight_wu(),
n_outputs: 1,
},
};
Expand Down Expand Up @@ -928,7 +944,7 @@ mod tests {

assert_eq!(spend, TxId(2));

assert_eq!(spend.with(&sim).info().weight, Weight::from_wu(688));
assert_eq!(spend.with(&sim).info().weight, Weight::from_wu(616));

assert_eq!(
alice.with(&sim).data().own_transactions,
Expand Down Expand Up @@ -1000,4 +1016,66 @@ mod tests {
assert!(alice.with(&sim).info().unconfirmed_txos.is_empty());
assert!(bob.with(&sim).info().unconfirmed_txos.is_empty());
}

#[test]
fn test_weight_prediction_by_script_type() {
use crate::config::{ScorerConfig, WalletTypeConfig};
use bitcoin::transaction::{predict_weight, InputWeightPrediction};

let script_types = [ScriptType::P2tr, ScriptType::P2wpkh, ScriptType::P2pkh];

for script_type in script_types {
let wallet_types = vec![WalletTypeConfig {
name: "weight_test".to_string(),
count: 2,
strategies: vec!["UnilateralSpender".to_string()],
scorer: ScorerConfig {
fee_savings_weight: 1.0,
privacy_weight: 2.0,
payment_obligation_weight: 1.0,
coordination_weight: 0.0,
},
script_type,
}];

let mut sim = SimulationBuilder::new(42, wallet_types, 5, 1, 0).build();
let wallet_id = WalletId(0);
let funding_addr = wallet_id.with_mut(&mut sim).new_address();
let spend_addr = wallet_id.with_mut(&mut sim).new_address();

let funding_tx = sim.new_tx(|tx, _| {
tx.outputs.push(Output {
amount: Amount::from_sat(1_000),
address_id: funding_addr,
});
});

let spend_tx = sim.new_tx(|tx, _| {
tx.inputs.push(Input {
outpoint: Outpoint {
txid: funding_tx,
index: 0,
},
});
tx.outputs.push(Output {
amount: Amount::from_sat(900),
address_id: spend_addr,
});
});

let expected_input = match script_type {
ScriptType::P2tr => InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH,
ScriptType::P2wpkh => InputWeightPrediction::P2WPKH_MAX,
ScriptType::P2pkh => InputWeightPrediction::P2PKH_COMPRESSED_MAX,
};
let expected_output_len = match script_type {
ScriptType::P2tr => 34,
ScriptType::P2wpkh => 22,
ScriptType::P2pkh => 25,
};
let expected_weight = predict_weight([expected_input], [expected_output_len]);

assert_eq!(spend_tx.with(&sim).info().weight, expected_weight);
}
}
}
49 changes: 49 additions & 0 deletions src/script_type.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use bitcoin::transaction::InputWeightPrediction;
use serde::Deserialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScriptType {
P2tr,
P2wpkh,
P2pkh,
}

impl Default for ScriptType {
fn default() -> Self {
ScriptType::P2tr
}
}

impl ScriptType {
pub(crate) fn input_weight_prediction(self) -> InputWeightPrediction {
match self {
ScriptType::P2tr => InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH,
ScriptType::P2wpkh => InputWeightPrediction::P2WPKH_MAX,
ScriptType::P2pkh => InputWeightPrediction::P2PKH_COMPRESSED_MAX,
}
}

pub(crate) fn input_weight_wu(self) -> u32 {
const INPUT_BASE_WU_NO_SCRIPTSIG_LEN: u32 = (32 + 4 + 4) * 4;
INPUT_BASE_WU_NO_SCRIPTSIG_LEN + self.input_weight_prediction().weight().to_wu() as u32
}

pub(crate) fn output_script_len(self) -> usize {
match self {
ScriptType::P2tr => 34,
ScriptType::P2wpkh => 22,
ScriptType::P2pkh => 25,
}
}

pub(crate) fn output_weight_wu(self) -> u32 {
let script_len = self.output_script_len();
let output_len = 8 + 1 + script_len;
(output_len as u32) * 4
}

pub(crate) fn is_segwit(self) -> bool {
matches!(self, ScriptType::P2tr | ScriptType::P2wpkh)
}
}
31 changes: 4 additions & 27 deletions src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use crate::Simulation;
use bitcoin::consensus::Decodable;
use bitcoin::hashes::Hash;
use bitcoin::transaction::{predict_weight, InputWeightPrediction};
use bitcoin::FeeRate;
use bitcoin::{Amount, Weight};
use bitcoin::{FeeRate, ScriptBuf, WitnessProgram};

define_entity!(
Tx,
Expand Down Expand Up @@ -111,32 +111,9 @@ pub(crate) struct Output {
pub(crate) address_id: AddressId,
}

impl From<Output> for bitcoin::transaction::TxOut {
fn from(o: Output) -> Self {
// FIXME refactor into fn encode_as_txo(enum { AddressId, Index, Outpoint })
// TODO handle multiple address types
let mut program = [0u8; 32];
// TODO tag, segregate from txos encoding indexes?
program[0] = o
.address_id
.0
.try_into()
.expect("TODO support more than 256 addresses");

let witness_program =
WitnessProgram::new(bitcoin::WitnessVersion::V1, &program[..]).unwrap();
let script_pubkey = ScriptBuf::new_witness_program(&witness_program);

bitcoin::transaction::TxOut {
value: o.amount,
script_pubkey,
}
}
}

impl Output {
fn size(&self) -> usize {
bitcoin::transaction::TxOut::from(*self).size()
fn script_pubkey_len(&self, sim: &Simulation) -> usize {
self.address(sim).data().script_type.output_script_len()
}

fn address<'a>(&self, sim: &'a Simulation) -> AddressHandle<'a> {
Expand Down Expand Up @@ -281,7 +258,7 @@ impl TxInfo {

let weight = predict_weight(
prevouts.clone().map(|i| InputWeightPrediction::from(i)),
tx.outputs.iter().map(|o| o.size()),
tx.outputs.iter().map(|o| o.script_pubkey_len(sim)),
);

// TODO separate to a different index struct
Expand Down
3 changes: 3 additions & 0 deletions src/tx_contruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ mod tests {
payment_obligation_weight: 0.0,
coordination_weight: 0.0,
},
script_type: crate::script_type::ScriptType::P2tr,
}];
SimulationBuilder::new(42, wallet_types, 10, 1, 0).build()
}
Expand All @@ -265,6 +266,7 @@ mod tests {
strategies: vec![create_strategy("UnilateralSpender").unwrap()],
},
default_scorer,
crate::script_type::ScriptType::P2tr,
);
let address = wallet.with_mut(sim).new_address();

Expand Down Expand Up @@ -329,6 +331,7 @@ mod tests {
strategies: vec![create_strategy("UnilateralSpender").unwrap()],
},
default_scorer,
crate::script_type::ScriptType::P2tr,
);
let address = wallet.with_mut(sim).new_address();

Expand Down
Loading