Skip to content

Compute (potentially reorging) Settlement Status From Wallet Data#1692

Draft
DanGould wants to merge 6 commits into
payjoin:masterfrom
DanGould:settlement-status
Draft

Compute (potentially reorging) Settlement Status From Wallet Data#1692
DanGould wants to merge 6 commits into
payjoin:masterfrom
DanGould:settlement-status

Conversation

@DanGould

@DanGould DanGould commented Jun 29, 2026

Copy link
Copy Markdown
Member

This is following up on #1684 where I figured out Monitor's status can go stale in the case of a reorg. Instead, we can run a classifier that can always classify the status based on mempool+chain state data instead.

This classification, that tracks OutPoint status also makes the #1218 workaround that skips monitoring the status of non-segwit outputs obsolete and can actually monitor non-segwit spending Payjoin cooperative txs.

Sessions still close once a Receiver<Monitor> detects a transaction but the actual SettlementStatus is determined by calling classify on chain state each time.

Co-authored by Claude code

Pull Request Checklist

Please confirm the following before requesting review:

@DanGould
DanGould force-pushed the settlement-status branch from 8832e47 to e7c0dde Compare June 29, 2026 17:03
@coveralls

coveralls commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29565128648

Coverage increased (+0.3%) to 86.441%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 40 uncovered changes across 2 files (495 of 535 lines covered, 92.52%).
  • 2 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
payjoin-cli/src/app/v2/mod.rs 73 37 50.68%
payjoin/src/core/receive/v2/mod.rs 429 425 99.07%
Total (4 files) 535 495 92.52%

Coverage Regressions

2 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
payjoin-cli/src/app/v2/mod.rs 1 57.7%
payjoin/src/core/receive/v2/mod.rs 1 93.48%

Coverage Stats

Coverage Status
Relevant Lines: 16432
Covered Lines: 14204
Line Coverage: 86.44%
Coverage Strength: 335.74 hits per line

💛 - Coveralls

@spacebear21

Copy link
Copy Markdown
Collaborator

IMO such an API change should wait until after 1.0 release and is a candidate for a 2.0 API:

  • Like you said in Rename Receiver<Monitor>::check_for_broadcast to check_for_transaction, etc. #1684: "I doubt that this updated follow-up Receiver change is critically necessary for 1.0. What we've shipped seems good enough for mobile wallets to get status". We already have plenty of demand from mobile wallets and releasing a stable API now seems like a bigger win than pushing that deadline further while we debate the exact monitor shape.
  • We already have a big breaking API rewrite in the works with Non-blocking Interface for Payjoin State Machine  #1446, that would abandon the callback pattern for advancing the state machine in favor of a validator pattern where the typestate machine is advanced explicitly by providing the result of the check explicitly. That seems more inline with the Monitor approach in this PR and those changes can all be bundled into a comprehensive, well thought-out 2.0 follow-up.

In the meantime, I think it's sufficient to add a big disclaimer in the check_for_transaction making it clear that it:

@chavic

chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Field evidence in favor of this direction, from the BTCPay Server payjoin plugin (a settlement-accounting receiver over payjoin-ffi): we independently hit both flaws this PR identifies. Our accounting layer pins the proposal's expected transaction ID at hand-out time for exact reconciliation, which is silently wrong for non-SegWit senders (we currently reject them as policy, ValeraFinebits/btcpayserver-payjoin-plugin#78), and our stored reconciliation verdict shares the stale-on-reorg problem — a re-runnable classify() over contested outpoints would replace both host-side workarounds, and the Cooperative cut-through recognition covers a case txid-pinning can't see at all.

In the meantime, for whatever it's worth to the 1.0 discussion: #1718 proposes the machine-readable version of the disclaimer suggested above — proposal_txid_is_stable() on the fallback-holding states, additive-only and shape-neutral with respect to this PR; it degrades into a convenience if this lands.

@spacebear21 spacebear21 added this to the payjoin-2.0 milestone Jul 9, 2026
@benalleng benalleng added the receive receiving payjoin label Jul 16, 2026
@DanGould
DanGould force-pushed the settlement-status branch from e7c0dde to 47b49c5 Compare July 16, 2026 16:26
DanGould added 6 commits July 17, 2026 15:38
The persisted SessionEvent format freezes at the payjoin-cli 1.0
database-backwards-compatibility commitment, so reshape the receiver's
terminal SessionOutcome while a format break is still free.

Change Success(Vec<(ScriptBuf, Witness)>) to Success(Txid). The
witness payload was recorded so the receiver could reconstruct the
final Payjoin on the non-segwit path, but payjoin#1218 removed that path
entirely, leaving the enum payload as dead code. The Receiver settlement
classifier (payjoin#1692) will restore non-segwit detection, which would make
the payload live again, but sufficient record of a settlement is the txid
of the transaction that settled it rather than the witness list. The
wallet that observed the spend already holds the full transaction.
Recording Success(Txid) also keeps the terminal variants consistent
where the outcome for the transaction we recognize carries the same
evidence as the one we do not.

Add Other(Txid), mapped to SessionStatus::Failed, for contested
outpoints settled by an unrecognized transaction. Nothing produces it
yet. The reason to reserve it now is API semver, not data
compatibility. 8c92050 deliberately kept SessionOutcome exhaustive.
A wildcard match arm has no meaningful semantics for a terminal outcome,
and the compile error on a new variant is a feature. So adding a
variant after 1.0 would be a semver-breaking change that we want. Naming
Other before the freeze makes the settlement classifier additive by
construction while keeping the enum exhaustive. (Forward
compatibility of the persisted data is explicitly not a goal. See the
persist module docs, we want unknown state to error.)

Update payjoin-cli to match the new shapes exhaustively. no wildcard
arms, per 8c92050 surfacing the settlement txid in the history and
cancel output, and pin the sealed JSON encodings of the terminal
events in a serialization-shape test. StatusText becomes String rather
than &'static str in order to accomodate the txid in format!.
Introduce outpoint-based settlement attribution for a monitored
session. A pure, total classify maps caller-supplied chain facts to a
settlement status without any I/O or persisted events.

Add the compute types SettlementStatus and SettlementOutcome
{Cooperative(Txid), Fallback, Other(Txid)} plus ChainView/OutpointSpend,
and the primitive getters the wallet needs (payjoin_txid,
fallback_txid, contested_outpoints, receiver_input).

classify recognizes a cooperative settlement by either signal: the
spending transaction carries the receiver's contributed input (the
input fingerprint) or, for an output-substitution proposal, reproduces
the proposal's output set (a cut-through). Both are structural, so a
non-SegWit txid change does not hide them; Cooperative therefore
carries the observed settling txid, which in that case differs from
the predicted payjoin_txid and is otherwise unavailable to the caller
of a txid-oblivious classifier. Precedence is
Cooperative > Fallback > Other.

SettlementOutcome is #[non_exhaustive]. This is a deliberate exception
to 8c92050's keep-exhaustive list, judged by that commit's own test:
does a wildcard arm have meaningful semantics? For a *persisted*
terminal outcome it does not (SessionOutcome stays exhaustive). But
SettlementOutcome is a never-persisted classification verdict, and a
finer future label (say, distinguishing a sender re-spend from a
third-party spend) degrades soundly in a wildcard arm to "settled by
something this build cannot label -- surface for review". No session
state is resumed through it, so the failure mode 8c92050 guards
against does not arise. SettlementStatus itself stays exhaustive:
Pending/Detected is a closed shape.
Add Receiver<Monitor>::conclude, which persists the single terminal
Closed(SessionOutcome) event once the caller's confidence bar is met,
mapping Cooperative(txid) -> Success(txid), Fallback ->
FallbackBroadcasted, and Other(txid) -> Other(txid). The persisted
SessionOutcome shape (Success(Txid), the reserved Other(Txid)) was
sealed with the 1.0 format freeze; this wires the settlement
classifier to it and makes the classifier produce the reserved Other
variant, so drop the reservation note from its docs. The Success txid
is the observed settling transaction's, which for a non-SegWit sender
differs from the predicted payjoin txid the receiver could compute on
its own.

Re-express the legacy check_for_transaction over classify via a
txid-only ChainView: each found transaction is classified structurally
before concluding, instead of trusting the txid probe alone. The
consume-self signature and transient-error flow are unchanged.
Add the foreign-facing settlement surface: a ChainBackend trait that
reports each contested outpoint's spend status, the ChainSpend record,
and SettlementStatus/SettlementOutcome uniffi mirrors. Monitor::classify
queries the backend, builds a ChainView, and returns the status without
persisting anything. Cooperative mirrors the core variant's payload,
carrying the observed settling txid as a string.

SettlementOutcome is #[non_exhaustive] in core, so the conversion keeps
a wildcard arm that degrades a finer label this binding predates to
Other until the binding is regenerated.
Drive the monitor step from classify: build a ChainView by querying the
wallet for each contested outpoint (get_raw_transaction_verbose for the
spending tx and its confirmations), then render the status each tick as
Pending / cooperative / fallback / other -- surfacing the settling txid
for cooperative and other spends -- concluding once a configurable
confirmation bar is met. Status lines go through the session-scoped
printer so concurrently resumed sessions stay attributable.

SettlementOutcome is #[non_exhaustive], so the render match keeps a
wildcard arm that shows a generic settled line for any finer label
this build predates.
Spell out the Monitor contract so integrators do not trust an imprecise
0-conf verdict. The proposal and the fallback double-spend the sender's
inputs, so detection is necessary but not sufficient: only confirmation
resolves the race, and confirmation depth is a confidence dial, not a
state transition.

Document the division of labor (library knows the semantics, wallet
knows the ground truth and holds the spending transaction), the
classify/conclude flow, and that the legacy check_for_transaction
cannot surface Other and concludes optimistically on first sight.
@DanGould

Copy link
Copy Markdown
Member Author

Changed to be additive to target 1.1 on top of #1747

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

Labels

receive receiving payjoin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants