Skip to content
Open
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
59 changes: 54 additions & 5 deletions src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,14 @@ pub(crate) struct InitiatePayjoinOutcome {
balance_difference: f64,
/// Fee savings from the payjoin
fee_savings: Amount,
// TODO: somekind of privacy gained metric?
/// Privacy score based on input/output mixing and timing analysis resistance
privacy_score: f64,
Comment on lines -109 to +110
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking back at this privacy score comment now. I think its a bit outdated and should be removed of 2 party payjoins. Eventually we want dense subset score. i.e given I payjoin with this UTXO over others how dense is subtransaction model. Or given I do a mp pj with this participant over others how dense is the subtransaction model.

}

impl InitiatePayjoinOutcome {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have fee_savings and privacy_score calculation in InitiatePayjoinOutcome should this be done for RespondToPayjoinOutcome so scoring is not affected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, RespondToPayjoinOutcome should have the same privacy_score as InitiatePayjoinOutcome to ensure fair comparison between initiating vs responding to payjoins. since both actions provides identical fee savings and privacy benefits.

/// Batching anxiety should increase and payjoin utility should decrease the closer the deadline is.
/// This can be modeled as a inverse cubic function of the time left.
/// TODO: how do we model potential fee savings? Understanding that at most there will be one input and one output added could lead to a simple linear model.
/// Fee savings are modeled linearly based on the additional input/output structure of payjoins.
fn score(&self, payjoin_utility_factor: f64) -> ActionScore {
let points = [
(0.0, 0.0),
Expand All @@ -121,8 +122,20 @@ impl InitiatePayjoinOutcome {
];
let utility = piecewise_linear(self.time_left as f64, &points);

let score = self.balance_difference + (self.amount_handled * utility);
debug!("InitiatePayjoinEvent score: {:?}", score);
// Base utility score
let base_score = self.balance_difference + (self.amount_handled * utility);

// Add fee savings benefit (convert to float for calculation)
let fee_benefit = self.fee_savings.to_float_in(bitcoin::Denomination::Satoshi);

// Add privacy benefit (weighted by utility factor)
let privacy_benefit = self.privacy_score * payjoin_utility_factor;

let score = base_score + fee_benefit + privacy_benefit;
debug!(
"InitiatePayjoinEvent score: {:?} (base: {:?}, fee: {:?}, privacy: {:?})",
score, base_score, fee_benefit, privacy_benefit
);
ActionScore(score)
Comment on lines +134 to 139
Copy link
Contributor

@Mshehu5 Mshehu5 Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This question is for anyone reviewing

if maybe a simulation where fee_benefit is meant to matter in. (maybe to see the fee saving benefits of transaction cut through in multi party)
Should it be scaled up or have an option of scaling it up? with a utility factor or something else
as currently with this score, base_score will mostly be higher and be the main decider due to payjoin_utility_factor increase

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Fee's right now are static.
One TODO item is to pay a higher fee rate the more anxious you are for your deadline. This should be modeled as a piecewise linear with a cap. Also this should probably be implemented first in the unilateral strategy then else where.

}
}
Expand Down Expand Up @@ -221,6 +234,41 @@ impl WalletView {
}
}
}
/// Calculate fee savings for a payjoin based on the typical structure:
/// - Payjoin adds one input and one output compared to separate transactions
/// - Fee savings = (2 separate txs) - (1 combined tx)
fn calculate_payjoin_fee_savings(amount: f64) -> Amount {
// Rough estimate: payjoins typically save ~100-200 sats in fees
// This is a simplified model - in reality it depends on:
// - Current fee rate
// - Input/output sizes
// - Whether batching would have occurred anyway
let base_savings_sats = 150.0;

// Larger amounts might justify slightly higher fee savings due to more inputs
// Cap at 2x for very large amounts
let amount_factor = (amount / 100000.0).min(2.0);
Comment on lines +248 to +250
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand this comment. A fee a user would pay is a reflection of their impatience not really a function of the payment amount.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xZaddyy I still don't understand why fee savings is a function of the amount not the size of the transaction.

let total_savings = (base_savings_sats * (1.0 + amount_factor * 0.2)) as u64;

Amount::from_sat(total_savings)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For context: fees are largely ignored by the simulation right now. If you look in the coin selection code you see that fee rate is hardcoded and the unilateral txs all have the same "weight" so they are paying the same absolute fee.

The fee savings for a 2-party payjoin is mainly for the responder / receiver. Where they only need to cover the fees of a input and output contribution and the rest is covered by the sender.

Ideally the fee rate a agent choose is a function of their impatience (how close to the deadline they are). This should be a TODO -- I dont think we have this documented anywhere.

An easier to place to start is probably representing fee savings to the batched unilateral strategy. Where an agent make 1 tx instead of N when they know about N payment obligation

In general fee savings should always reflect how much blockspace you saved. For the payjoin receiver they would compare the absolute fee they would have to pay in a unilateral Tx ( something that we can hardcode for now given that all txs weight and pay the same fee rate) vs the absolute fee they would have to contribute to just adding an input and output.

}

/// Calculate privacy score for a payjoin
/// Higher scores indicate better privacy benefits
fn calculate_payjoin_privacy_score(amount: f64) -> f64 {
// Base privacy benefit from transaction structure obfuscation
let base_privacy = 10.0;

// Larger amounts get slightly higher privacy scores as they're more valuable to hide
// Log scaling, capped at 1.0
let amount_factor = (amount / 100000.0).ln_1p().min(1.0);

// Random timing component (simplified - in reality depends on network timing)
let timing_privacy = 2.0;

base_privacy + (amount_factor * 5.0) + timing_privacy
Comment on lines +246 to +269
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

niit : Since some of these are hardcode maybe const world be better
e.g

+const BASE_PRIVACY_SCORE: f64 = 10.0;
+const TIMING_PRIVACY_BONUS: f64 = 2.0;

also the numbers being multiplied and divided having a const for them with a variable name can help know the usecase better

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the review @Mshehu5

}

fn get_payment_obligation_handled_outcome(
payment_obligation_id: &PaymentObligationId,
sim: &Simulation,
Expand Down Expand Up @@ -299,7 +347,8 @@ fn simulate_one_action(wallet_handle: &WalletHandleMut, action: &Action) -> Vec<
time_left: po.deadline.0 as i32 - wallet_view.current_timestep.0 as i32,
amount_handled,
balance_difference,
fee_savings: Amount::ZERO, // TODO: implement this
fee_savings: calculate_payjoin_fee_savings(amount_handled),
privacy_score: calculate_payjoin_privacy_score(amount_handled),
}));
}

Expand Down