Skip to content

Receiever Monitor Typestate#1061

Merged
arminsabouri merged 1 commit intopayjoin:masterfrom
arminsabouri:monitoring
Oct 2, 2025
Merged

Receiever Monitor Typestate#1061
arminsabouri merged 1 commit intopayjoin:masterfrom
arminsabouri:monitoring

Conversation

@arminsabouri
Copy link
Collaborator

@arminsabouri arminsabouri commented Sep 11, 2025

Payjoin Monitor

A payjoin session is closed once there is nothing more to do in the
protocol but wait for expiry, or confirmation of one of the possible
transactions to settle the outcome.

This commit introduces a monitoring typestate as the final receiver
typestate. Monitoring checks for the confirmation of session
relevant transactions. Concretely we check for:

  • The payjoin proposal via txid if all inputs are segwit.
  • The fallback tx via txid.
  • The payjoin proposal via spent outpoints if at least one input is not
    segwit.
  • Any spent outpoint which would indicate a double spend of an outpoint
    that was used in the payjoin.

The typestate accepts two fallbacks:

  • transaction_exists(txid)
  • outpoint_spent(outpoint)

If a broadcasted payjoin proposal was detected the session will close
and the success event will include the sender scriptsig and/or
witness. Fallbacks and double spends will also close the session and
the event logged will include which condition closed the session.

Related to #807

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls
Copy link
Collaborator

coveralls commented Sep 17, 2025

Pull Request Test Coverage Report for Build 18196173117

Details

  • 289 of 323 (89.47%) changed or added relevant lines in 5 files are covered.
  • 8 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.09%) to 84.652%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin-cli/src/app/v2/mod.rs 17 24 70.83%
payjoin/src/core/receive/v2/mod.rs 150 159 94.34%
payjoin-cli/src/app/wallet.rs 12 30 40.0%
Files with Coverage Reduction New Missed Lines %
payjoin-cli/src/app/v2/mod.rs 8 59.23%
Totals Coverage Status
Change from base Build 18176477486: 0.09%
Covered Lines: 8968
Relevant Lines: 10594

💛 - Coveralls

@spacebear21 spacebear21 mentioned this pull request Sep 17, 2025
2 tasks
@arminsabouri arminsabouri force-pushed the monitoring branch 2 times, most recently from 3d3d801 to d06b610 Compare September 19, 2025 16:25
Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

Approach ACK. I agree using callbacks in monitor makes sense as it's cohesive with the rest of the Receiver state machine.

@arminsabouri arminsabouri added this to the payjoin-1.0 milestone Sep 22, 2025
@arminsabouri arminsabouri self-assigned this Sep 22, 2025
@arminsabouri
Copy link
Collaborator Author

@spacebear21 does this error make any sense to you. Coming from dart test

00:00 +0 -2: loading test/test_payjoin_unit_test.dart [E]
  Failed to load "test/test_payjoin_unit_test.dart":
  lib/payjoin_ffi.dart:6459:50: Error: The getter 'length' isn't defined for the class 'RustBuffer'.
   - 'RustBuffer' is from 'package:payjoin_dart/payjoin_ffi.dart' ('lib/payjoin_ffi.dart').
  Try correcting the name to the name of an existing getter, or defining a getter or field named 'length'.
              final buffer = Uint8List(1 + lowered.length);
                                                   ^^^^^^
  lib/payjoin_ffi.dart:6459:40: Error: The argument type 'num' can't be assigned to the parameter type 'int'.
              final buffer = Uint8List(1 + lowered.length);
                                         ^

@spacebear21
Copy link
Collaborator

This is a bug in uniffi-dart, looks like a typo possibly... Ignore for now while I investigate with @chavic

@spacebear21
Copy link
Collaborator

Confirmed locally that it was a simple typo, I opened a fix PR here: Uniffi-Dart/uniffi-dart#85

You could change the uniffi-dart git rev in Cargo.toml to point to that branch to unblock CI in the meantime until it gets merged.

spacebear21 added a commit to spacebear21/rust-payjoin that referenced this pull request Sep 24, 2025
The latest merge fixed a bug in callbacks that return Option<T>, which
popped up in payjoin#1061
@spacebear21 spacebear21 mentioned this pull request Sep 24, 2025
2 tasks
spacebear21 added a commit to spacebear21/rust-payjoin that referenced this pull request Sep 24, 2025
`430fe0b` fixed a bug in callbacks that return Option<T>, which popped
up in payjoin#1061
spacebear21 added a commit to spacebear21/rust-payjoin that referenced this pull request Sep 24, 2025
`430fe0b` fixed a bug in callbacks that return Option<T>, which popped
up in payjoin#1061
@arminsabouri arminsabouri force-pushed the monitoring branch 4 times, most recently from 2ecd9de to 8ed4389 Compare September 29, 2025 16:13
@arminsabouri arminsabouri changed the title WIP - monitor typestate Receiever Monitor Typestate Sep 29, 2025
@arminsabouri arminsabouri marked this pull request as ready for review September 29, 2025 16:14
Comment on lines +1132 to +1232
return MaybeFatalOrSuccessTransition::transient(Error::Implementation(
ImplementationError::from(format!("Payjoin transaction ID mismatch. Expected: {payjoin_txid}, Got: {tx_id}").as_str()),
));
Copy link
Collaborator Author

@arminsabouri arminsabouri Sep 29, 2025

Choose a reason for hiding this comment

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

Is this an implementation error? I think it should be an API error.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not following why this branch should error, it says "If we have a payjoin transaction with segwit inputs, we can check for the txid". But here wouldn't any non-segwit transaction always fail the check and immediately return a transient error?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The error branch here indicates that the user has confirmed the payjoin was broadcasted but they gave the API an entirely different transaction -- meaning we cannot close the session with the correct witness/scriptsig

But here wouldn't any non-segwit transaction always fail the check and immediately return a transient error?

No b/c a tx with non-segwit inputs should hit the Ok(None) branch

Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

There is a typo in the commit message: "The typestate accepts two fallbacks" should be "callbacks".

Left code/approach comments, I haven't looked closely at FFI changes or the tests but will review those more closely in a second pass.

proposal: Receiver<Monitor>,
persister: &ReceiverPersister,
) -> Result<()> {
// On a session resumption, the receiver will resume again in this state.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Isn't this implicit for all of these helper methods?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah this comment is out of place. My intention was to illustrate that we are not explictly not looping and sleeping here. I'll just remove this comment.

where
Err: std::error::Error,
{
#[cfg(test)]
Copy link
Collaborator

Choose a reason for hiding this comment

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

why were these changed to test-only?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously they were being used by the last typestate of the receiver statemachine. That typestate now uses a different state transition.

Comment on lines +1163 to +1262
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
SessionOutcome::FallbackBroadcasted,
Copy link
Collaborator

Choose a reason for hiding this comment

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

In Payjoin terms shouldn't this be considered a Failure? The wallet may consider the payment successful but a Payjoin did not occur.

Copy link
Collaborator Author

@arminsabouri arminsabouri Sep 30, 2025

Choose a reason for hiding this comment

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

I was going back and forth on this. Will change to a plain failure

Edit: the outcome of this comment depends on #1061 (comment)

Comment on lines 1170 to 1278
let mut outpoints_spend = vec![];
for ot in payjoin_proposal.unsigned_tx.input.iter() {
match outpoint_spent(ot.previous_output) {
Ok(false) => {}
Ok(true) => outpoints_spend.push(ot.previous_output),
Err(e) =>
return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
}
}

if outpoints_spend.len() == payjoin_proposal.unsigned_tx.input.len() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could this be simplified to:

if payjoin.proposal.unsigned_tx.input.iter().all(|ot| 
            match outpoint_spent(ot.previous_output) {
                Ok(detected) => detected,
                Err(e) =>
                    return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
            }
) {
// All the payjoin proposal outpoints were spent. This means our payjoin proposal has non-segwit inputs and is broadcasted.
// ...
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Would this not complicate the else if block? We would have to interate once more and is .some()

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah yeah I missed that block. Maybe something like iter().filter(|ot| ...).count(); would work, I think an iterator looks cleaner but this is a nit.

if outpoints_spend.len() == payjoin_proposal.unsigned_tx.input.len() {
// All the payjoin proposal outpoints were spent. This means our payjoin proposal has non-segwit inputs and is broadcasted.
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
// TODO: there seems to be not great way to get the tx of the tx that spent these outpoints.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you mean the signatures? What if outpoints_spend returned a Option<TxOut> or similar instead of a bool?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Option

My comment is suggesting that there is no way to get the tx that spent an outpoint/utxo -- we would then use that tx to get the missing witness/scriptsig. Bitcoind rpc only returns a non-null value when the txout exists in the utxoset db
https://bitcoincore.org/en/doc/29.0.0/rpc/blockchain/gettxout/

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see, that seems like a bitcoind implementation detail though? I'm not sure our API should make this baked in assumption. In theory the callback would need to return Result<Option<TxIn>, ImplementationError>, and the question becomes "how to obtain those TxIn with the RPC"?

Copy link
Collaborator Author

@arminsabouri arminsabouri Oct 2, 2025

Choose a reason for hiding this comment

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

In theory the callback would need to return Result<Option, ImplementationError>,

We already have the TxIn. The questions is if the outpoint is still in the utxoset. i.e spent.

and the question becomes "how to obtain those TxIn with the RPC"?

yes exactly. Implementers would need to keep an index of outpoint/utxo to txid. Which is uncommon. Alternatively, implementers can look up the txid by having index of what txs paid them. This is more common however, outside the scope of the API

Comment on lines +224 to +180
/// Fallback transaction was broadcasted
FallbackBroadcasted,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure that this new variant is necessary, wouldn't a Failure or Cancel be appropriate?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I guess the answer depends on wether or not we want a dedicated fallback broadcasted session status.

pub fn status(&self) -> SessionStatus {

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should be able to deduce that a fallback tx was broadcasted by replaying the event log up to the monitor typestate if that results in a Failure? Therefore a distinct outcome doesn't provide additional information. I would like @nothingmuch 's input on this though if he's available.

@arminsabouri arminsabouri force-pushed the monitoring branch 2 times, most recently from 79a03c1 to 7bc0138 Compare October 1, 2025 18:15
AppliedFeeRange(PsbtContext),
FinalizedProposal(bitcoin::Psbt),
GotReplyableError(JsonReply),
PostedPayjoin(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: PostedPayjoinProposal()

/// TODO: this should include the sender's witnesses once tx monitoring is implemented
/// (this will ensure the payjoin txid can be computed)
Success,
Success(Vec<(bitcoin::ScriptBuf, bitcoin::Witness)>),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need to expose a txid helper that reconstructs the final txid from the payjoin psbt + sender signatures?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes and no. witness/scripsig is useful incase we have to re-broadcast the tx as the receiver. However, if segwit inputs are used then we do not need to reconstruct the txid. If non-segwit inputs are used we will need to reconstruct the txid but it seems impractical to get the witness/scripsig for the sender inputs

re: #1061 (comment)

Comment on lines +224 to +180
/// Fallback transaction was broadcasted
FallbackBroadcasted,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should be able to deduce that a fallback tx was broadcasted by replaying the event log up to the monitor typestate if that results in a Failure? Therefore a distinct outcome doesn't provide additional information. I would like @nothingmuch 's input on this though if he's available.

Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

We should follow up with integration tests on the various monitoring outcomes: payjoin tx broadcasted, fallback tx broadcasted, doublespend detected, segwit/non-segwit.... seems important to get these right.

A payjoin session is closed once there is nothing more to do in the
protocol but wait for expiry, or confirmation of one of the possible
transactions to settle the outcome.

This commit introduces a monitoring typestate as the final receiver
typestate. Monitoring checks for the confirmation of session
relevant transactions. Concretely we check for:
* The payjoin proposal via txid if all inputs are segwit.
* The fallback tx via txid.
* The payjoin proposal via spent outpoints if at least one input is not
  segwit.
* Any spent outpoint which would indicate a double spend of an outpoint
  that was used in the payjoin.

The typestate accepts two callbacks:
* `transaction_exists(txid)`
* `outpoint_spent(outpoint)`

If a broadcasted payjoin proposal was detected the session will close
and the success event will include the sender `scriptsig` and/or
`witness`. Fallbacks and double spends will also close the session and
the event logged will include which condition closed the session.
Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

code ACK 0c67af7

I'm still on the fence about SessionOutcome::FallbackBroadcasted vs. using Failure but that doesn't need to block this PR from getting merged, we can make a note to discuss this on the next dev call.

@arminsabouri arminsabouri merged commit ada6334 into payjoin:master Oct 2, 2025
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants