Skip to content
Draft
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
126 changes: 101 additions & 25 deletions payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ use payjoin::bitcoin::consensus::encode::serialize_hex;
use payjoin::bitcoin::{Amount, FeeRate};
use payjoin::persist::{OptionalTransitionOutcome, SessionPersister};
use payjoin::receive::v2::{
replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized,
MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal,
replay_event_log as replay_receiver_event_log, ChainView, HasReplyableError, Initialized,
MaybeInputsOwned, MaybeInputsSeen, Monitor, OutpointSpend, OutputsUnknown, PayjoinProposal,
PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver,
ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload,
WantsFeeRange, WantsInputs, WantsOutputs,
ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, SettlementOutcome, SettlementStatus,
Spend, UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs,
};
use payjoin::send::v2::{
replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback,
Expand Down Expand Up @@ -82,10 +82,13 @@ impl StatusText for ReceiveSession {
ReceiveSession::PendingFallback(_) => "Pending fallback handling",
ReceiveSession::Closed(session_outcome) => match session_outcome {
ReceiverSessionOutcome::Aborted => "Session aborted",
ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted",
ReceiverSessionOutcome::Success =>
"Session success, Payjoin settled cooperatively",
ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted",
ReceiverSessionOutcome::PayjoinProposalSent =>
"Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs",
ReceiverSessionOutcome::Other(_) => "Settled by an unrecognized transaction",
_ => "Session closed",
},
}
}
Expand Down Expand Up @@ -437,7 +440,7 @@ impl AppTrait for App {
let row = SessionHistoryRow {
session_id,
role: Role::Receiver,
status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted),
status: ReceiveSession::Closed(ReceiverSessionOutcome::aborted()),
completed_at: None,
error_message: Some(e.to_string()),
};
Expand Down Expand Up @@ -492,7 +495,7 @@ impl AppTrait for App {
let row = SessionHistoryRow {
session_id,
role: Role::Receiver,
status: ReceiveSession::Closed(ReceiverSessionOutcome::Aborted),
status: ReceiveSession::Closed(ReceiverSessionOutcome::aborted()),
completed_at: Some(completed_at),
error_message: Some(e.to_string()),
};
Expand Down Expand Up @@ -619,7 +622,7 @@ impl App {
},
ReceiveSession::PendingFallback(receiver) => receiver,
ReceiveSession::Closed(
ReceiverSessionOutcome::Success(_)
ReceiverSessionOutcome::Success
| ReceiverSessionOutcome::FallbackBroadcasted
| ReceiverSessionOutcome::PayjoinProposalSent,
) => {
Expand All @@ -638,6 +641,10 @@ impl App {
}
return Ok(());
}
ReceiveSession::Closed(_) => {
println!("Session {session_id} is already closed. Cannot cancel.");
return Ok(());
}
};

if no_broadcast {
Expand Down Expand Up @@ -967,47 +974,116 @@ impl App {
return self.monitor_payjoin_proposal(session, persister).await;
}

/// Build a [`ChainView`] for the monitored session by querying the wallet.
///
/// This demo wallet can only look up the two transactions whose ids the receiver predicts (the
/// Payjoin proposal and the fallback), so it cannot attribute a spend to an unrelated third
/// transaction. A wallet with a full transaction monitor would instead report the actual
/// spender of each contested outpoint, which is what surfaces [`SettlementOutcome::Other`] and
/// an output-substitution [`SettlementOutcome::Cooperative`] whose txid the receiver cannot
/// predict.
fn chain_view(&self, proposal: &Receiver<Monitor>) -> Result<ChainView> {
let wallet = self.wallet();
let payjoin_txid = proposal.payjoin_txid();
let payjoin = wallet.get_raw_transaction_verbose(&payjoin_txid)?;
let fallback = match proposal.fallback_txid() {
Some(txid) => wallet.get_raw_transaction_verbose(&txid)?,
None => None,
};

let mut spends = Vec::new();
for outpoint in proposal.contested_outpoints() {
if let Some((tx, confirmations)) = &payjoin {
if tx.input.iter().any(|txin| txin.previous_output == outpoint) {
spends.push(OutpointSpend {
outpoint,
spend: Some(Spend { tx: tx.clone(), confirmations: *confirmations }),
});
continue;
}
}
if let Some((tx, confirmations)) = &fallback {
if tx.input.iter().any(|txin| txin.previous_output == outpoint) {
spends.push(OutpointSpend {
outpoint,
spend: Some(Spend { tx: tx.clone(), confirmations: *confirmations }),
});
continue;
}
}
}
Ok(ChainView { spends })
}

// The confidence bar below is configurable; with the default of 0 the `>=` comparison is
// trivially true, but it is meaningful once a maintainer raises the bar to require depth.
#[allow(clippy::absurd_extreme_comparisons)]
async fn monitor_payjoin_proposal(
&self,
proposal: Receiver<Monitor>,
persister: &ReceiverPersister,
) -> Result<()> {
// Confidence bar: conclude once the detected settlement has at least this many
// confirmations. 0 concludes as soon as the transaction is seen (mempool) -- fast, but a
// reorg or the conflicting double-spend can still flip it. Raise it to require depth.
const CONFIRMATION_BAR: u32 = 0;

// On a session resumption, the receiver will resume again in this state.
let poll_interval = tokio::time::Duration::from_millis(200);
let timeout_duration = tokio::time::Duration::from_secs(5);

let mut interval = tokio::time::interval(poll_interval);
interval.tick().await;

tracing::debug!("Polling for payment confirmation");
tracing::debug!("Polling for settlement");

let result = tokio::time::timeout(timeout_duration, async {
loop {
interval.tick().await;
let check_result = proposal
.check_for_transaction(|txid| {
self.wallet()
.get_raw_transaction(&txid)
.map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
})
.save(persister);

match check_result {
Ok(OptionalTransitionOutcome::Progress(())) => {
println!("Payjoin transaction detected in the mempool!");
return Ok(());
let view = match self.chain_view(&proposal) {
Ok(view) => view,
Err(e) => {
tracing::warn!("Chain query failed, retrying: {e}");
continue;
}
};

match proposal.classify(&view) {
SettlementStatus::Pending => {
println!("Pending: no contested outpoint spent yet");
continue;
}
SettlementStatus::Detected { outcome, confirmations } => {
match &outcome {
SettlementOutcome::Cooperative =>
println!("Payjoin settled ({confirmations} conf)"),
SettlementOutcome::Fallback => println!(
"Fell back to the original transaction ({confirmations} conf)"
),
SettlementOutcome::Other(txid) => println!(
"Spent by another transaction {txid} ({confirmations} conf)"
),
// `SettlementOutcome` is #[non_exhaustive]; render any finer label this
// build predates without losing the confirmation count.
_ => println!("Settled ({confirmations} conf)"),
}
if confirmations >= CONFIRMATION_BAR {
return outcome;
}
}
Ok(OptionalTransitionOutcome::Stasis(_)) => continue,
Err(_) => continue,
}
}
})
.await;

match result {
Ok(ok) => ok,
Ok(outcome) => {
proposal.conclude(outcome).save(persister)?;
println!("Session concluded.");
Ok(())
}
Err(_) => Err(anyhow!(
"No payjoin transaction detected in mempool within {timeout_duration:?}, stopping."
"No settlement reached the confidence bar within {timeout_duration:?}, stopping."
)),
}
}
Expand Down
19 changes: 12 additions & 7 deletions payjoin-cli/src/app/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,20 @@ impl BitcoindWallet {
}
}

/// Look up a wallet transaction and its confirmation depth.
///
/// Uses the wallet `gettransaction` RPC so it can find a confirmed transaction by txid
/// without `-txindex`, unlike the node `getrawtransaction`. Returns `None` if the
/// transaction is unknown to the wallet, and `Some((tx, confirmations))` otherwise, where
/// `confirmations == 0` means the transaction is only in the mempool. A negative
/// `confirmations` (the wallet reports `-1` for a conflicted/replaced transaction) is
/// clamped to `0`: a conflicted transaction has not settled.
#[cfg(feature = "v2")]
pub fn get_raw_transaction(
&self,
txid: &Txid,
) -> Result<Option<payjoin::bitcoin::Transaction>> {
let raw_tx = tokio::task::block_in_place(|| {
pub fn get_raw_transaction_verbose(&self, txid: &Txid) -> Result<Option<(Transaction, u32)>> {
let wallet_tx = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
match self.rpc.get_transaction(txid).await {
Ok(rpc_res) => Ok(Some(rpc_res.tx)),
Ok(res) => Ok(Some(res)),
Err(e) =>
if e.is_tx_not_found() {
Ok(None)
Expand All @@ -156,7 +161,7 @@ impl BitcoindWallet {
}
})
})?;
Ok(raw_tx)
Ok(wallet_tx.map(|res| (res.tx, res.confirmations.max(0) as u32)))
}

/// Get a new address from the wallet
Expand Down
95 changes: 95 additions & 0 deletions payjoin-ffi/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,78 @@ fn try_deserialize_tx(
.map_err(|e| ForeignError::InternalError(e.to_string()))
}

/// A spend of a single contested outpoint, as reported by a [`ChainBackend`].
///
/// Returned only for a spent outpoint; an unspent outpoint is reported as `None` from
/// [`ChainBackend::spend_status`]. The spending transaction is mandatory: it is required to classify
/// a Payjoin structurally, and the spending txid is derived from it.
#[derive(Clone, uniffi::Record)]
pub struct ChainSpend {
/// Consensus-encoded spending transaction. Required to classify a Payjoin structurally.
pub spending_tx: Vec<u8>,
/// Confirmations of the spend. `0` means mempool-only.
pub confirmations: u32,
}

/// A wallet-side chain oracle for settlement attribution.
///
/// Implement this to report which transaction (if any) spent each contested outpoint, at what
/// depth. It is the outpoint-based successor to the txid-only [`TransactionFinder`], driving the
/// richer [`Monitor::classify`] flow.
#[uniffi::export(with_foreign)]
pub trait ChainBackend: Send + Sync {
/// Return the spend status of `outpoint`, or `None` if it is unspent / unknown.
fn spend_status(&self, outpoint: OutPoint) -> Result<Option<ChainSpend>, ForeignError>;
}

/// Foreign-facing mirror of [`payjoin::receive::v2::SettlementOutcome`].
#[derive(Clone, uniffi::Enum)]
pub enum SettlementOutcome {
/// The Payjoin settled cooperatively: the spending transaction either contains the receiver's
/// contributed input or reproduces the proposal's output set (a cut-through). The caller already
/// holds the spending transaction it reported through its [`ChainBackend`].
Cooperative,
/// The fallback (original) transaction settled; no privacy gain.
Fallback,
/// An unrecognized transaction settled the contested outpoints.
Other { txid: String },
}

impl From<payjoin::receive::v2::SettlementOutcome> for SettlementOutcome {
fn from(value: payjoin::receive::v2::SettlementOutcome) -> Self {
use payjoin::receive::v2::SettlementOutcome as Core;
match value {
Core::Cooperative => SettlementOutcome::Cooperative,
Core::Fallback => SettlementOutcome::Fallback,
Core::Other(txid) => SettlementOutcome::Other { txid: txid.to_string() },
// `SettlementOutcome` is #[non_exhaustive]; a finer label this binding predates
// surfaces as `Other` with an empty txid until the binding is regenerated.
_ => SettlementOutcome::Other { txid: String::new() },
}
}
}

/// Foreign-facing mirror of [`payjoin::receive::v2::SettlementStatus`].
#[derive(Clone, uniffi::Enum)]
pub enum SettlementStatus {
/// None of the contested outpoints are spent yet.
Pending,
/// A transaction spending the contested outpoints was observed. `confirmations == 0` is
/// mempool-only.
Detected { outcome: SettlementOutcome, confirmations: u32 },
}

impl From<payjoin::receive::v2::SettlementStatus> for SettlementStatus {
fn from(value: payjoin::receive::v2::SettlementStatus) -> Self {
use payjoin::receive::v2::SettlementStatus as Core;
match value {
Core::Pending => SettlementStatus::Pending,
Core::Detected { outcome, confirmations } =>
SettlementStatus::Detected { outcome: outcome.into(), confirmations },
}
}
}

#[uniffi::export]
impl Monitor {
pub fn check_for_transaction(
Expand All @@ -1518,6 +1590,29 @@ impl Monitor {
},
)))))
}

/// Classify the current settlement state by querying `chain_backend` for the spend status of
/// each contested outpoint. This is the foreign-facing wrapper over the pure
/// [`payjoin::receive::v2::Receiver::classify`]: it performs the outpoint probes, then
/// classifies the resulting snapshot. It does not persist anything.
pub fn classify(
&self,
chain_backend: Arc<dyn ChainBackend>,
) -> Result<SettlementStatus, ForeignError> {
let mut spends = Vec::new();
for outpoint in self.0.contested_outpoints() {
let Some(spend) = chain_backend.spend_status(OutPoint::from(outpoint))? else {
continue;
};
let tx = try_deserialize_tx(spend.spending_tx)?;
spends.push(payjoin::receive::v2::OutpointSpend {
outpoint,
spend: Some(payjoin::receive::v2::Spend { tx, confirmations: spend.confirmations }),
});
}
let view = payjoin::receive::v2::ChainView { spends };
Ok(self.0.classify(&view).into())
}
}

#[derive(uniffi::Object)]
Expand Down
Loading
Loading