diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a97859a18..86d721eab 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -120,7 +120,7 @@ If the change has no user-visible effect (refactoring, tests, CI, comments), say Mark a deprecation with the version placeholder instead of guessing the next release: ```rust -#[deprecated(since = "0.0.0-unreleased", note = "use `Receiver::classify`")] +#[deprecated(since = "0.0.0-unreleased", note = "use `Receiver::classify_tx`")] ``` `contrib/release/bump.sh` replaces it with the crate's version in the release PR, and the release checks fail if one is left. rustc warns callers about it like any other deprecation. Don't use `TBD`, which rustc treats as a future deprecation and does not warn about. diff --git a/payjoin-cli/CHANGELOG.md b/payjoin-cli/CHANGELOG.md index be97ca13f..8bba03b1b 100644 --- a/payjoin-cli/CHANGELOG.md +++ b/payjoin-cli/CHANGELOG.md @@ -5,6 +5,12 @@ - Fix panic when spending taproot UTXOs. They are now contributed as key-path spends with an assumed default input weight. - Any unsupported coin is simply skipped instead of crashing the cli. +- `resume` checks the wallet once for transactions settling each open receiver + session, in any state, and closes a session once its settling transaction + has one confirmation. It reports whether the Payjoin, the fallback, or + another transaction settled it. Sessions with non-SegWit senders are now + tracked too. The receiver no longer polls the mempool after posting the + proposal. ## 1.0.0 diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index a35391de4..ab9b6317d 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -27,7 +27,7 @@ v2 = ["payjoin/v2", "payjoin/io"] ahash = "0.7.8" anyhow = "1.0.103" async-trait = "0.1.89" -bitcoind-async-client = "0.16.1" +bitcoind-async-client = { version = "0.16.1", features = ["raw_rpc"] } clap = { version = "4.5.45", features = ["derive"] } config = "0.15.17" dirs = "6.0.0" diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 924129b13..f6ba7444a 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use anyhow::{anyhow, Context, Result}; use payjoin::bitcoin::consensus::encode::serialize_hex; -use payjoin::bitcoin::{Amount, FeeRate, Transaction}; +use payjoin::bitcoin::{Address, Amount, FeeRate, Transaction, Txid}; use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; use payjoin::receive::mark_checklist; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, HasReplyableError, Initialized, - MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, PayjoinProposal, + MaybeInputsOwned, MaybeInputsSeen, OutputsUnknown, PayjoinProposal, PendingFallback as ReceiverPendingFallback, ProvisionalProposal, ReceiveSession, Receiver, - ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload, - WantsFeeRange, WantsInputs, WantsOutputs, + ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, Settlement, SettlementOutcome, + UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs, }; use payjoin::send::v2::{ replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback, @@ -40,6 +40,27 @@ const W_STATUS: usize = 15; /// misbehaving directory or relay is not hammered in a tight loop. const TRANSIENT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5); +/// Confirmations required before a receiver session is closed. +const SETTLEMENT_CONFIRMATIONS: i64 = 1; + +/// A wallet transaction that settles a receiver session. +struct FoundSettlement { + settlement: Settlement, + txid: Txid, + /// Negative for a transaction that conflicts with a confirmed one. + confirmations: i64, +} + +/// The result of checking the wallet for a receiver session's settlement. +enum SettlementCheck { + /// No wallet transaction settles the session. + Unsettled, + /// A settling transaction has fewer than `SETTLEMENT_CONFIRMATIONS`. + Confirming, + /// The session was closed. + Closed, +} + /// A request-construction error that can report whether it was caused by /// session expiry. Implemented by the sender/receiver request-building error /// types so `post_via_relay` can hand expiry back to the caller (which owns the @@ -106,16 +127,16 @@ impl StatusText for ReceiveSession { ReceiveSession::PayjoinProposal(_) => "Payjoin proposal sent", ReceiveSession::HasReplyableError(_) => "Session failure, waiting to post error response", - ReceiveSession::Monitor(_) => "Monitoring payjoin proposal", + ReceiveSession::Monitor(_) => "Payjoin proposal sent, awaiting settlement", 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 proposal was broadcasted", ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted", ReceiverSessionOutcome::PayjoinProposalSent => - "Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs", - ReceiverSessionOutcome::Unrecognized(_) => - "Settled by an unrecognized transaction", + "Payjoin proposal sent, settlement no longer tracked", + ReceiverSessionOutcome::Unrecognized(_) => "Settled by an unrecognized transaction", }, } } @@ -367,7 +388,9 @@ impl AppTrait for App { res = self.process_receiver_session( ReceiveSession::Initialized(session.clone()), &persister, - ) => res?, + ) => { + res?; + } _ = interrupt.changed() => { let session_id = persister.session_id(); persister.print(format_args!( @@ -396,19 +419,21 @@ impl AppTrait for App { return Ok(()); } - let mut tasks: Vec<((Role, SessionId), tokio::task::JoinHandle>)> = Vec::new(); + // Each task returns whether its session finished. + let mut tasks: Vec<((Role, SessionId), tokio::task::JoinHandle>)> = Vec::new(); // Process receiver sessions for session_id in recv_session_ids { let self_clone = self.clone(); let recv_persister = ReceiverPersister::from_id(self.db.clone(), session_id.clone()); match replay_receiver_event_log(&recv_persister) { - Ok((receiver_state, _)) => { + Ok((receiver_state, history)) => { + let address = history.pj_uri().address().clone(); tasks.push(( (Role::Receiver, session_id), tokio::spawn(async move { self_clone - .process_receiver_session(receiver_state, &recv_persister) + .resume_receiver_session(receiver_state, &address, &recv_persister) .await }), )); @@ -437,7 +462,10 @@ impl AppTrait for App { tasks.push(( (Role::Sender, session_id), tokio::spawn(async move { - self_clone.process_sender_session(sender_state, &sender_persister).await + self_clone + .process_sender_session(sender_state, &sender_persister) + .await + .map(|()| true) }), )); } @@ -462,9 +490,11 @@ impl AppTrait for App { for ((role, id), task) in tasks { match task.await { - Ok(Ok(())) => { + Ok(Ok(true)) => { print_session(role, &id, "Session completed."); } + // The session already printed what it awaits. + Ok(Ok(false)) => {} Ok(Err(e)) => { print_session(role, &id, format_args!("Session error: {e:#}")); } @@ -931,15 +961,17 @@ impl App { } } - /// Drive the receiver state machine until the session terminates. Each - /// step function performs one state transition and returns the next - /// state; the driver stops on the terminal `Closed` and - /// `PendingFallback` states. + /// Drive the receiver state machine until the session terminates or awaits + /// settlement. Each step function performs one state transition and + /// returns the next state; the driver stops on the terminal `Closed` and + /// `PendingFallback` states, and on `Monitor`. + /// + /// Returns `false` if the session stopped on `Monitor`. async fn process_receiver_session( &self, mut session: ReceiveSession, persister: &ReceiverPersister, - ) -> Result<()> { + ) -> Result { loop { session = match session { ReceiveSession::Initialized(proposal) => @@ -964,18 +996,20 @@ impl App { self.send_payjoin_proposal(proposal, persister).await?, ReceiveSession::HasReplyableError(error) => self.handle_error(error, persister).await?, - ReceiveSession::Monitor(proposal) => { - self.monitor_payjoin_proposal(proposal, persister).await?; - return Ok(()); + ReceiveSession::Monitor(_) => { + persister.print( + "Awaiting settlement. Run `payjoin-cli resume` to check the wallet again.", + ); + return Ok(false); } ReceiveSession::PendingFallback(_) => { let id = persister.session_id(); persister.print(format_args!( "Session was cancelled. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction." )); - return Ok(()); + return Ok(true); } - ReceiveSession::Closed(_) => return Ok(()), + ReceiveSession::Closed(_) => return Ok(true), }; } } @@ -1146,13 +1180,9 @@ impl App { return Ok(ReceiveSession::Closed(ReceiverSessionOutcome::Aborted)); } }; - let payjoin_psbt = proposal.psbt().clone(); match proposal.process_response(&res.bytes().await?, ohttp_ctx).save(persister) { Ok(session) => { - persister.print(format_args!( - "Response successful. Watch mempool for successful Payjoin. TXID: {}", - payjoin_psbt.extract_tx_unchecked_fee_rate().compute_txid() - )); + persister.print("Response successful."); Ok(ReceiveSession::Monitor(session)) } Err(e) if e.is_transient() => { @@ -1165,61 +1195,79 @@ impl App { } } - /// Watch the mempool for the payjoin transaction until it appears or a - /// timeout elapses. The poll/timeout loop is one logical step from the - /// session's perspective, so it stays local rather than in the driver. - async fn monitor_payjoin_proposal( + /// Check the wallet for the session's settlement, then drive the session + /// if nothing settled it. + /// + /// Returns whether the session finished. + async fn resume_receiver_session( &self, - mut proposal: Receiver, + session: ReceiveSession, + address: &Address, persister: &ReceiverPersister, - ) -> Result<()> { - // 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"); - - 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(())) => { - persister.print("Payjoin transaction detected in the mempool!"); - return Ok(()); - } - Ok(OptionalTransitionOutcome::Stasis(current_state)) => { - proposal = current_state; - } - Err(e) if e.is_transient() => { - tracing::debug!( - "Transient error checking for transaction, retrying: {e:?}" - ); - proposal = - e.transient_state().expect("transient error carries current state"); - } - Err(e) => return Err(e.into()), - } + ) -> Result { + match self.check_settlement(&session, address, persister)? { + SettlementCheck::Closed => Ok(true), + SettlementCheck::Confirming => Ok(false), + SettlementCheck::Unsettled => self.process_receiver_session(session, persister).await, + } + } + + /// Classify the wallet's transactions paying `address` against the + /// session, and close it once a settling transaction has + /// `SETTLEMENT_CONFIRMATIONS`. + fn check_settlement( + &self, + session: &ReceiveSession, + address: &Address, + persister: &ReceiverPersister, + ) -> Result { + let Some(FoundSettlement { settlement, txid, confirmations }) = + self.find_settlement(session, address)? + else { + return Ok(SettlementCheck::Unsettled); + }; + + let settled_by = match settlement.outcome() { + SettlementOutcome::Payjoin => format!("Payjoin {txid}"), + SettlementOutcome::Fallback => format!("Fallback transaction {txid}"), + SettlementOutcome::Unrecognized => format!("Unrecognized transaction {txid}"), + other => format!("{other:?} transaction {txid}"), + }; + if confirmations < SETTLEMENT_CONFIRMATIONS { + persister.print(format_args!( + "{settled_by} spent the sender's inputs and has {confirmations} of {SETTLEMENT_CONFIRMATIONS} confirmation(s). Run `payjoin-cli resume` again once it confirms." + )); + return Ok(SettlementCheck::Confirming); + } + + persister.print(format_args!( + "{settled_by} settled the session with {confirmations} confirmation(s)." + )); + settlement.close().save(persister)?; + Ok(SettlementCheck::Closed) + } + + /// Find the most-confirmed wallet transaction paying `address` that + /// settles the session. + /// + /// The CLI never substitutes the receiver output, so both the fallback and + /// the Payjoin pay `address`. + fn find_settlement( + &self, + session: &ReceiveSession, + address: &Address, + ) -> Result> { + let mut deepest: Option = None; + for txid in self.wallet().txids_paying(address)? { + let Some((tx, confirmations)) = self.wallet().get_wallet_transaction(&txid)? else { + continue; + }; + let Some(settlement) = session.classify_tx(&tx) else { continue }; + if deepest.as_ref().is_none_or(|deepest| confirmations > deepest.confirmations) { + deepest = Some(FoundSettlement { settlement, txid, confirmations }); } - }) - .await; - - match result { - Ok(ok) => ok, - Err(_) => Err(anyhow!( - "No payjoin transaction detected in mempool within {timeout_duration:?}, stopping." - )), } + Ok(deepest) } /// Handle error by attempting to send an error response over the directory diff --git a/payjoin-cli/src/app/wallet.rs b/payjoin-cli/src/app/wallet.rs index e74f1dfce..042c2e870 100644 --- a/payjoin-cli/src/app/wallet.rs +++ b/payjoin-cli/src/app/wallet.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +#[cfg(feature = "v2")] +use std::str::FromStr; use std::sync::Arc; use anyhow::{anyhow, Context, Result}; @@ -12,6 +14,13 @@ use payjoin::bitcoin::{ }; use payjoin::receive::InputPair; +/// One address entry from listreceivedbyaddress. +#[cfg(feature = "v2")] +#[derive(Debug, serde::Deserialize)] +struct ReceivedByAddress { + txids: Vec, +} + #[derive(Clone)] pub struct BitcoindWallet { rpc: Arc, @@ -160,25 +169,45 @@ impl BitcoindWallet { } } + /// Returns a wallet transaction and its confirmation count, which is + /// negative if it conflicts with a confirmed transaction. #[cfg(feature = "v2")] - pub fn get_raw_transaction( - &self, - txid: &Txid, - ) -> Result> { - let raw_tx = tokio::task::block_in_place(|| { + pub fn get_wallet_transaction(&self, txid: &Txid) -> Result> { + let res = 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)), - Err(e) => - if e.is_tx_not_found() { - Ok(None) - } else { - Err(e) - }, + Ok(rpc_res) => Ok(Some((rpc_res.tx, rpc_res.confirmations))), + Err(e) if e.is_tx_not_found() => Ok(None), + Err(e) => Err(e), } }) })?; - Ok(raw_tx) + Ok(res) + } + + /// Returns the txids of all wallet transactions paying `address`, + /// including unconfirmed ones. + #[cfg(feature = "v2")] + pub fn txids_paying(&self, address: &Address) -> Result> { + // bitcoind-async-client has no wrapper for listreceivedbyaddress. + // Arguments: minconf, include_empty, include_watchonly, address_filter. + let params = [ + serde_json::json!(0), + serde_json::json!(false), + serde_json::json!(true), + serde_json::json!(address.to_string()), + ]; + let received: Vec = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { self.rpc.call_raw("listreceivedbyaddress", ¶ms).await }) + }) + .context("Failed to list transactions received by address")?; + + received + .into_iter() + .flat_map(|entry| entry.txids) + .map(|txid| Txid::from_str(&txid).context("Invalid txid from listreceivedbyaddress")) + .collect() } /// Get a new address from the wallet diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index d371b71ad..74c17a352 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -131,7 +131,7 @@ pub enum Commands { #[arg(long = "expire-in")] expire_in: Option, }, - /// Resume pending payjoins (BIP77/v2 only) + /// Resume pending payjoins and close receiver sessions settled in the wallet (BIP77/v2 only) #[cfg(feature = "v2")] Resume { /// Only resume a specific session diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index 17559a82c..b8528ed68 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -751,6 +751,178 @@ mod e2e { Ok(()) } + /// A receiver session the sender settles with its fallback before the + /// receiver posts a proposal closes on `resume` once the fallback confirms. + #[cfg(feature = "v2")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn receiver_settles_fallback_before_proposal_v2( + ) -> Result<(), Box> { + use std::cell::Cell; + + use payjoin_test_utils::{init_tracing, TestServices}; + use tempfile::TempDir; + + type Result = std::result::Result; + + /// How long to wait for a payjoin-cli subprocess to print an expected line. + const STDOUT_TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(10); + + init_tracing(); + let mut services = TestServices::initialize_with_relays(3).await?; + let temp_dir = tempdir()?; + + let result = tokio::select! { + res = services.take_ohttp_relay_handle() => Err(format!("Ohttp relay is long running: {res:?}").into()), + res = services.take_directory_handle() => Err(format!("Directory server is long running: {res:?}").into()), + res = settle_fallback_cli_async(&services, &temp_dir) => res, + }; + + assert!( + result.is_ok(), + "receiver fallback settlement test failed: {:#?}", + result.unwrap_err() + ); + + /// Wait for `child` to print lines containing each of `expected`, in + /// order, then terminate it. + async fn wait_for_lines(mut child: Child, expected: &[&str]) -> Result<()> { + let mut stdout = child.stdout.take().expect("Failed to take child stdout"); + let next = Cell::new(0); + let res = tokio::time::timeout( + STDOUT_TIMEOUT, + wait_for_stdout_match(&mut stdout, |line| { + while next.get() < expected.len() && line.contains(expected[next.get()]) { + next.set(next.get() + 1); + } + next.get() == expected.len() + }), + ) + .await; + + terminate(child).await.expect("Failed to kill payjoin-cli"); + match res { + Ok(Some(_)) => Ok(()), + _ => Err(format!("payjoin-cli did not report {:?}", expected[next.get()]).into()), + } + } + + async fn settle_fallback_cli_async( + services: &TestServices, + temp_dir: &TempDir, + ) -> Result<()> { + let receiver_db_path = temp_dir.path().join("receiver_db"); + let sender_db_path = temp_dir.path().join("sender_db"); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver(None, None)?; + + // Empty the receiver wallet so its session stops at input + // contribution, before a proposal exists. + let sweep_address = sender.new_address()?; + receiver.send_all(std::slice::from_ref(&sweep_address))?; + bitcoind.client.generate_to_address(1, &sweep_address)?; + + let cert_path = &temp_dir.path().join("localhost.der"); + tokio::fs::write(cert_path, services.cert()).await?; + services.wait_for_services_ready().await?; + let ohttp_keys = services.fetch_ohttp_keys().await?; + let ohttp_keys_path = temp_dir.path().join("ohttp_keys"); + tokio::fs::write(&ohttp_keys_path, ohttp_keys.encode()?).await?; + + let receiver_rpchost = format!("http://{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("http://{}/wallet/sender", bitcoind.params.rpc_socket); + let cookie_file = &bitcoind.params.cookie_file; + let payjoin_cli = env!("CARGO_BIN_EXE_payjoin-cli"); + let directory = &services.directory_url(); + let ohttp_relays = &services.ohttp_relay_urls(); + let cli = |rpchost: &str, db_path: &std::path::Path| { + let mut cmd = Command::new(payjoin_cli); + cmd.arg("--root-certificate") + .arg(cert_path) + .arg("--rpchost") + .arg(rpchost) + .arg("--cookie-file") + .arg(cookie_file) + .arg("--db-path") + .arg(db_path) + .arg("--ohttp-relays") + .arg(ohttp_relays) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + cmd + }; + let resume_receiver = || { + cli(&receiver_rpchost, &receiver_db_path) + .arg("resume") + .spawn() + .expect("Failed to execute payjoin-cli") + }; + + // Receiver publishes a BIP21 and then goes offline. + let cli_receive_initiator = cli(&receiver_rpchost, &receiver_db_path) + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--pj-directories") + .arg(directory) + .arg("--ohttp-keys") + .arg(&ohttp_keys_path) + .spawn() + .expect("Failed to execute payjoin-cli"); + let bip21 = get_bip21_from_receiver(cli_receive_initiator).await; + + // Sender posts the original proposal and waits for a response. + let mut cli_send_initiator = cli(&sender_rpchost, &sender_db_path) + .arg("send") + .arg(&bip21) + .arg("--fee-rate") + .arg("1") + .spawn() + .expect("Failed to execute payjoin-cli"); + let mut stdout = cli_send_initiator.stdout.take().expect("Failed to take child stdout"); + let posted_line = tokio::time::timeout( + tokio::time::Duration::from_secs(35), + wait_for_stdout_match(&mut stdout, |line| line.contains("No response yet.")), + ) + .await?; + terminate(cli_send_initiator).await.expect("Failed to kill payjoin-cli"); + // Session-scoped lines are prefixed with `[ ]`. + let sender_session_id = posted_line + .expect("sender should post the original proposal") + .split_whitespace() + .nth(1) + .expect("could not split stdout") + .trim_end_matches(']') + .to_string(); + + // Receiver retrieves the original and stops for lack of inputs. + wait_for_lines(resume_receiver(), &["No spendable UTXOs available in wallet"]).await?; + + // Sender gives up and broadcasts the fallback. + let cli_cancel = cli(&sender_rpchost, &sender_db_path) + .arg("cancel") + .arg(&sender_session_id) + .spawn() + .expect("Failed to execute payjoin-cli cancel"); + wait_for_lines(cli_cancel, &["Broadcasted fallback transaction txid"]).await?; + + // An unconfirmed fallback leaves the session open. + wait_for_lines( + resume_receiver(), + &["Fallback transaction", "has 0 of 1 confirmation(s)"], + ) + .await?; + + // Once the fallback confirms, resume closes the session. + bitcoind.client.generate_to_address(1, &sweep_address)?; + wait_for_lines( + resume_receiver(), + &["settled the session with 1 confirmation(s)", "Session completed."], + ) + .await?; + wait_for_lines(resume_receiver(), &["No sessions to resume."]).await?; + Ok(()) + } + Ok(()) + } + #[cfg(all(feature = "v1", feature = "v2", feature = "_manual-tls"))] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn send_receive_payjoin_v2_to_v1() -> Result<(), Box> diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md index 578323a34..c3965431f 100644 --- a/payjoin-ffi/CHANGELOG.md +++ b/payjoin-ffi/CHANGELOG.md @@ -1,5 +1,23 @@ ## Unreleased +- Add settlement classification to every receiver state that holds a + fallback transaction, from `MaybeInputsOwned` through `Monitor`. Pass + every consensus-encoded transaction the wallet receives to `classify_tx` + on each open session, or to `classify_receiver_tx` with a session in any + state. It returns a `Settlement` whose `outcome` is a `SettlementOutcome` + of `Payjoin`, `Fallback` or `Unrecognized`, or none for a transaction that + spends none of the sender's original prevouts, and never reports `Payjoin` + before `PayjoinProposal`. `Settlement.close` ends the session once the + settlement meets the wallet's confirmation bar. In Kotlin, `close` is + `closeSession`. +- Remove `Monitor.check_for_transaction`, with its `TransactionFinder` + callback and `MonitorTransition`. It closed the session on first sight of + a predicted txid and never saw a spend that is neither the proposal nor + the fallback. Use `classify_tx` and `Settlement.close` instead. +- Remove `proposal_txid_is_stable` from every receiver state. It checked + only the sender's inputs, so a legacy or P2SH-wrapped receiver input could + change the txid even when it returned true. Use `classify_tx` instead. + ## [0.25.1] - Wraps payjoin 1.2.0 diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 2d5d8cb68..a24e2d27d 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -796,7 +796,8 @@ public async Task TestIntegrationV2ToV2(TransitionMode mode, bool taproot) cancellationToken); var proposalResponseBuffer = await proposalResponse.Content.ReadAsByteArrayAsync(cancellationToken); - payjoinProposal.ProcessResponse(proposalResponseBuffer, proposalRequest.ClientResponse); + using var monitorTransition = payjoinProposal.ProcessResponse(proposalResponseBuffer, proposalRequest.ClientResponse); + using var monitor = monitorTransition.Save(recvPersister); // ******************************* // SENDER SIDE (FINALIZATION) @@ -855,6 +856,26 @@ public async Task TestIntegrationV2ToV2(TransitionMode mode, bool taproot) var finalHex = extractionDoc.RootElement.GetProperty("hex").GetString()!; RpcCall(sender, "sendrawtransaction", JsonSerializer.Serialize(finalHex)); + // ******************************* + // Inside the Receiver: + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + var finalTx = Convert.FromHexString(finalHex); + using (var monitorSettlement = monitor.ClassifyTx(finalTx)) + { + Assert.NotNull(monitorSettlement); + Assert.Equal(SettlementOutcome.Payjoin, monitorSettlement!.Outcome()); + } + var replayed = PayjoinMethods.ReplayReceiverEventLog(recvPersister).State(); + Assert.IsType(replayed); + using var settlement = PayjoinMethods.ClassifyReceiverTx(replayed, finalTx); + Assert.NotNull(settlement); + Assert.Equal(SettlementOutcome.Payjoin, settlement!.Outcome()); + using var closeTransition = settlement.Close(); + closeTransition.Save(recvPersister); + Assert.True(recvPersister.Closed); + Assert.IsType(PayjoinMethods.ReplayReceiverEventLog(recvPersister).State()); + // ******************************* // VERIFY RESULTS // The broadcast transaction spends one input from each party into the diff --git a/payjoin-ffi/csharp/UnitTests.cs b/payjoin-ffi/csharp/UnitTests.cs index ffdd9961b..42e91875f 100644 --- a/payjoin-ffi/csharp/UnitTests.cs +++ b/payjoin-ffi/csharp/UnitTests.cs @@ -81,6 +81,29 @@ public void ReceiverPersistence() Assert.IsType(state); } + // A consensus-encoded transaction with one input and one output. It spends + // nothing a fresh session holds. + private static readonly byte[] UnrelatedTx = Convert.FromHexString( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000"); + + [Fact] + public void ClassifyReceiverTxWithoutFallback() + { + // A session that has not received the original has nothing to compare + // against, and bytes that are not a transaction are rejected whatever + // the session's state. + var persister = new InMemoryReceiverPersister(); + var ohttpKeys = OhttpKeys.Decode(OhttpKeysData); + var builder = new ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys); + builder.Build().Save(persister); + var state = PayjoinMethods.ReplayReceiverEventLog(persister).State(); + + Assert.Null(PayjoinMethods.ClassifyReceiverTx(state, UnrelatedTx)); + Assert.Throws(() => + PayjoinMethods.ClassifyReceiverTx(state, new byte[] { 0 })); + } + [Fact] public void SenderPersistence() { diff --git a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart index 43a91ec82..52c69b3c9 100644 --- a/payjoin-ffi/dart/test/test_payjoin_integration_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_integration_test.dart @@ -572,10 +572,12 @@ Future run_integration_v2_to_v2( headers: {"Content-Type": request_response.request.contentType}, body: request_response.request.body, ); - proposal.processResponse( - body: fallback_response.bodyBytes, - ohttpContext: request_response.clientResponse, - ); + payjoin.Monitor monitor = proposal + .processResponse( + body: fallback_response.bodyBytes, + ohttpContext: request_response.clientResponse, + ) + .save(persister: recv_persister); // ********************** // Inside the Sender: @@ -628,6 +630,31 @@ Future run_integration_v2_to_v2( )["hex"]; sender.call(method: "sendrawtransaction", params: [jsonEncode(final_tx_hex)]); + // ********************** + // Inside the Receiver: + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + final final_tx = Uint8List.fromList(hex.decode(final_tx_hex as String)); + final monitor_settlement = monitor.classifyTx(tx: final_tx); + expect(monitor_settlement, isNotNull); + expect(monitor_settlement!.outcome(), payjoin.SettlementOutcome.payjoin); + final replayed = payjoin + .replayReceiverEventLog(persister: recv_persister) + .state(); + expect(replayed, isA()); + final settlement = payjoin.classifyReceiverTx( + session: replayed, + tx: final_tx, + ); + expect(settlement, isNotNull); + expect(settlement!.outcome(), payjoin.SettlementOutcome.payjoin); + settlement.close().save(persister: recv_persister); + expect(recv_persister.closed, isTrue); + expect( + payjoin.replayReceiverEventLog(persister: recv_persister).state(), + isA(), + ); + // Check resulting transaction and balances var decodedTx = jsonDecode( sender.call( diff --git a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart index 3a16deb9d..98e8d95f5 100644 --- a/payjoin-ffi/dart/test/test_payjoin_unit_test.dart +++ b/payjoin-ffi/dart/test/test_payjoin_unit_test.dart @@ -6,6 +6,15 @@ import "package:payjoin/payjoin.dart" as payjoin; import "utils.dart"; +// A consensus-encoded transaction with one input and one output. It spends +// nothing a fresh session holds. +final unrelatedTx = Uint8List.fromList( + hex.decode( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + "0000000000ffffffff0100000000000000000000000000", + ), +); + void main() { group('Test URIs', () { test('Test url userinfo rejection', () { @@ -91,6 +100,39 @@ void main() { ); }); + test("Test classify receiver tx without fallback", () { + // A session that has not received the original has nothing to compare + // against, and bytes that are not a transaction are rejected whatever + // the session's state. + var persister = InMemoryReceiverPersister(); + payjoin.ReceiverBuilder( + address: "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + directory: "https://example.com", + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ), + ), + ), + ).build().save(persister: persister); + final state = payjoin + .replayReceiverEventLog(persister: persister) + .state(); + + expect( + payjoin.classifyReceiverTx(session: state, tx: unrelatedTx), + isNull, + ); + expect( + () => payjoin.classifyReceiverTx( + session: state, + tx: Uint8List.fromList([0]), + ), + throwsA(isA()), + ); + }); + test("Test sender persistence", () { var receiver_persister = InMemoryReceiverPersister(); var receiver = payjoin.ReceiverBuilder( diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts index 1b091da5d..c5ae426cf 100644 --- a/payjoin-ffi/javascript/test/integration.test.ts +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -694,10 +694,9 @@ async function testIntegrationV2ToV2( body: requestResponse.request.body, }); const fallbackResponseBuffer = await fallbackResponse.arrayBuffer(); - proposal.processResponse( - fallbackResponseBuffer, - requestResponse.clientResponse, - ); + const monitor = proposal + .processResponse(fallbackResponseBuffer, requestResponse.clientResponse) + .save(recvPersister); let pollOutcome: | PJNested<"PollingForProposalTransitionOutcome", "Progress"> @@ -742,6 +741,43 @@ async function testIntegrationV2ToV2( const finalHex = extraction.hex as string; sender.call("sendrawtransaction", [JSON.stringify(finalHex)]); + // Classify the broadcast Payjoin, then close the session through the + // settlement, as a wallet that replays its open sessions would. + const finalTx = new Uint8Array(Buffer.from(finalHex, "hex")).buffer; + const monitorSettlement = monitor.classifyTx(finalTx); + assert( + monitorSettlement !== undefined, + "Monitor should classify the Payjoin", + ); + assert.strictEqual( + monitorSettlement.outcome(), + payjoin.SettlementOutcome.Payjoin, + "Monitor should recognize the Payjoin", + ); + const replayed = payjoin.replayReceiverEventLog(recvPersister).state(); + assert.strictEqual( + replayed.tag, + "Monitor", + "Session should replay to Monitor", + ); + const settlement = payjoin.classifyReceiverTx(replayed, finalTx); + assert( + settlement !== undefined, + "Replayed session should classify the Payjoin", + ); + assert.strictEqual( + settlement.outcome(), + payjoin.SettlementOutcome.Payjoin, + "Replayed session should recognize the Payjoin", + ); + settlement.close().save(recvPersister); + assert(recvPersister.closed, "Closing should close the persister"); + assert.strictEqual( + payjoin.replayReceiverEventLog(recvPersister).state().tag, + "Closed", + "Session should replay as closed", + ); + const decodedPsbt = JSON.parse( sender.call("decodepsbt", [JSON.stringify(finalPsbt)]), ); diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts index 76341f77e..def3728aa 100644 --- a/payjoin-ffi/javascript/test/unit.test.ts +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -39,6 +39,16 @@ const OHTTP_KEYS = new Uint8Array([ 0x00, 0x03, ]).buffer; +// A consensus-encoded transaction with one input and one output. It spends +// nothing a fresh session holds. +const UNRELATED_TX = new Uint8Array( + Buffer.from( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000", + "hex", + ), +).buffer; + const ORIGINAL_PSBT = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; @@ -120,6 +130,38 @@ function runUnitTests(name: string, payjoin: typeof nodejsPayjoin) { ); }); + test("classify receiver tx without fallback", () => { + // A session that has not received the original has nothing to + // compare against, and bytes that are not a transaction are + // rejected whatever the session's state. + const persister = new InMemoryReceiverPersister(); + new payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode(OHTTP_KEYS), + ) + .build() + .save(persister); + const state = payjoin.replayReceiverEventLog(persister).state(); + + assert.strictEqual( + payjoin.classifyReceiverTx(state, UNRELATED_TX), + undefined, + "An Initialized session should classify nothing", + ); + assert.throws( + () => + payjoin.classifyReceiverTx( + state, + new Uint8Array([0]).buffer, + ), + (e: unknown) => + payjoin.TransactionParseError.InvalidTransaction.instanceOf( + e, + ), + ); + }); + test("sender persistence", () => { const persister = new InMemoryReceiverPersister(); const address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt index 3816ff8c5..0a1b25f7c 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -164,23 +164,28 @@ class IntegrationTests { cancelReceiver(payjoinProposal, recvPersister) return } - payjoinProposal.createPostRequest(relay).useDisposable { posted -> + val monitor = payjoinProposal.createPostRequest(relay).useDisposable { posted -> val body = http.post(posted.request) payjoinProposal.processResponse(body, posted.clientResponse).use { transition -> - transition.save(recvPersister).use { } + transition.save(recvPersister) } } + monitor.use { + // ********************** + // Inside the Sender: + val psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay) + val finalTx = finishPayjoin( + senderRpc, + receiverRpc, + psbtBase64, + senderOutpoints, + receiverOutpoints, + ) - // ********************** - // Inside the Sender: - val psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay) - finishPayjoin( - senderRpc, - receiverRpc, - psbtBase64, - senderOutpoints, - receiverOutpoints, - ) + // ********************** + // Inside the Receiver: + settleReceiver(monitor, recvPersister, finalTx) + } } } } @@ -391,7 +396,7 @@ class IntegrationTests { psbtBase64: String, senderOutpoints: Set, receiverOutpoints: Set, - ) { + ): ByteArray { val payjoinPsbt = Json.parseToJsonElement(rpc(senderRpc, "walletprocesspsbt", jstr(psbtBase64))) .jsonObject.getValue("psbt").jsonPrimitive.content val finalPsbt = Json.parseToJsonElement(rpc(senderRpc, "finalizepsbt", jstr(payjoinPsbt), "false")) @@ -426,6 +431,26 @@ class IntegrationTests { assertEquals(100.0 - networkFees, receiverPending, 1e-6) val senderBalance = Json.parseToJsonElement(rpc(senderRpc, "getbalance")).jsonPrimitive.double assertEquals(0.0, senderBalance, 1e-6) + return HexFormat.of().parseHex(finalTxHex) + } + + /** + * Classifies the broadcast Payjoin, then closes the session through the settlement, as a + * wallet that replays its open sessions would. + */ + private fun settleReceiver(monitor: Monitor, recvPersister: InMemoryReceiverPersister, finalTx: ByteArray) { + assertNotNull(monitor.classifyTx(finalTx), "Monitor should classify the Payjoin").use { settlement -> + assertEquals(SettlementOutcome.PAYJOIN, settlement.outcome()) + } + val replayed = replayReceiverEventLog(recvPersister).state() + assertIs(replayed) + assertNotNull(classifyReceiverTx(replayed, finalTx), "Replayed session should classify the Payjoin") + .use { settlement -> + assertEquals(SettlementOutcome.PAYJOIN, settlement.outcome()) + settlement.closeSession().use { it.save(recvPersister) } + } + assertTrue(recvPersister.closed, "closing should close the persister") + assertIs(replayReceiverEventLog(recvPersister).state()) } } diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt index 2cbfcd03f..34c857e99 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt @@ -1,8 +1,10 @@ package org.payjoindevkit import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -21,6 +23,23 @@ class PersistenceTests { assertTrue(persister.closed) } + @Test + fun classifyReceiverTxWithoutFallback() { + // A session that has not received the original has nothing to compare against, and bytes + // that are not a transaction are rejected whatever the session's state. + val persister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys) + .build() + .save(persister) + val state = replayReceiverEventLog(persister).state() + + assertNull(classifyReceiverTx(state, unrelatedTx)) + assertFailsWith { + classifyReceiverTx(state, byteArrayOf(0)) + } + } + @Test fun senderPersistence() { val receiverPersister = InMemoryReceiverPersister() diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt index ff1351dda..611ad20b5 100644 --- a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt @@ -6,3 +6,10 @@ internal val ohttpKeysData: ByteArray = HexFormat.of().parseHex( "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", ) + +/** A consensus-encoded transaction with one input and one output. It spends nothing a fresh session holds. */ +internal val unrelatedTx: ByteArray = + HexFormat.of().parseHex( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + + "0000000000ffffffff0100000000000000000000000000", + ) diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index 689e4dc12..e0f42215b 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -419,9 +419,9 @@ async def _run_integration_v2_to_v2(self, mode: TransitionMode): headers={"Content-Type": request_recv.request.content_type}, content=request_recv.request.body, ) - payjoin_proposal.process_response( + monitor: Monitor = payjoin_proposal.process_response( response.content, request_recv.client_response - ) + ).save(recv_persister) # ********************** # Inside the Sender: @@ -457,6 +457,25 @@ async def _run_integration_v2_to_v2(self, mode: TransitionMode): )["hex"] self.sender.call("sendrawtransaction", [json.dumps(final_tx_hex)]) + # ********************** + # Inside the Receiver: + # Classify the broadcast Payjoin, then close the session through the + # settlement, as a wallet that replays its open sessions would. + final_tx = bytes.fromhex(final_tx_hex) + monitor_settlement = monitor.classify_tx(final_tx) + self.assertIsNotNone(monitor_settlement) + self.assertEqual(monitor_settlement.outcome(), SettlementOutcome.PAYJOIN) + replayed = replay_receiver_event_log(recv_persister).state() + self.assertTrue(replayed.is_MONITOR()) + settlement = classify_receiver_tx(replayed, final_tx) + self.assertIsNotNone(settlement) + self.assertEqual(settlement.outcome(), SettlementOutcome.PAYJOIN) + settlement.close().save(recv_persister) + self.assertTrue(recv_persister.closed) + self.assertTrue( + replay_receiver_event_log(recv_persister).state().is_CLOSED() + ) + # Check resulting transaction and balances decoded_psbt = json.loads( self.sender.call("decodepsbt", [json.dumps(final_psbt)]) diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py index 2302c49d2..eab41ad33 100644 --- a/payjoin-ffi/python/test/test_payjoin_unit_test.py +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -60,6 +60,36 @@ def test_receiver_persistence(self): self.assertTrue(result.state().is_INITIALIZED()) +# A consensus-encoded transaction with one input and one output. It spends +# nothing a fresh session holds. +UNRELATED_TX = bytes.fromhex( + "02000000010000000000000000000000000000000000000000000000000000000000000000" + "0000000000ffffffff0100000000000000000000000000" +) + + +class TestClassifyReceiverTx(unittest.TestCase): + def test_classify_receiver_tx_without_fallback(self): + # A session that has not received the original has nothing to compare + # against, and bytes that are not a transaction are rejected whatever + # the session's state. + persister = InMemoryReceiverPersister() + payjoin.ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + payjoin.OhttpKeys.decode( + bytes.fromhex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003" + ) + ), + ).build().save(persister) + state = payjoin.replay_receiver_event_log(persister).state() + + self.assertIsNone(payjoin.classify_receiver_tx(state, UNRELATED_TX)) + with self.assertRaises(payjoin.TransactionParseError): + payjoin.classify_receiver_tx(state, b"\x00") + + class TestSenderPersistence(unittest.TestCase): def test_sender_persistence(self): # Create a receiver to just get the pj uri diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs index 8d100b51a..a8a038d7d 100644 --- a/payjoin-ffi/src/receive/error.rs +++ b/payjoin-ffi/src/receive/error.rs @@ -272,6 +272,14 @@ impl From for InputPairError { fn from(value: FfiValidationError) -> Self { InputPairError::FfiValidation(value) } } +/// Error decoding a consensus-encoded transaction passed across the FFI boundary. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum TransactionParseError { + /// The provided bytes are not a valid consensus-encoded transaction. + #[error("Invalid transaction: {0}")] + InvalidTransaction(String), +} + /// Error that may occur when a receiver event log is replayed #[derive(Debug, thiserror::Error, uniffi::Object)] #[uniffi::export(Debug, Display)] diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs index b13dacb0d..02431cd77 100644 --- a/payjoin-ffi/src/receive/mod.rs +++ b/payjoin-ffi/src/receive/mod.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, RwLock}; pub use error::{ AddressParseError, BuildReceiverError, CoinSelectionError, InputContributionError, InputPairError, JsonReply, OutputSubstitutionError, ProtocolError, PsbtInputError, - ReceiverCreateRequestError, ReceiverError, SessionError, + ReceiverCreateRequestError, ReceiverError, SessionError, TransactionParseError, }; use payjoin::bitcoin::consensus::Decodable; use payjoin::bitcoin::psbt::Psbt; @@ -192,6 +192,40 @@ macro_rules! impl_pending_fallback_cancel_for_receiver { }; } +fn decode_transaction(tx: &[u8]) -> Result { + payjoin::bitcoin::Transaction::consensus_decode(&mut &tx[..]) + .map_err(|e| TransactionParseError::InvalidTransaction(e.to_string())) +} + +macro_rules! impl_settlement_classification_for_receiver { + ($ty:ident) => { + #[uniffi::export] + impl $ty { + /// Classify a consensus-encoded transaction against this session's + /// Payjoin proposal and fallback. + /// + /// Call this with each transaction the wallet receives. Signatures + /// are not verified, so pass only transactions seen on the network. + /// If several transactions spend the sender's inputs, classify the + /// one with the most confirmations. + /// + /// Returns `None` if `tx` spends none of the sender's original + /// inputs. Otherwise returns a [`Settlement`] with outcome + /// [`SettlementOutcome::Fallback`] if `tx` is the fallback, + /// [`SettlementOutcome::Payjoin`] if it is the proposal, or + /// [`SettlementOutcome::Unrecognized`] otherwise. States before + /// [`PayjoinProposal`] never return [`SettlementOutcome::Payjoin`]. + pub fn classify_tx( + &self, + tx: Vec, + ) -> Result>, TransactionParseError> { + let tx = decode_transaction(&tx)?; + Ok(self.0.classify_tx(&tx).map(|settlement| Arc::new(settlement.into()))) + } + } + }; +} + impl_terminal_cancel_for_receiver!(Initialized); impl_terminal_cancel_for_receiver!(UncheckedOriginalPayload); impl_pending_fallback_cancel_for_receiver!(MaybeInputsOwned); @@ -204,6 +238,16 @@ impl_pending_fallback_cancel_for_receiver!(ProvisionalProposal); impl_pending_fallback_cancel_for_receiver!(PayjoinProposal); impl_pending_fallback_cancel_for_receiver!(Monitor); +impl_settlement_classification_for_receiver!(MaybeInputsOwned); +impl_settlement_classification_for_receiver!(MaybeInputsSeen); +impl_settlement_classification_for_receiver!(OutputsUnknown); +impl_settlement_classification_for_receiver!(WantsOutputs); +impl_settlement_classification_for_receiver!(WantsInputs); +impl_settlement_classification_for_receiver!(WantsFeeRange); +impl_settlement_classification_for_receiver!(ProvisionalProposal); +impl_settlement_classification_for_receiver!(PayjoinProposal); +impl_settlement_classification_for_receiver!(Monitor); + #[uniffi::export] impl HasReplyableError { /// Cancel the Payjoin session without posting the receiver error response. @@ -324,6 +368,36 @@ impl ReplayResult { pub fn session_history(&self) -> ReceiverSessionHistory { self.session_history.clone() } } +/// Classify a consensus-encoded transaction against a receiver session. +/// +/// Returns the result of `classify_tx` for states that hold a fallback, and +/// `None` for all other states. +#[uniffi::export] +pub fn classify_receiver_tx( + session: ReceiveSession, + tx: Vec, +) -> Result>, TransactionParseError> { + match session { + ReceiveSession::MaybeInputsOwned { inner } => inner.classify_tx(tx), + ReceiveSession::MaybeInputsSeen { inner } => inner.classify_tx(tx), + ReceiveSession::OutputsUnknown { inner } => inner.classify_tx(tx), + ReceiveSession::WantsOutputs { inner } => inner.classify_tx(tx), + ReceiveSession::WantsInputs { inner } => inner.classify_tx(tx), + ReceiveSession::WantsFeeRange { inner } => inner.classify_tx(tx), + ReceiveSession::ProvisionalProposal { inner } => inner.classify_tx(tx), + ReceiveSession::PayjoinProposal { inner } => inner.classify_tx(tx), + ReceiveSession::Monitor { inner } => inner.classify_tx(tx), + ReceiveSession::Initialized { .. } + | ReceiveSession::UncheckedOriginalPayload { .. } + | ReceiveSession::HasReplyableError { .. } + | ReceiveSession::ReceiverPendingFallback { .. } + | ReceiveSession::Closed { .. } => { + decode_transaction(&tx)?; + Ok(None) + } + } +} + #[uniffi::export] pub fn replay_receiver_event_log( persister: Arc, @@ -1277,6 +1351,9 @@ impl WantsOutputs { /// Substitute the receiver output script with the provided script. /// + /// If `output_script` is not owned by the receiver's wallet, watch it to + /// find the Payjoin proposal when it settles. + /// /// Returns an updated [`WantsOutputs`] with the substituted output. pub fn substitute_receiver_script( &self, @@ -1834,29 +1911,85 @@ impl HasReplyableError { } } -#[uniffi::export(with_foreign)] -pub trait TransactionFinder: Send + Sync { - fn callback(&self, txid: String) -> Result>, ForeignError>; +/// Typestate for a Payjoin proposal that has been posted to the sender. +/// +/// Call [`Monitor::classify_tx`] with each transaction the wallet receives, and +/// [`Settlement::close`] the returned settlement once it has enough +/// confirmations. If nothing settles the session before it expires, cancel it +/// and broadcast the fallback. +#[derive(uniffi::Object)] +pub struct Monitor(pub payjoin::receive::v2::Receiver); + +impl From> for Monitor { + fn from(value: payjoin::receive::v2::Receiver) -> Self { + Self(value) + } } -#[allow(clippy::type_complexity)] +/// Which transaction spent the sender's original inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum SettlementOutcome { + /// The Payjoin proposal. + Payjoin, + /// The fallback transaction. + Fallback, + /// Neither the Payjoin proposal nor the fallback, e.g. a fee bump or a + /// sender double-spend. + Unrecognized, +} + +impl From for SettlementOutcome { + fn from(value: payjoin::receive::v2::SettlementOutcome) -> Self { + use payjoin::receive::v2::SettlementOutcome as Core; + + match value { + Core::Payjoin => SettlementOutcome::Payjoin, + Core::Fallback => SettlementOutcome::Fallback, + Core::Unrecognized => SettlementOutcome::Unrecognized, + // The core enum is non_exhaustive. + _ => SettlementOutcome::Unrecognized, + } + } +} + +/// A transaction spending the sender's original inputs, as classified by +/// `classify_tx`. #[derive(uniffi::Object)] -pub struct MonitorTransition( +pub struct Settlement(payjoin::receive::v2::Settlement); + +impl From for Settlement { + fn from(value: payjoin::receive::v2::Settlement) -> Self { Self(value) } +} + +#[uniffi::export] +impl Settlement { + /// Returns which transaction settled the session. + pub fn outcome(&self) -> SettlementOutcome { self.0.outcome().into() } + + /// Close the Payjoin session. + /// + /// Call this once the settlement has enough confirmations for the wallet. + /// + /// Returns a [`SettlementCloseTransition`] that, once successfully + /// persisted, closes the receiver session. + pub fn close(&self) -> SettlementCloseTransition { + SettlementCloseTransition(Arc::new(RwLock::new(Some(self.0.clone().close())))) + } +} + +/// A transition produced by closing a settled session. +#[derive(uniffi::Object)] +#[allow(clippy::type_complexity)] +pub struct SettlementCloseTransition( Arc< RwLock< - Option< - payjoin::persist::MaybeFatalOrSuccessTransition< - payjoin::receive::v2::SessionEvent, - payjoin::receive::v2::Receiver, - payjoin::receive::Error, - >, - >, + Option>, >, >, ); #[uniffi::export] -impl MonitorTransition { +impl SettlementCloseTransition { pub fn save( &self, persister: Arc, @@ -1866,7 +1999,9 @@ impl MonitorTransition { let value = inner.take().expect("Already saved or moved"); - value.save(&adapter).map_err(ReceiverPersistedError::from)?; + value + .save(&adapter) + .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; Ok(()) } @@ -1880,85 +2015,14 @@ impl MonitorTransition { inner.take().expect("Already saved or moved") }; - value.save_async(&adapter).await.map_err(ReceiverPersistedError::from)?; + value + .save_async(&adapter) + .await + .map_err(|e| ReceiverPersistedError::from(ImplementationError::new(e)))?; Ok(()) } } -#[derive(uniffi::Object)] -pub struct Monitor(pub payjoin::receive::v2::Receiver); - -impl From> for Monitor { - fn from(value: payjoin::receive::v2::Receiver) -> Self { - Self(value) - } -} - -fn try_deserialize_tx( - buf: Vec, -) -> Result { - payjoin::bitcoin::transaction::Transaction::consensus_decode(&mut buf.as_slice()) - .map_err(|e| ForeignError::InternalError(e.to_string())) -} - -#[uniffi::export] -impl Monitor { - /// Check the network for the payjoin or fallback transaction via the - /// `find_transaction` callback. - /// - /// Returns a [`MonitorTransition`] that, once persisted, completes - /// the session if a transaction is found. - pub fn check_for_transaction( - &self, - find_transaction: Arc, - ) -> MonitorTransition { - MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_for_transaction( - |txid| { - find_transaction - .callback(txid.to_string()) - .and_then(|buf| buf.map(try_deserialize_tx).transpose()) - .map_err(|e| ImplementationError::new(e).into()) - }, - ))))) - } -} - -macro_rules! impl_proposal_txid_is_stable { - ($($state:ty),* $(,)?) => { - $( - #[uniffi::export] - impl $state { - /// Whether the payjoin proposal's transaction ID is knowable in advance. - /// - /// If every sender input is native SegWit (empty script_sig), the - /// transaction ID computed from the unsigned proposal remains valid once - /// the sender re-signs, so the receiver can record it (e.g. to reconcile - /// an incoming payment) and monitor the network for it. If any sender - /// input finalizes with a non-empty script_sig — legacy inputs, but also - /// P2SH-wrapped SegWit — the sender's final script_sig changes the - /// transaction ID: any ID derived from the proposal before the sender - /// signs will never appear on the network, and monitoring concludes the - /// session without checking. - pub fn proposal_txid_is_stable(&self) -> bool { - self.0.proposal_txid_is_stable() - } - } - )* - }; -} - -impl_proposal_txid_is_stable!( - MaybeInputsOwned, - MaybeInputsSeen, - OutputsUnknown, - WantsOutputs, - WantsInputs, - WantsFeeRange, - ProvisionalProposal, - PayjoinProposal, - Monitor, -); - #[derive(uniffi::Object)] #[allow(clippy::type_complexity)] pub struct PendingFallbackTransition( diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index f2be18d49..c28dc9534 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -7,6 +7,7 @@ cdylib_name = "payjoin_ffi" [bindings.kotlin.rename] "ReceiverPendingFallback.close" = "closeSession" "SenderPendingFallback.close" = "closeSession" +"Settlement.close" = "closeSession" "JsonReceiverSessionPersister.close" = "closeSession" "JsonReceiverSessionPersisterAsync.close" = "closeSession" "JsonSenderSessionPersister.close" = "closeSession" diff --git a/payjoin/CHANGELOG.md b/payjoin/CHANGELOG.md index 055cbfffe..ada6469a8 100644 --- a/payjoin/CHANGELOG.md +++ b/payjoin/CHANGELOG.md @@ -2,6 +2,43 @@ ## Unreleased +- Close a leak in the `HasFallbackTx` seal. Generic code bounded on the trait + could call the sealed `fallback_tx` method, which was never part of the + documented API; such code no longer compiles. Read the fallback transaction + from `Receiver::fallback_tx` after `cancel`, or from + `SessionHistory::fallback_tx`. + +### API Additions + +- Add settlement classification to `receive::v2::Receiver`. On every state + that holds a fallback transaction, `classify_tx` returns a `Settlement` + for a transaction spending the sender's original prevouts. Its `outcome` + is a `SettlementOutcome` saying whether that transaction is the Payjoin + proposal, the fallback, or neither. `ReceiveSession::classify_tx` does the + same for a session in any state. Wallets should pass every transaction + they receive to `classify_tx` on each open session. It recognizes the + proposal even after a non-SegWit sender's signature changes its txid, and + never reports `Payjoin` before `PayjoinProposal` +- Add `Settlement::close`, which ends a session once its settlement meets + the wallet's confirmation bar, without broadcasting the fallback. Only + `classify_tx` makes a `Settlement`, so a session closes this way only after + a transaction has settled it. It records `PayjoinProposalSent` from + `Monitor`, and from `PayjoinProposal` when the Payjoin settled it, and + `Aborted` otherwise + +### Deprecations + +- `Receiver::check_for_transaction` is deprecated. It closes the + session on first sight of a predicted txid, never sees a spend that is + neither the proposal nor the fallback, and closes a session with non-SegWit + sender inputs without checking. Use `classify_tx` and `Settlement::close` + instead. It now probes a txid that includes the script_sig of a legacy or + P2SH-wrapped receiver input. It used to probe a txid that never appears on + chain for such a session +- `Receiver::proposal_txid_is_stable` is deprecated. It checks only the + sender's inputs, so a legacy or P2SH-wrapped receiver input can change the + txid even when it returns true. Use `classify_tx` + ## 1.2.0 This release adds a non-blocking interface to the receiver state machine. diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index bbb0bbc3c..e614a37a1 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -29,7 +29,7 @@ use std::str::FromStr; use std::time::Duration; use bitcoin::psbt::Psbt; -use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, TxOut, Txid}; +use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, Transaction, TxOut, Txid}; pub use error::{CreateRequestError, SessionError}; pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; @@ -149,6 +149,29 @@ pub enum ReceiveSession { } impl ReceiveSession { + /// Classify a transaction against this session. + /// + /// Returns the result of [`Receiver::classify_tx`] for states that hold a fallback, and + /// `None` for all other states. + pub fn classify_tx(&self, tx: &Transaction) -> Option { + match self { + ReceiveSession::MaybeInputsOwned(r) => r.classify_tx(tx), + ReceiveSession::MaybeInputsSeen(r) => r.classify_tx(tx), + ReceiveSession::OutputsUnknown(r) => r.classify_tx(tx), + ReceiveSession::WantsOutputs(r) => r.classify_tx(tx), + ReceiveSession::WantsInputs(r) => r.classify_tx(tx), + ReceiveSession::WantsFeeRange(r) => r.classify_tx(tx), + ReceiveSession::ProvisionalProposal(r) => r.classify_tx(tx), + ReceiveSession::PayjoinProposal(r) => r.classify_tx(tx), + ReceiveSession::Monitor(r) => r.classify_tx(tx), + ReceiveSession::Initialized(_) + | ReceiveSession::UncheckedOriginalPayload(_) + | ReceiveSession::HasReplyableError(_) + | ReceiveSession::PendingFallback(_) + | ReceiveSession::Closed(_) => None, + } + } + fn new(context: SessionContext) -> Self { ReceiveSession::Initialized(Receiver { state: Initialized {}, session_context: context }) } @@ -321,6 +344,8 @@ fn try_pending_fallback(session: ReceiveSession) -> Result bitcoin::Transaction; - } + pub(crate) trait FallbackTx: State { + fn original_psbt(&self) -> &bitcoin::Psbt; - impl FallbackTx for super::MaybeInputsOwned { fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() + self.original_psbt().clone().extract_tx_unchecked_fee_rate() + } + + /// The finalized Payjoin proposal, if the sender may have received it. + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { None } + + /// The outcome a session in this state closes with once settled by `outcome`. + fn settled_session_outcome(&self, _outcome: SettlementOutcome) -> SessionOutcome { + SessionOutcome::Aborted } } + impl FallbackTx for super::MaybeInputsOwned { + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } + } + impl FallbackTx for super::MaybeInputsSeen { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } } impl FallbackTx for super::OutputsUnknown { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.original.psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.original.psbt } } impl FallbackTx for super::WantsOutputs { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::WantsInputs { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::WantsFeeRange { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.inner.original_psbt().clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { self.inner.original_psbt() } } impl FallbackTx for super::ProvisionalProposal { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() - } + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } } + // The proposal may have reached the sender even if persisting the post failed. impl FallbackTx for super::PayjoinProposal { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } + + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { Some(&self.psbt_context.payjoin_psbt) } + + // Only a settled Payjoin proves the proposal reached the sender. + fn settled_session_outcome(&self, outcome: SettlementOutcome) -> SessionOutcome { + match outcome { + SettlementOutcome::Payjoin => SessionOutcome::PayjoinProposalSent, + _ => SessionOutcome::Aborted, + } } } impl FallbackTx for super::Monitor { - fn fallback_tx(&self) -> bitcoin::Transaction { - self.psbt_context.original_psbt.clone().extract_tx_unchecked_fee_rate() + fn original_psbt(&self) -> &bitcoin::Psbt { &self.psbt_context.original_psbt } + + fn payjoin_psbt(&self) -> Option<&bitcoin::Psbt> { Some(&self.psbt_context.payjoin_psbt) } + + fn settled_session_outcome(&self, _outcome: SettlementOutcome) -> SessionOutcome { + SessionOutcome::PayjoinProposalSent } } } @@ -407,9 +443,39 @@ impl State for S {} /// Marker trait for receiver protocol states that hold a verified broadcastable /// fallback transaction. /// -/// This trait is sealed to prevent external implementations. +/// This trait is sealed to prevent external implementations. Its methods are not callable +/// outside this crate. Use [`Receiver::cancel`] to obtain the fallback transaction. +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Transaction; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn fallback_tx(receiver: &Receiver) -> Transaction { +/// receiver.fallback_tx() +/// } +/// ``` +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Psbt; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn original_psbt(receiver: &Receiver) -> Psbt { +/// receiver.original_psbt().clone() +/// } +/// ``` +/// +/// ```compile_fail,E0624 +/// use payjoin::bitcoin::Psbt; +/// use payjoin::receive::v2::{HasFallbackTx, Receiver}; +/// +/// fn payjoin_psbt(receiver: &Receiver) -> Option { +/// receiver.payjoin_psbt().cloned() +/// } +/// ``` +#[allow(private_bounds)] pub trait HasFallbackTx: sealed::FallbackTx {} +#[allow(private_bounds)] impl HasFallbackTx for T {} /// A higher-level receiver construct which will be taken through different states through the @@ -484,26 +550,68 @@ impl Receiver { ) } - /// Whether the payjoin proposal's transaction ID is knowable in advance. + /// The prevouts spent by the sender's original transaction, and so by both the fallback and + /// the Payjoin proposal. + fn contested_prevouts(&self) -> impl Iterator + '_ { + self.state.original_psbt().unsigned_tx.input.iter().map(|txin| txin.previous_output) + } + + /// Classify a transaction against this session's Payjoin proposal and fallback. /// - /// If every sender input is native SegWit, the sender's final signatures - /// live in the witness and each script_sig stays empty, so the - /// transaction ID computed from the unsigned proposal remains valid once - /// the sender re-signs — the receiver can record it (e.g. to reconcile an - /// incoming payment) and monitor the network for it. + /// Call this with each transaction the wallet receives. Signatures are not verified, so pass + /// only transactions seen on the network. If several transactions spend the sender's inputs, + /// classify the one with the most confirmations. /// - /// If any sender input finalizes with a non-empty script_sig — legacy - /// inputs, whose signatures live there, but also P2SH-wrapped SegWit - /// inputs, whose script_sig carries the redeem script push — that - /// script_sig is part of the txid preimage and changes the transaction - /// ID. Any transaction ID derived from the proposal before the sender - /// signs will never appear on the network, and - /// [`Receiver::check_for_transaction`] concludes the session - /// without monitoring. Receivers that track payments by transaction ID - /// can use this to choose a policy up front: decline to contribute and - /// let the fallback pay, or reconcile that session by script instead of - /// by transaction ID. - pub fn proposal_txid_is_stable(&self) -> bool { + /// Returns `None` if `tx` spends none of the sender's original inputs. Otherwise returns a + /// [`Settlement`] with outcome [`SettlementOutcome::Fallback`] if `tx` is the fallback, + /// [`SettlementOutcome::Payjoin`] if it is the proposal, or + /// [`SettlementOutcome::Unrecognized`] otherwise. States before [`Receiver`] + /// never return [`SettlementOutcome::Payjoin`]. + pub fn classify_tx(&self, tx: &Transaction) -> Option { + let outcome = self.settlement_outcome(tx)?; + Some(Settlement { outcome, session_outcome: self.state.settled_session_outcome(outcome) }) + } + + /// Which transaction `tx` is to this session, if it spends a contested prevout. + fn settlement_outcome(&self, tx: &Transaction) -> Option { + let spends_contested_prevout = tx + .input + .iter() + .any(|txin| self.contested_prevouts().any(|prevout| prevout == txin.previous_output)); + if !spends_contested_prevout { + return None; + } + + let mut unsigned_tx = tx.clone(); + for txin in &mut unsigned_tx.input { + txin.script_sig = ScriptBuf::new(); + } + let unsigned_txid = unsigned_tx.compute_txid(); + + if unsigned_txid == self.state.original_psbt().unsigned_tx.compute_txid() { + return Some(SettlementOutcome::Fallback); + } + if let Some(payjoin_psbt) = self.state.payjoin_psbt() { + if unsigned_txid == payjoin_psbt.unsigned_tx.compute_txid() { + return Some(SettlementOutcome::Payjoin); + } + } + Some(SettlementOutcome::Unrecognized) + } + + /// Whether every sender input finalizes with an empty script_sig, so the sender's signatures + /// leave the Payjoin proposal's txid unchanged. + /// + /// Only sender inputs are checked. A legacy or P2SH-wrapped receiver input still changes the + /// txid of the unsigned proposal. + #[deprecated( + since = "0.0.0-unreleased", + note = "checks only the sender's inputs; use `Receiver::classify_tx`" + )] + pub fn proposal_txid_is_stable(&self) -> bool { self.sender_script_sigs_are_empty() } + + /// Whether every sender input finalizes with an empty script_sig. + fn sender_script_sigs_are_empty(&self) -> bool { self.state.fallback_tx().input.iter().all(|txin| txin.script_sig.is_empty()) } } @@ -1254,6 +1362,9 @@ impl Receiver { /// Substitute the receiver output script with the provided script. /// + /// If `output_script` is not owned by the receiver's wallet, watch it to find the Payjoin + /// proposal when it settles. + /// /// Returns an updated [`Receiver`] with the substituted output. pub fn substitute_receiver_script( self, @@ -1793,7 +1904,7 @@ impl Receiver { } } -/// Completed payjoin proposal monitoring for the payjoin or fallback transaction. +/// Completed Payjoin proposal that has been posted to the sender. /// /// See [`Receiver`] for further documentation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1801,23 +1912,68 @@ pub struct Monitor { psbt_context: PsbtContext, } -/// Typestate to monitor the network for the Payjoin proposal or fallback transaction. -/// -/// After the Payjoin proposal is signed and sent back to the sender, the receiver should monitor -/// the network and confirm the status of transaction (or the fallback). In this case, the status -/// can refer to whether the transaction has been broadcast, has some number of confirmations, etc. -/// The caller should decide the condition that must be satisfied for the Payjoin to be considered -/// successful. +/// Which transaction spent the sender's original inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SettlementOutcome { + /// The Payjoin proposal. + Payjoin, + /// The fallback transaction. + Fallback, + /// Neither the Payjoin proposal nor the fallback, e.g. a fee bump or a sender double-spend. + Unrecognized, +} + +/// A transaction spending the sender's original inputs, as classified by +/// [`Receiver::classify_tx`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settlement { + outcome: SettlementOutcome, + session_outcome: SessionOutcome, +} + +impl Settlement { + /// Returns which transaction settled the session. + pub fn outcome(&self) -> SettlementOutcome { self.outcome } + + /// Close the Payjoin session. + /// + /// Call this once the settlement has enough confirmations for the wallet. + /// + /// Returns a [`TerminalTransition`] that, once successfully persisted, closes the receiver + /// session as [`SessionOutcome::PayjoinProposalSent`] if the proposal was delivered, or + /// [`SessionOutcome::Aborted`] otherwise. + pub fn close(self) -> TerminalTransition { + TerminalTransition::new(SessionEvent::Closed(self.session_outcome), ()) + } +} + +/// Typestate for a Payjoin proposal that has been posted to the sender. /// -/// Call [`Receiver::check_for_transaction`] to confirm the status of the transaction in the -/// network and conclude the Payjoin session. +/// Call [`Receiver::classify_tx`] with each transaction the wallet receives, and +/// [`Settlement::close`] the returned settlement once it has enough confirmations. If nothing +/// settles the session before it expires, [`cancel`](Receiver::cancel) it and broadcast the +/// fallback. impl Receiver { - /// Check the network for the payjoin or fallback transaction via the `find_transaction` + /// The txid of the broadcast Payjoin proposal, or `None` if the sender's signatures change + /// it. + fn stable_payjoin_txid(&self) -> Option { + if !self.sender_script_sigs_are_empty() { + return None; + } + // Includes the script_sigs of the receiver's finalized inputs. + let payjoin_tx = + self.state.psbt_context.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + Some(payjoin_tx.compute_txid()) + } + + /// Check the network for the Payjoin or fallback transaction via the `find_transaction` /// callback. /// /// Returns a [`MaybeFatalOrSuccessTransition`] that, once successfully persisted, either /// concludes the session if a transaction is found, or yields a [`Receiver`] to /// remain in stasis if no transaction is found yet. + #[deprecated(since = "0.0.0-unreleased", note = "use `classify_tx` and `Settlement::close`")] pub fn check_for_transaction( self, find_transaction: impl Fn(Txid) -> Result, ImplementationError>, @@ -1827,14 +1983,13 @@ impl Receiver { // If the fallback transaction included any non-SegWit inputs, then the transaction ID of // the Payjoin proposal is going to change when the sender signs their non-SegWit address // one more time. The receiver cannot monitor the transaction, and should conclude the session. - if !self.proposal_txid_is_stable() { - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::PayjoinProposalSent, - )); - } - - let payjoin_proposal = &self.state.psbt_context.payjoin_psbt; - let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid(); + let payjoin_txid = match self.stable_payjoin_txid() { + Some(txid) => txid, + None => + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::PayjoinProposalSent, + )), + }; // If the sender is spending SegWit-only inputs, then the transaction ID of the Payjoin proposal // is not going to change when the sender signs it. So we can use the TXID to check the // network for the Payjoin proposal. @@ -1851,9 +2006,11 @@ impl Receiver { } // Payjoin transaction with SegWit inputs was detected. Complete the session, // recording the txid of the transaction that settled it. - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::Success(tx_id), - )); + if let Some(SettlementOutcome::Payjoin) = self.settlement_outcome(&tx) { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::Success(tx_id), + )); + } } Ok(None) => {} Err(e) => @@ -1863,10 +2020,12 @@ impl Receiver { // If the Payjoin proposal was not found, check the fallback transaction, as it is // the second of two transactions whose IDs the receiver is aware of. match find_transaction(fallback_tx.compute_txid()) { - Ok(Some(_)) => - return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( - SessionOutcome::FallbackBroadcasted, - )), + Ok(Some(tx)) => + if let Some(SettlementOutcome::Fallback) = self.settlement_outcome(&tx) { + return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed( + SessionOutcome::FallbackBroadcasted, + )); + }, Ok(None) => {} Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e), self), @@ -2083,6 +2242,7 @@ pub mod test { } #[test] + #[allow(deprecated)] fn test_monitor_typestate() -> Result<(), BoxError> { let psbt_ctx = native_segwit_psbt_context(); let payjoin_tx = psbt_ctx.payjoin_psbt.unsigned_tx.clone(); @@ -2185,6 +2345,7 @@ pub mod test { } #[test] + #[allow(deprecated)] fn test_nested_segwit_sender_input_txid_is_unstable() -> Result<(), BoxError> { use bitcoin::hashes::Hash; @@ -2269,6 +2430,338 @@ pub mod test { Ok(()) } + #[test] + fn test_classify_tx_payjoin_with_receiver_script_sig() { + // A receiver input with a script_sig changes the txid of the unsigned proposal. + let mut psbt_ctx = native_segwit_psbt_context(); + psbt_ctx.payjoin_psbt.inputs[1].final_script_sig = Some(ScriptBuf::from_bytes(vec![0x51])); + let unsigned_txid = psbt_ctx.payjoin_psbt.unsigned_tx.compute_txid(); + let mut broadcast_tx = psbt_ctx.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + let mut sender_witness = Witness::new(); + sender_witness.push([0x30; 71]); // dummy signature + sender_witness.push([0x02; 33]); // dummy compressed pubkey + broadcast_tx.input[0].witness = sender_witness; + let monitor = Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + }; + + assert_ne!(broadcast_tx.compute_txid(), unsigned_txid); + assert_eq!( + monitor.classify_tx(&broadcast_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + #[allow(deprecated)] + fn test_check_for_transaction_regression() { + // A receiver input with a script_sig changes the txid of the unsigned proposal. + let mut psbt_ctx = native_segwit_psbt_context(); + psbt_ctx.payjoin_psbt.inputs[1].final_script_sig = Some(ScriptBuf::from_bytes(vec![0x51])); + let payjoin_tx = psbt_ctx.payjoin_psbt.clone().extract_tx_unchecked_fee_rate(); + let fallback_tx = psbt_ctx.original_psbt.clone().extract_tx_unchecked_fee_rate(); + let monitor = Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + }; + let check_for = |broadcast: &Transaction| { + let persister = InMemoryPersister::default(); + monitor + .clone() + .check_for_transaction(|txid| { + Ok((txid == broadcast.compute_txid()).then(|| broadcast.clone())) + }) + .save(&persister) + .expect("InMemoryPersister shouldn't fail"); + let inner = persister.inner.lock().expect("Shouldn't be poisoned"); + assert!(inner.is_closed); + inner.events.last().cloned() + }; + + assert_eq!( + check_for(&payjoin_tx), + Some(SessionEvent::Closed(SessionOutcome::Success(payjoin_tx.compute_txid()))) + ); + assert_eq!( + check_for(&fallback_tx), + Some(SessionEvent::Closed(SessionOutcome::FallbackBroadcasted)) + ); + } + + #[test] + #[allow(deprecated)] + fn test_proposal_txid_is_stable_regression() { + // The BIP78 test vectors use a P2SH-wrapped SegWit sender. + let native_segwit_monitor = Receiver { + state: Monitor { psbt_context: native_segwit_psbt_context() }, + session_context: SHARED_CONTEXT.clone(), + }; + assert!(native_segwit_monitor.proposal_txid_is_stable()); + assert!(!segwit_monitor().proposal_txid_is_stable()); + } + + fn segwit_monitor() -> Receiver { + let psbt_ctx = PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// A monitor whose proposal is identical to the original transaction. + fn identical_proposal_monitor() -> Receiver { + let original = PARSED_ORIGINAL_PSBT.clone(); + let psbt_ctx = PsbtContext { original_psbt: original.clone(), payjoin_psbt: original }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// A monitor whose proposal substitutes an output and contributes no input. + fn cutthrough_monitor() -> Receiver { + let original = PARSED_ORIGINAL_PSBT.clone(); + let mut proposal = original.clone(); + proposal.unsigned_tx.output[0].script_pubkey = + ScriptBuf::from_bytes(vec![0x6a, 0x01, 0x2a]); + let psbt_ctx = PsbtContext { original_psbt: original, payjoin_psbt: proposal }; + Receiver { + state: Monitor { psbt_context: psbt_ctx }, + session_context: SHARED_CONTEXT.clone(), + } + } + + /// The finalized fallback transaction. + fn fallback_tx() -> Transaction { PARSED_ORIGINAL_PSBT.clone().extract_tx().expect("valid tx") } + + /// Returns `tx` with a non-empty script_sig on its first input, changing only its txid. + fn with_perturbed_txid(mut tx: Transaction) -> Transaction { + tx.input[0].script_sig = ScriptBuf::from_bytes(vec![0x51]); // OP_TRUE, arbitrary + tx + } + + /// Returns `tx` with a different output value, spending the same prevouts. + fn respent(mut tx: Transaction) -> Transaction { + tx.output[0].value = Amount::from_sat(tx.output[0].value.to_sat() - 1); + tx + } + + #[test] + fn test_classify_tx_ignores_unrelated_tx() { + use bitcoin::hashes::Hash as _; + + let monitor = segwit_monitor(); + let mut unrelated = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + for txin in unrelated.input.iter_mut() { + txin.previous_output = OutPoint { txid: Txid::all_zeros(), vout: 9 }; + } + assert_eq!(monitor.classify_tx(&unrelated).map(|s| s.outcome()), None); + } + + #[test] + fn test_classify_tx_payjoin() { + let monitor = segwit_monitor(); + let payjoin_tx = PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx; + + assert_eq!( + monitor.classify_tx(&payjoin_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_payjoin_with_changed_txid() { + // A non-SegWit sender's signature changes the proposal's txid. + let monitor = segwit_monitor(); + let signed_tx = with_perturbed_txid(PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx); + assert_ne!(signed_tx.compute_txid(), PARSED_PAYJOIN_PROPOSAL.unsigned_tx.compute_txid()); + + assert_eq!( + monitor.classify_tx(&signed_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_payjoin_without_receiver_input() { + let monitor = cutthrough_monitor(); + let proposal_tx = monitor.state.psbt_context.payjoin_psbt.unsigned_tx.clone(); + assert!(proposal_tx + .input + .iter() + .map(|txin| txin.previous_output) + .eq(monitor.contested_prevouts())); + + assert_eq!( + monitor.classify_tx(&proposal_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Payjoin) + ); + } + + #[test] + fn test_classify_tx_fallback() { + let monitor = segwit_monitor(); + assert_eq!( + monitor.classify_tx(&fallback_tx()).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_malleated_fallback() { + // A third party can malleate a non-SegWit fallback's script_sig. + let monitor = segwit_monitor(); + let malleated = with_perturbed_txid(fallback_tx()); + assert_ne!(malleated.compute_txid(), monitor.fallback_tx().compute_txid()); + + assert_eq!( + monitor.classify_tx(&malleated).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_identical_proposal_is_fallback() { + let monitor = identical_proposal_monitor(); + assert_eq!( + monitor.classify_tx(&fallback_tx()).map(|s| s.outcome()), + Some(SettlementOutcome::Fallback) + ); + } + + #[test] + fn test_classify_tx_unrecognized() { + let monitor = segwit_monitor(); + let other_tx = respent(fallback_tx()); + + assert_eq!( + monitor.classify_tx(&other_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + #[test] + fn test_classify_tx_reordered_outputs_is_unrecognized() { + let monitor = cutthrough_monitor(); + let mut reordered_tx = monitor.state.psbt_context.payjoin_psbt.unsigned_tx.clone(); + reordered_tx.output.reverse(); + + assert_eq!( + monitor.classify_tx(&reordered_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + #[test] + fn test_classify_tx_shared_prevouts_are_not_payjoin() { + // Same inputs as the proposal, different outputs. + let monitor = segwit_monitor(); + let other_tx = respent(PARSED_PAYJOIN_PROPOSAL.clone().unsigned_tx); + assert_eq!(other_tx.input, PARSED_PAYJOIN_PROPOSAL.unsigned_tx.input); + + assert_eq!( + monitor.classify_tx(&other_tx).map(|s| s.outcome()), + Some(SettlementOutcome::Unrecognized) + ); + } + + /// The events persisted by closing `settlement`. + fn close_events(settlement: Settlement) -> Vec { + let persister = InMemoryPersister::default(); + settlement.close().save(&persister).expect("InMemoryPersister shouldn't fail"); + let inner = persister.inner.lock().expect("Shouldn't be poisoned"); + assert!(inner.is_closed, "close must close the session"); + inner.events.clone() + } + + #[test] + fn test_settlement_close_from_monitor_records_no_settlement_outcome() { + let monitor = segwit_monitor(); + let closed = vec![SessionEvent::Closed(SessionOutcome::PayjoinProposalSent)]; + for tx in + [PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), fallback_tx(), respent(fallback_tx())] + { + let settlement = monitor.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(close_events(settlement), closed); + } + } + + #[test] + fn test_settlement_close_from_payjoin_proposal() { + let receiver = Receiver { + state: PayjoinProposal { + psbt_context: PsbtContext { + original_psbt: PARSED_ORIGINAL_PSBT.clone(), + payjoin_psbt: PARSED_PAYJOIN_PROPOSAL.clone(), + }, + }, + session_context: SHARED_CONTEXT.clone(), + }; + let cases = [ + ( + PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), + SettlementOutcome::Payjoin, + SessionOutcome::PayjoinProposalSent, + ), + (fallback_tx(), SettlementOutcome::Fallback, SessionOutcome::Aborted), + (respent(fallback_tx()), SettlementOutcome::Unrecognized, SessionOutcome::Aborted), + ]; + for (tx, outcome, closed) in cases { + let settlement = receiver.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(settlement.outcome(), outcome); + assert_eq!(close_events(settlement), vec![SessionEvent::Closed(closed)]); + } + } + + #[test] + fn test_settlement_close_before_proposal_is_aborted() { + let receiver = Receiver { + state: maybe_inputs_owned_v2_from_test_vector(), + session_context: SHARED_CONTEXT.clone(), + }; + let cases = [ + (fallback_tx(), SettlementOutcome::Fallback), + (respent(fallback_tx()), SettlementOutcome::Unrecognized), + (PARSED_PAYJOIN_PROPOSAL.unsigned_tx.clone(), SettlementOutcome::Unrecognized), + ]; + for (tx, outcome) in cases { + let settlement = receiver.classify_tx(&tx).expect("tx spends the contested prevouts"); + assert_eq!(settlement.outcome(), outcome); + assert_eq!( + close_events(settlement), + vec![SessionEvent::Closed(SessionOutcome::Aborted)] + ); + } + } + + #[test] + fn test_receive_session_classify_tx() { + let monitor = segwit_monitor(); + assert_eq!( + ReceiveSession::Monitor(monitor.clone()).classify_tx(&fallback_tx()), + monitor.classify_tx(&fallback_tx()) + ); + + let initialized = ReceiveSession::Initialized(Receiver { + state: Initialized {}, + session_context: SHARED_CONTEXT.clone(), + }); + assert_eq!(initialized.classify_tx(&fallback_tx()), None); + let pending_fallback = ReceiveSession::PendingFallback(Receiver { + state: PendingFallback { fallback_tx: fallback_tx() }, + session_context: SHARED_CONTEXT.clone(), + }); + assert_eq!(pending_fallback.classify_tx(&fallback_tx()), None); + assert_eq!( + ReceiveSession::Closed(SessionOutcome::Aborted).classify_tx(&fallback_tx()), + None + ); + } + #[test] fn test_v2_mutable_receiver_state_closures() { let persister = InMemoryPersister::default(); diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 63fd3c3da..0354c4d9f 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -182,11 +182,17 @@ impl SessionHistory { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum SessionStatus { + /// The session is open and has not expired. Active, + /// The session expired before it was closed. Expired, + /// The session closed as [`SessionOutcome::Aborted`]. Failed, + /// The session closed after the Payjoin proposal was sent. Completed, + /// The session closed as [`SessionOutcome::FallbackBroadcasted`]. FallbackBroadcasted, + /// The session was cancelled or failed and holds a fallback transaction to broadcast. PendingFallback, } @@ -215,29 +221,36 @@ pub enum SessionEvent { Closed(SessionOutcome), } -/// Represents all possible outcomes for a closed Payjoin session +/// Represents all possible outcomes for a closed Payjoin session. +/// +/// Outcomes describe the protocol, not which transaction settled the payment. /// /// This enum is deliberately exhaustive: a terminal outcome is a closed set, and a wildcard arm /// has no meaningful semantics for a caller deciding what a session's conclusion was. A new /// unhandled variant _should_ error at compile-time. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SessionOutcome { - /// Payjoin completed successfully: the transaction with this txid settled the session + /// The Payjoin transaction with this txid was found. + /// + /// Only produced by the deprecated [`Receiver::check_for_transaction`]. + /// + /// [`Receiver::check_for_transaction`]: super::Receiver::check_for_transaction Success(bitcoin::Txid), - /// Payjoin was not successful + /// The session ended without delivering the Payjoin proposal, or was cancelled. + /// + /// The fallback may still have paid the receiver. Aborted, - /// Fallback transaction was broadcasted + /// The fallback transaction was found. + /// + /// Only produced by the deprecated [`Receiver::check_for_transaction`]. + /// + /// [`Receiver::check_for_transaction`]: super::Receiver::check_for_transaction FallbackBroadcasted, - /// Payjoin proposal was sent, but its broadcast status cannot be tracked because - /// the sender is using non-SegWit inputs which will change the transaction ID - /// of the proposal + /// The Payjoin proposal was delivered to the sender. PayjoinProposalSent, - /// The contested outpoints were settled by an unrecognized transaction (neither the - /// Payjoin nor the fallback), identified by its txid. + /// The sender's inputs were spent by an unrecognized transaction with this txid. /// - /// NOTE: Nothing in this release produces this variant. It is reserved now because this enum is - /// deliberately exhaustive, so a new variant after 1.0 would be a semver-breaking API - /// change; naming it before the freeze lets the settlement classifier land additively. + /// NOTE: Nothing produces this variant. It is reserved because this enum is exhaustive. Unrecognized(bitcoin::Txid), } @@ -261,6 +274,61 @@ mod tests { }; use crate::receive::{InternalPayloadError, PayloadError}; + /// The events of a session that posted its Payjoin proposal. + fn posted_proposal_events(session_context: SessionContext) -> Vec { + let persister = InMemoryPersister::::default(); + let original = original_from_test_vector(); + let maybe_inputs_owned = unchecked_receiver_from_test_vector() + .assume_interactive_receiver() + .save(&persister) + .expect("Save should not fail"); + let outputs_unknown = maybe_inputs_owned + .check_inputs_not_owned(&mut |_| Ok(false)) + .save(&persister) + .expect("No inputs should be owned") + .check_no_inputs_seen_before(&mut |_| Ok(false)) + .save(&persister) + .expect("No inputs should be seen before"); + let wants_outputs = outputs_unknown + .identify_receiver_outputs(&mut |_| Ok(true)) + .save(&persister) + .expect("Outputs should be identified"); + let wants_inputs = + wants_outputs.commit_outputs().save(&persister).expect("Save should not fail"); + let wants_fee_range = + wants_inputs.clone().commit_inputs().save(&persister).expect("Save should not fail"); + let provisional_proposal = wants_fee_range + .clone() + .apply_fee_range(None, None) + .save(&persister) + .expect("Fee range should apply"); + let payjoin_proposal = provisional_proposal + .clone() + .finalize_proposal(|psbt| Ok(psbt.clone())) + .save(&persister) + .expect("Payjoin proposal should be finalized"); + + vec![ + SessionEvent::Created(session_context), + SessionEvent::RetrievedOriginalPayload { original, reply_key: None }, + SessionEvent::CheckedBroadcastSuitability(), + SessionEvent::CheckedInputsNotOwned(), + SessionEvent::CheckedNoInputsSeenBefore(), + SessionEvent::IdentifiedReceiverOutputs(vec![0]), + SessionEvent::CommittedOutputs { + outputs: wants_inputs.state.inner.proposal.payjoin_psbt.unsigned_tx.output.clone(), + change_vout: wants_inputs.state.inner.proposal.change_vout, + }, + SessionEvent::CommittedInputs { + receiver_inputs: wants_fee_range.state.inner.proposal.receiver_inputs.clone(), + payjoin_psbt: wants_fee_range.state.inner.proposal.payjoin_psbt.clone(), + }, + SessionEvent::AppliedFeeRange(provisional_proposal.state.psbt_context.clone()), + SessionEvent::FinalizedProposal(payjoin_proposal.psbt().clone()), + SessionEvent::PostedPayjoinProposal(), + ] + } + fn unchecked_receiver_from_test_vector() -> Receiver { Receiver { state: UncheckedOriginalPayload { original: original_from_test_vector() }, @@ -1226,4 +1294,23 @@ mod tests { Ok(()) } + + #[test] + fn posted_session_past_expiration_replays_as_expired() { + let expiration = (SystemTime::now() - Duration::from_secs(1)).try_into().unwrap(); + let session_context = SessionContext { expiration, ..SHARED_CONTEXT.clone() }; + + let persister = InMemoryPersister::::default(); + let events = posted_proposal_events(session_context); + for event in events.clone() { + persister.save_event(event).expect("in memory persister save should not fail"); + } + + let err = replay_event_log(&persister).expect_err("a posted session should expire"); + assert!(err.is_expired()); + let history = SessionHistory::new(events); + assert_eq!(err.expiry_fallback_tx().cloned(), history.fallback_tx()); + assert!(history.fallback_tx().is_some()); + assert_eq!(history.status(), SessionStatus::Expired); + } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 889528645..2df5f21b6 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -206,7 +206,8 @@ mod integration { use payjoin::persist::OptionalTransitionOutcome; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, Monitor, PayjoinProposal, - ReceiveSession, Receiver, ReceiverBuilder, SessionStatus, UncheckedOriginalPayload, + ReceiveSession, Receiver, ReceiverBuilder, SessionStatus, SettlementOutcome, + UncheckedOriginalPayload, }; use payjoin::send::v2::{replay_event_log as replay_sender_event_log, SenderBuilder}; use payjoin::send::ResponseError; @@ -563,21 +564,22 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver cannot validate that the sender has broadcasted the Payjoin proposal or the fallback transaction. - // The sender is using a non-SegWit address, so their signature is going to change the TXID. So we test whether the - // function exists early and does not call the closure. - monitoring_payment - .check_for_transaction(|_| { - panic!("when the sender is using a non-SegWit address type, the check_for_transaction function should skip the check and return success") - }) + // The sender's non-SegWit signature changes the Payjoin txid. + let settlement = monitoring_payment + .classify_tx(&broadcasted_transaction) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::PayjoinProposalSent)), - "The last event of the persister should be a SessionOutcome::PayjoinProposalSent since the sender is going to change the TXID when they sign the Payjoin proposal", + Some(payjoin::receive::v2::SessionEvent::Closed( + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent + )), ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -620,31 +622,25 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the Payjoin proposal. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => { - panic!("should be able to find the payjoin proposal broadcasted") - } - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), Some(payjoin::receive::v2::SessionEvent::Closed( - payjoin::receive::v2::SessionOutcome::Success( - broadcasted_transaction.compute_txid() - ) + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent )), - "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -695,31 +691,25 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the Payjoin proposal. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => { - panic!("should be able to find the payjoin proposal broadcasted") - } - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Payjoin); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), Some(payjoin::receive::v2::SessionEvent::Closed( - payjoin::receive::v2::SessionOutcome::Success( - broadcasted_transaction.compute_txid() - ) + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent )), - "The last event of the persister should be a SessionOutcome::Success with the settlement txid", ); assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) @@ -765,30 +755,27 @@ mod integration { Amount::from_btc(0.0)? ); - // Receiver should be able to validate that the sender has broadcasted the fallback transaction. - // The check_for_transaction closure should be called twice: first for the Payjoin proposal, which will not be found, - // and then for the fallback transaction, which will be found.. - monitoring_payment - .check_for_transaction(|txid| { - let get_tx_result = receiver.get_raw_transaction(txid); - match get_tx_result { - Ok(tx) => - Ok(Some(tx.transaction().expect("transaction should be decodable"))), - Err(_) => Ok(None), - } - }) + let found_tx = receiver + .get_raw_transaction(broadcasted_transaction.compute_txid())? + .transaction() + .expect("transaction should be decodable"); + let settlement = monitoring_payment + .classify_tx(&found_tx) + .expect("the transaction settles the session"); + assert_eq!(settlement.outcome(), SettlementOutcome::Fallback); + settlement + .close() .save(&recv_persister) - .expect("receiver should successfully monitor for the payment"); + .expect("receiver should successfully close the session"); - // Receiver session should have completed with a Success and a fallback session - // outcome. let (_session, session_history) = replay_receiver_event_log(&recv_persister)?; assert_eq!( recv_persister.load().unwrap().last(), - Some(payjoin::receive::v2::SessionEvent::Closed(payjoin::receive::v2::SessionOutcome::FallbackBroadcasted)), - "The last event of the persister should be a SessionOutcome::Success with the correct sender signature", + Some(payjoin::receive::v2::SessionEvent::Closed( + payjoin::receive::v2::SessionOutcome::PayjoinProposalSent + )), ); - assert_eq!(session_history.status(), SessionStatus::FallbackBroadcasted); + assert_eq!(session_history.status(), SessionStatus::Completed); Ok(()) }