Skip to content

lock inputs via strategies and unlock early due to payment anxiety#8

Open
Mshehu5 wants to merge 1 commit intopayjoin:masterfrom
Mshehu5:lock
Open

lock inputs via strategies and unlock early due to payment anxiety#8
Mshehu5 wants to merge 1 commit intopayjoin:masterfrom
Mshehu5:lock

Conversation

@Mshehu5
Copy link
Copy Markdown
Contributor

@Mshehu5 Mshehu5 commented Mar 1, 2026

this PR aims to address two TODO in unspent coin wallet.rs L159-160

// TODO Startegies should inform which inputs can be spendable.
// TODO: these inputs should unlock if the payjoin is expired or the associated payment obligation is due soon (i.e payment anxiety)
coins are now locked when used by a strategy and unlock when expired or due to payment anxiety(payment obligation almost due) which is currently implemented as if less than 10% of your deadline window remains this will unlock the coins. This threshold can be changed as is just a value

@Mshehu5 Mshehu5 force-pushed the lock branch 2 times, most recently from 425cef2 to bb8c115 Compare March 1, 2026 14:14
Copy link
Copy Markdown
Contributor

@0xZaddyy 0xZaddyy left a comment

Choose a reason for hiding this comment

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

Good implementation @Mshehu5 :)

fn unspent_coins(&self) -> impl Iterator<Item = OutputHandle<'a>> + '_ {
self.potentially_spendable_txos().filter(|o| {
!self.info().unconfirmed_spends.contains(&o.outpoint())
// TODO Startegies should inform which inputs can be spendable.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I realize this may have been misleading. Wallets should mark utxos as locked as secondary information (WalletInfo). Perhaps a better name for it could be: "used_utxos". This information should not live in the strategy struct

src/wallet.rs Outdated
Comment on lines +173 to +179
fn is_payment_due_soon(&self, po_id: &PaymentObligationId) -> bool {
let po = po_id.with(self.sim).data();
let time_to_deadline = po.deadline.0.saturating_sub(self.sim.current_timestep.0);
let anxiety_window =
(po.deadline.0.saturating_sub(po.reveal_time.0) / PAYMENT_ANXIETY_THRESHOLD).max(1);
time_to_deadline <= anxiety_window
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This should be implemented in action.rs and should live with the rest of the scoring logic.
The way I think this should go

  • Wallets should score handling an action with every utxo they have (even ones used in other payments that are not confirmed)
  • If I use a UTXO that is locked but it means I handle a "more valuable" payment I should prefer to do that. The two costs should be evaluated against each other.

For example, Alice has only one UTXO and needs to pay Bob but there is ample time so she starts a Payjoin. UTXO a is locked in a payjoin that Alice started. She is waiting for Bob to respond to her. After a couple timesteps a higher priority tx shows up and Alice would prefer to spend the UTXO on that payment obligation. The cost function should inform the wallet that abandoning the payjoin is more valuable.

And this "every utxo" scaffolding will set us up nicely for subset sum portion of the cost function.

@arminsabouri
Copy link
Copy Markdown
Collaborator

Heads up @Mshehu5 CI is failing

Copy link
Copy Markdown
Collaborator

@arminsabouri arminsabouri left a comment

Choose a reason for hiding this comment

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

AbandonedPayjoin is defintely the way to go with this! Nice work.
I just had a few bikeshed comments and a comment about how wallet info is meant to work

tx
}

fn abandon_payjoin(&mut self, payment_obligation_id: &PaymentObligationId) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe rename or add comment that this is a fallback.

src/wallet.rs Outdated
self.info_mut()
.unconfirmed_txos_in_payjoins
.insert(input.outpoint, *bulletin_board_id);
self.info_mut().used_utxos.insert(input.outpoint);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the "used" terminology implies they were used and spent. Perhaps better name would be in_use ?

src/actions.rs Outdated
Comment on lines +206 to +214
// Same urgency curve as PaymentObligationHandledOutcome:
let points = [
(0.0, 2.0 * payment_obligation_utility_factor),
(2.0, payment_obligation_utility_factor),
(5.0, 0.0),
];
let utility = piecewise_linear(self.time_left as f64, &points);
let score = self.amount_freed * utility;
debug!("AbandonPayjoinOutcome score: {:?}", score);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This makes sense as an outcome but can we just call the unilateral payment obligation score method instead of dup code?

src/actions.rs Outdated
#[derive(Debug)]
pub(crate) struct AbandonPayjoinOutcome {
time_left: i32,
amount_freed: f64,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this be amount handled?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Or is it something else?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

currently changed it to unblocked_payment_amount

but maybe realesed_payment_amount or payment_amount might be better

Comment on lines +286 to +292
// Remove from initiated or received payjoins map
self.info_mut()
.initiated_payjoins
.remove(payment_obligation_id);
self.info_mut()
.received_payjoins
.remove(payment_obligation_id);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Perhaps a TODO or somethign to address in this PR.
info is a vec. The latest info is mean to capture the latest state. Its a vec so we can analyze the transcript of the simulation as it runs. So perhaps we can create a append a new wallet info and update the latest info index in wallet data.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This comment still seems unaddressed

@arminsabouri arminsabouri marked this pull request as draft March 24, 2026 23:49
@arminsabouri
Copy link
Copy Markdown
Collaborator

feel free to request review whenever this pr is ready

@Mshehu5 Mshehu5 force-pushed the lock branch 5 times, most recently from 255b8f9 to 3167196 Compare March 29, 2026 15:43
Comment on lines +217 to +226
fn cost(&self, payment_obligation_weight: f64) -> ActionCost {
let points = [
(0.0, 2.0 * payment_obligation_weight),
(2.0, payment_obligation_weight),
(5.0, 0.0),
];
let utility = piecewise_linear(self.time_left as f64, &points);
let cost = -(self.unblocked_payment_amount * utility);
debug!("AbandonPayjoinOutcome cost: {:?}", cost);
ActionCost(cost)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not sure if this is the best way to implement this should amount be a factor ? or should it just be time left ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hm this is tricky. Whatever we end up choosing here we will use in our multiparty version so this is important to nail down. The cost seems like its two fold (for the sender)

  1. The cost of missing a payment obligation which you abstracted away in payment_obligation_urgency_score.
  2. The cost of losing some privacy. Which we can ignore for now.

if you satisfy our other payment obligation using the existing UTXOs AND the payment obligatoin is not expiring then there should be no strong reason to fallback.

@Mshehu5 Mshehu5 marked this pull request as ready for review March 30, 2026 06:40
@Mshehu5 Mshehu5 requested a review from arminsabouri March 30, 2026 06:40
Copy link
Copy Markdown
Collaborator

@arminsabouri arminsabouri left a comment

Choose a reason for hiding this comment

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

The scope of this PR is high. Its trying to do alot of things at once. And the scope is only increasing.
I would suggest we tighten and make a smaller PR for just the two party payjoin case.
Concretly if a sender's payment obligatoin is still not handled and its getting close to the deadline we fallback -- just broadcast the unilateral tx. No need to manage internal wallet info state or mark utxo as locked.

}
}

/// Score of abandoning a pending payjoin.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// Score of abandoning a pending payjoin.
/// Score of abandoning a pending payjoin.
/// And using the inputs in this payjoin as inputs to a seperate tx

/// Set of multi-party payjoin sessions that this wallet is participating in
pub(crate) active_multi_party_payjoins: HashMap<BulletinBoardId, MultiPartyPayjoinSession>,
/// UTXOs currently in use by interactive protocols (payjoins, multi-party sessions).
pub(crate) in_use_utxos: OrdSet<Outpoint>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this not the same thing as the keys of L54?

Comment on lines +181 to +183
let next_info = self.info().clone();
let next_info_id = WalletInfoId(self.sim.wallet_info.len());
self.sim.wallet_info.push(next_info);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is the prev and next wallet info not the same thing? Its just cloning the current wallet info. I think you want to pass in a new wallet info.

Comment on lines +217 to +226
fn cost(&self, payment_obligation_weight: f64) -> ActionCost {
let points = [
(0.0, 2.0 * payment_obligation_weight),
(2.0, payment_obligation_weight),
(5.0, 0.0),
];
let utility = piecewise_linear(self.time_left as f64, &points);
let cost = -(self.unblocked_payment_amount * utility);
debug!("AbandonPayjoinOutcome cost: {:?}", cost);
ActionCost(cost)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hm this is tricky. Whatever we end up choosing here we will use in our multiparty version so this is important to nail down. The cost seems like its two fold (for the sender)

  1. The cost of missing a payment obligation which you abstracted away in payment_obligation_urgency_score.
  2. The cost of losing some privacy. Which we can ignore for now.

if you satisfy our other payment obligation using the existing UTXOs AND the payment obligatoin is not expiring then there should be no strong reason to fallback.

Comment on lines +302 to +303
// Clear in_use_utxos so scoring evaluates all UTXOs, including ones
// committed to pending interactive protocols.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think we want to clear these out. because it will be ignoring the cost of falling back on a payjoin

Comment on lines +286 to +292
// Remove from initiated or received payjoins map
self.info_mut()
.initiated_payjoins
.remove(payment_obligation_id);
self.info_mut()
.received_payjoins
.remove(payment_obligation_id);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This comment still seems unaddressed

self.participate_in_multi_party_payjoin(bulletin_board_id);
}
Action::AbandonPayjoin(po_id) => {
self.abandon_payjoin(po_id);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems like this should be fallback not abandon and nothign happens.
The sender still should send the payment. Unless there is a more pressing payment that needs that UTXO but that doesnt seem like its being considered here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants