Skip to content
Open
Changes from 1 commit
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
123 changes: 119 additions & 4 deletions rust/src/manager/wallet_manager/payjoin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ use payjoin::{
},
};
use rand::seq::SliceRandom as _;
use redb::TableDefinition;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tracing::{debug, error, warn};

use crate::database::key::OutPointKey;
use crate::database::wallet_data::{
PayjoinSenderSession, PendingAction, WalletDataDb, WalletDataError,
};
Expand All @@ -36,6 +38,9 @@ const PAYJOIN_SESSION_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const OHTTP_RELAYS: [&str; 3] =
["https://relay.payjoin.org", "https://ohttp.achow101.com", "https://pj.bobspacebkk.com"];

const INPUTS_OFFERED_TABLE: TableDefinition<OutPointKey, u64> =
TableDefinition::new("payjoin_inputs_offered");

/// Persists payjoin session events to the wallet's database so sessions survive app restarts.
///
/// `lock` serialises concurrent calls within a single actor lifetime — the `PayjoinActor`
Expand Down Expand Up @@ -162,6 +167,63 @@ impl PayjoinSessionPersister {
}
}
}

/// Returns Err if any outpoint was already offered in a prior payjoin session
pub(crate) fn check_no_inputs_seen_before(&self, inputs: &[bitcoin::OutPoint]) -> Result<()> {
let read_txn =
self.db.db.begin_read().map_err(|e| eyre::eyre!("failed to begin read: {e:?}"))?;

let table = match read_txn.open_table(INPUTS_OFFERED_TABLE) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
Err(e) => return Err(eyre::eyre!("failed to open inputs_offered table: {e:?}")),
};

for outpoint in inputs {
let key = OutPointKey::from(outpoint);
if table
.get(&key)
.map_err(|e| eyre::eyre!("failed to read inputs_offered: {e:?}"))?
.is_some()
{
return Err(eyre::eyre!(
"input {}:{} was already offered in a prior payjoin session",
outpoint.txid,
outpoint.vout
));
}
}

Ok(())
}

/// Records outpoints as offered so they are not reused in future payjoin sessions
///
/// Inputs are never cleaned up, even after fallback or cancellation, because a
/// malicious receiver has already seen them and could probe again on retry
pub(crate) fn record_offered_inputs(&self, inputs: &[bitcoin::OutPoint]) -> Result<()> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);

let write_txn =
self.db.db.begin_write().map_err(|e| eyre::eyre!("failed to begin write: {e:?}"))?;

{
let mut table = write_txn
.open_table(INPUTS_OFFERED_TABLE)
.map_err(|e| eyre::eyre!("failed to open inputs_offered table: {e:?}"))?;

for outpoint in inputs {
let key = OutPointKey::from(outpoint);
table
.insert(&key, now)
.map_err(|e| eyre::eyre!("failed to insert offered input: {e:?}"))?;
}
}

write_txn.commit().map_err(|e| eyre::eyre!("failed to commit offered inputs: {e:?}"))?;

Ok(())
}
}

impl SessionPersister for PayjoinSessionPersister {
Expand Down Expand Up @@ -304,9 +366,13 @@ pub(crate) fn build_sender(
network: bitcoin::Network,
persister: &PayjoinSessionPersister,
) -> Result<V2Sender<WithReplyKey>> {
// TODO: anti-probing (inputs_seen), verify our inputs have not appeared in a prior session
// TODO: surface payjoin downgrade to the user when the fallback tx is broadcast instead of the proposal

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@Sandipmandal25 we still have to do this TODO?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this todo is about surfacing payjoin downgrade to the user when the fallback tx broadcasts instead of the proposal. its a cross layer change not related to anti probing i will do this in follow up was waiting for this pr to merge.


// reject inputs that were already offered to a receiver in a prior session
let input_outpoints: Vec<bitcoin::OutPoint> =
signed_psbt.unsigned_tx.input.iter().map(|i| i.previous_output).collect();
persister.check_no_inputs_seen_before(&input_outpoints)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// SenderBuilder::new (v2) panics on non-OHTTP URIs; '#' in the endpoint signals v2
if !endpoint.contains('#') {
return Err(eyre::eyre!("not a BIP77 v2 endpoint; v1 not supported"));
Expand Down Expand Up @@ -349,10 +415,17 @@ pub(crate) fn build_sender(
})
.unwrap_or(BdkFeeRate::BROADCAST_MIN);

let sender = SenderBuilder::new(signed_psbt, pj_uri)
let send_session = SenderBuilder::new(signed_psbt, pj_uri)
.always_disable_output_substitution()
.build_recommended(fee_rate)
.map_err(|e| eyre::eyre!("failed to build payjoin sender: {e:?}"))?
.map_err(|e| eyre::eyre!("failed to build payjoin sender: {e:?}"))?;

// record after sender construction so a build failure does not burn inputs,
// but before .save() so a save failure never creates a session with
// unrecorded inputs; burned inputs on a save failure are harmless
persister.record_offered_inputs(&input_outpoints)?;

let sender = send_session
.save(persister)
.map_err(|e| eyre::eyre!("failed to persist payjoin session: {e:?}"))?;

Expand Down Expand Up @@ -862,14 +935,19 @@ mod tests {
use super::*;
use crate::{database::wallet_data::test_support, wallet::metadata::WalletId};
use bitcoin::{
ScriptBuf, Transaction, TxOut, psbt::Output as PsbtOutput, transaction::Version,
ScriptBuf, Transaction, TxOut, hashes::Hash as _, psbt::Output as PsbtOutput,
transaction::Version,
};

fn new_test_persister() -> (PayjoinSessionPersister, WalletDataDb, tempfile::TempDir) {
let (db, tmp) = test_support::new_test_wallet_data_db(WalletId::preview_new_random());
(PayjoinSessionPersister::new(db.clone()), db, tmp)
}

fn test_outpoint(txid_byte: u8, vout: u32) -> bitcoin::OutPoint {
bitcoin::OutPoint { txid: bitcoin::Txid::from_byte_array([txid_byte; 32]), vout }
}

fn test_fallback_tx() -> BdkTransaction {
BdkTransaction {
version: bitcoin::transaction::Version::TWO,
Expand Down Expand Up @@ -1235,4 +1313,41 @@ mod tests {
"record must be retained when broadcast outcome is unknown"
);
}

#[test]
fn inputs_offered_check_passes_for_fresh_inputs() {
let (persister, _db, _tmp) = new_test_persister();
let inputs = vec![test_outpoint(0xaa, 0), test_outpoint(0xbb, 1)];

let result = persister.check_no_inputs_seen_before(&inputs);
assert!(result.is_ok());
}

#[test]
fn inputs_offered_check_rejects_previously_seen_input() {
let (persister, _db, _tmp) = new_test_persister();
let inputs = vec![test_outpoint(0xaa, 0), test_outpoint(0xbb, 1)];

persister.record_offered_inputs(&inputs).expect("record succeeds");

let fresh = vec![test_outpoint(0xcc, 0)];
assert!(persister.check_no_inputs_seen_before(&fresh).is_ok());

let reused = vec![test_outpoint(0xcc, 0), test_outpoint(0xaa, 0)];
assert!(persister.check_no_inputs_seen_before(&reused).is_err());
}

#[test]
fn record_offered_inputs_persists_across_sessions() {
let (persister, db, _tmp) = new_test_persister();
let inputs = vec![test_outpoint(0xdd, 2)];

persister.record_offered_inputs(&inputs).expect("record succeeds");

let new_persister = PayjoinSessionPersister::new(db);
assert!(new_persister.check_no_inputs_seen_before(&inputs).is_err());

let fresh = vec![test_outpoint(0xee, 0)];
assert!(new_persister.check_no_inputs_seen_before(&fresh).is_ok());
}
}
Loading