Skip to content

Add payjoin v2 integration (workspace + runtime + glue crate)#7

Draft
evanlinjin wants to merge 17 commits into
masterfrom
example/payjoin
Draft

Add payjoin v2 integration (workspace + runtime + glue crate)#7
evanlinjin wants to merge 17 commits into
masterfrom
example/payjoin

Conversation

@evanlinjin
Copy link
Copy Markdown
Owner

@evanlinjin evanlinjin commented May 14, 2026

Summary

Build out payjoin v2 as a first-class integration for bdk_tx. The work is split across three crates in a new workspace:

  • bdk_tx — surface the APIs needed to hand candidates to and absorb PSBTs from an external builder (payjoin, coinjoin, etc.)
  • payjoin_runtime (new) — sans-IO state machine driving payjoin v2 send/receive sessions. Depends only on payjoin, bitcoin, bitcoin-ohttp.
  • bdk_payjoin (new) — thin glue crate for callers who want bdk_tx candidates and plans handed to the runtime.

Repo restructure

Converted to a cargo workspace. The existing bdk_tx source moves under tx/; CI scripts already invoked cargo at workspace scope and are unchanged.

bdk_tx API additions

Driven by the friction of integrating with payjoin's InputPair::new and finalising counterparty-modified PSBTs:

  • Input::to_psbt_pair(fallback_sequence) -> (TxIn, psbt::Input) — the same shape Selection::create_psbt already builds internally, exposed for handing a wallet input to an external builder.
  • Input::expected_input_weight() -> Weight — total input weight, required by payjoin's InputPair::new for unsigned P2TR / P2WSH inputs whose witness data isn't inferable from the script type.
  • Finalizer::from_psbt(&Psbt, plan_for) + Finalizer::update_psbt — build a finalizer for an existing PSBT (one a counterparty just modified) and re-attach plan-derived fields (BIP32 / taproot origins) sanitized in transit.
  • Split is_spendable into try_is_spendable -> Option<bool> and is_spendable -> bool. The old Option<bool> forced every caller to invent its own default for the "unknowable" case; selection filters now get the false-default they want.
  • Flip default PsbtParams::fallback_sequence from ENABLE_LOCKTIME_NO_RBF to ENABLE_RBF_NO_LOCKTIME. RBF is the modern default; rustdoc covers when to override back.

payjoin_runtime (new crate)

A sans-IO driver for payjoin v2 receiver and sender sessions. Callers alternate:

  • poll() -> Step<E> — yields Step::{SendRequest, Backoff, Save, Done, Failed}
  • feed_response(bytes)

Two wallet traits inject wallet-aware decisions:

  • ReceiverWalletis_owned, check_broadcast, contribute, process_psbt (with fallback_sequence defaulted).
  • SenderWalletprocess_psbt, get_tx.

Step::Save(Vec<E>) surfaces every internal .save(&persister) call as an event batch the caller persists atomically. Callers who don't need durability ignore them with one line (Step::Save(_) => {}); callers who do can call resume_from_events to reconstruct a session mid-protocol.

Re-exports the upstream payjoin types that appear in the public surface (ReceiverBuilder, SenderBuilder, PjUri, OhttpKeys, Uri, Request) so consumers don't need a direct payjoin dep.

bdk_payjoin (new crate)

Glue layer for callers who want bdk_tx candidates / plans handed to the runtime:

  • input_pair_from(&Input, fallback_sequence) / input_pairs_from(&InputCandidates, fallback_sequence) — convert bdk_tx candidates into payjoin InputPairs, handling the P2TR / P2WSH weight quirk.
  • sign_and_finalize_with_plans(&mut Psbt, plan_for, sign) — sign-and-finalize helper that uses plans to restore PSBT fields the proposal sanitizes away.

Re-exports payjoin_runtime::*.

Example

payjoin/examples/v2.rs is the new home of the payjoin v2 example, rewritten on top of the runtime. The full sender/receiver flow runs end-to-end against the live directory + relay in ~10s (sender 44.99999632 BTC, receiver 54.99999999 BTC, ~369 sat fee). Example shrinks from ~530 lines to ~360.

Testing

  • payjoin/tests/runtime.rsinput_pair_from (P2TR vs P2WPKH paths, fallback-sequence propagation) and sign_and_finalize_with_plans.
  • runtime/tests/psbt.rsrestore_psbt_utxos (owned vs non-owned inputs, finalized-input skip rule).
  • runtime/tests/resume.rsresume_from_events for empty-log and closed-failure cases.
  • Live e2e via the example.

Mid-protocol resume tests need a constructible OhttpKeys, which upstream gates behind #[cfg(test)]; covered by the live e2e for now.

Credits

Builds on @aagbotemi's initial payjoin v2 example (commits 3a8d4b0, b215701).

noahjoeris and others added 15 commits April 29, 2026 19:51
…d drop unused errors

ab7f64b refactor(input-candidates): remove unused policy errors (Noah Joeris)
ccf36cd docs(input-candidates): clarify candidate caches (Noah Joeris)

Pull request description:

  ### Description

  - Document `InputCandidates` fields
  - Fix a typo
  - Remove unused public types `MissingOutputs` and `PolicyFailure<PF>`

ACKs for top commit:
  ValuedMammal:
    ACK ab7f64b
  evanlinjin:
    ACK ab7f64b

Tree-SHA512: b1dcf347917cfdbae7a38c3f728a2309f0c7efbffa7911de0335352aa9063473d596de5e8fa682081baf01e33cd77f099d9a3caad0ac78ef2a9511ecf4d6a55a
…to tasks

- Update payjoin dep from git master to the 1.0.0-rc.2 release.
- Refactor the example to spawn separate tokio tasks for sender and
  receiver, polling the directory concurrently. This matches the
  real-world flow where the two sides communicate only via the relay.
- Fix latent bug where the receiver contributed zero inputs from a
  taproot wallet: InputPair::new(_, _, None) errors with NotSupported
  for unsigned P2TR / P2WSH inputs (the satisfaction weight is
  unknowable without a signed witness). Pass an explicit
  expected_weight for those input types.
- Adjust the balance assertions to account for payjoin fee sharing
  (the sender does not necessarily pay the full network fee).
- Apply assorted bdk_tx API drift fixes from the master rebase
  (filter_unspendable, ChangeScript, SelectorParams shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add several APIs that came out of integrating bdk_tx with the payjoin
library. None of these are payjoin-specific, but the friction was
visible there:

- `Input::to_psbt_pair(fallback_sequence) -> (TxIn, psbt::Input)`
  Returns the same `(TxIn, psbt::Input)` shape that `Selection::create_psbt`
  builds internally for every selected input. Useful when handing a
  wallet input to an external builder (payjoin's `InputPair::new`,
  coinjoin coordinators, etc.) that doesn't know how to satisfy it.

- `Input::expected_input_weight() -> Weight`
  The total input weight (txin base + plan satisfaction) for handing
  to counterparties that need an explicit weight. Payjoin's
  `InputPair::new` requires this for unsigned P2TR / P2WSH inputs
  because their witness data isn't inferable from the script type.

- `Finalizer::from_psbt(&Psbt, plan_for)` and `Finalizer::update_psbt`
  Build a finalizer for an existing PSBT (e.g. one a counterparty just
  modified) by looking up plans per outpoint, and re-attach
  plan-derived fields (bip32 / taproot origins) to PSBT inputs that
  were sanitized in transit.

- Split `is_spendable` into `try_is_spendable -> Option<bool>` and
  `is_spendable -> bool`. The old `Option<bool>` was a footgun — every
  caller had to invent its own default for the "unknowable" case. The
  bool variant defaults the unknown case to `false`, which is what
  coin selection filters want. Renamed `InputGroup::is_spendable` the
  same way and updated the one internal caller (`filter_unspendable`).

- Flip the default `PsbtParams::fallback_sequence` from
  `ENABLE_LOCKTIME_NO_RBF` to `ENABLE_RBF_NO_LOCKTIME`. Modern wallets
  and payjoin senders both want RBF by default; the previous default
  forced every caller to override it. The rustdoc spells out when to
  override back to locktime-only semantics.

The payjoin_v2 example drops ~30 lines that previously open-coded the
PSBT-input construction and finalize-from-external-PSBT logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move every crate file (src/, examples/, tests/, Cargo.toml) under tx/
and add a root workspace manifest. No source changes — `git mv`
preserves history. CI scripts and workflows continue to work
unchanged since they invoke cargo with workspace-wide commands
(`cargo build --all-targets`, `cargo test`, etc.).

This sets up the repo to host sister crates that depend on bdk_tx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A small new crate that encapsulates the conventions shared by `bdk_tx`
and `payjoin` so callers don't have to discover them by hand.

The public surface is two functions for the receiver's
contribute-inputs step:

- `input_pair_from(&Input, fallback_sequence) -> Option<InputPair>`
  Turns one bdk_tx candidate into a payjoin `InputPair`. Handles the
  P2TR / P2WSH weight quirk: payjoin's `InputPair::new` cannot infer
  the witness weight for an unsigned taproot or witness-script-hash
  input, so we pass the explicit weight derived from the spending
  plan. For input types it can infer (P2WPKH, P2PKH, nested
  P2SH-P2WPKH) we pass `None` because `Some` would be rejected as
  `ProvidedUnnecessaryWeight`.

- `input_pairs_from(&InputCandidates, fallback_sequence) -> Vec<InputPair>`
  The same conversion applied to every candidate in a filter chain.

For the sender side no glue is needed: `bdk_tx::Finalizer::from_psbt`
plus `Finalizer::update_psbt` already cover finalising a PSBT a
counterparty just modified.

The payjoin v2 example moves out of `tx/examples/` into
`payjoin/examples/v2.rs` and now uses these helpers, dropping the
hand-rolled InputPair construction. The matching dev-dependencies
(payjoin, url, tokio, reqwest) move from `tx/Cargo.toml` to
`payjoin/Cargo.toml` so `bdk_tx` no longer pulls them in for its own
tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote bdk_payjoin from a thin glue layer to a high-level runtime for
the payjoin v2 protocol. The two new state machines own the multi-stage
typestate, the OHTTP polling cycle, and every `.save(&persister)` call
internally. Callers drive each session by alternating `poll()` (yields
`Step::{SendRequest, Backoff, Done, Failed}`) with `feed_response(bytes)`,
which keeps the runtime transport-agnostic — no `reqwest`/`tokio` in the
lib's direct deps.

Wallet-aware decisions are injected through two new traits:

- `ReceiverWallet`: `is_owned`, `check_broadcast`, `contribute`,
  `plan_of_output`, `sign`, plus a `fallback_sequence` default impl.
  `contribute` returns a `bdk_tx::InputCandidates`, which keeps the
  runtime independent of `CanonicalUnspents` (the caller assembles
  candidates however they like — `Wallet::all_candidates()` today,
  `bdk_chain::CanonicalView` tomorrow).

- `SenderWallet`: `plan_of_output`, `prev_tx`, `sign`. The runtime
  drives finalization (`Finalizer::from_psbt` + `update_psbt` +
  `finalize`) and extraction; the impl only needs to add signatures.

Also exposes `restore_psbt_utxos(psbt, plan_lookup, prev_tx_lookup)` —
the helper that puts back `witness_utxo` / `non_witness_utxo` fields
the proposal sanitizes away. The sender state machine calls it
internally; users driving the protocol manually can call it directly.

Re-exports the payjoin types that appear in public signatures
(`ReceiverBuilder`, `SenderBuilder`, `PjUri`, `OhttpKeys`, `Uri`,
`Request`) so consumers don't need a direct `payjoin` dep.

The low-level `input_pair_from` / `input_pairs_from` helpers stay
public for callers that want partial glue without the full runtime.

The example still uses the old manual-driving code; the rewrite to
`{Receiver,Sender}Session` lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The example shrinks from ~530 lines to ~360. Each role is now built
around an adapter struct (`RecvAdapter`, `SendAdapter`) that implements
`ReceiverWallet` / `SenderWallet` over the existing example `Wallet`,
plus a generic `drive_*` helper that turns sans-IO `Step`s into reqwest
calls. The OHTTP polling loop, the `.save(&persister)` ceremony, the
5-stage receiver check sequence, and the proposal-finalize-and-extract
dance all move into the runtime. A 10s e2e run against the live
directory + relay still passes (sender ends at 44.99999632 BTC,
receiver at 54.99999999 BTC, ~369 sat fee).

Fixed one real bug in `receiver::process_original` along the way: the
`finalize_proposal` closure was returning an error when
`f.finalize(&mut psbt).is_finalized()` came back false, but by design
the receiver only signs/finalizes its own contributed inputs — the
sender's inputs are supposed to be left unfinalized at this point.
Drop the check and let the runtime continue. The previous (manually
driven) example also tolerated this case; the runtime was over-strict.

Add `into_wallet(self) -> W` on both sessions so callers can recover
the wallet adapter after termination.

Tests in `payjoin/tests/runtime.rs` cover the transport-independent
helpers:

- `input_pair_from` on P2TR (explicit weight path) and P2WPKH
  (auto-weight path), plus fallback-sequence propagation.
- `restore_psbt_utxos` on owned vs. non-owned inputs, with the
  finalized-input skip rule.

End-to-end state-machine behaviour is covered by the example; smoke
tests for `ReceiverSession::poll` / `SenderSession::poll` would need a
constructible `OhttpKeys`, but upstream gates `FromStr for OhttpKeys`
behind `#[cfg(test)]`, so we defer those to a future change that adds
the bech32-decoding shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stand up `payjoin_runtime` under `runtime/` as the sans-IO payjoin v2
driver — depends only on `payjoin`, `bitcoin`, and `bitcoin-ohttp`. Slim
`bdk_payjoin` down to a thin `bdk_tx` ergonomics layer that re-exports
the runtime and adds three helpers.

Why: the prior `bdk_payjoin` carried the runtime *and* the bdk_tx
glue, but the runtime only used three bdk_tx items (`Finalizer` ×2,
`InputCandidates` ×1) and the wallet traits exposed bdk_tx-flavored
concepts (`Plan`, `OutPoint`-keyed plan lookup). Anyone not using
bdk_tx had to pull it in for no benefit. This split makes
`payjoin_runtime` upstreamable as a standalone payjoin v2 runtime,
and leaves `bdk_payjoin` as the bridge for the bdk_tx audience.

The wallet trait surface shrinks. Both traits collapse signing,
finalization (and, on the sender side, UTXO restoration) into a
single `process_psbt(&mut Psbt) -> Result<(), Error>` method. The
runtime no longer asks for `plan_of_output` or `prev_tx` — the
wallet decides how to do its own thing. The bdk_tx-flavored
implementation lives in `bdk_payjoin::sign_and_finalize_with_plans`
and is called from the example's adapters.

`restore_psbt_utxos` moves to the runtime, with its `plan_for`
parameter replaced by `is_owned: impl Fn(OutPoint) -> bool` so the
runtime doesn't depend on `miniscript`. Callers that have plans
just pass `|op| plan_for(op).is_some()`.

bdk_payjoin's public surface now:

  pub use payjoin_runtime::*;
  pub fn input_pair_from(input: &Input, fb_seq: Sequence) -> Option<InputPair>;
  pub fn input_pairs_from(candidates: &InputCandidates, fb_seq: Sequence) -> Vec<InputPair>;
  pub fn sign_and_finalize_with_plans(
      psbt: &mut Psbt,
      plan_for: impl Fn(OutPoint) -> Option<Plan>,
      sign: impl FnOnce(&mut Psbt) -> Result<(), Error>,
  ) -> Result<(), Error>;

Tests follow the code: `restore_psbt_utxos` tests move to
`runtime/tests/psbt.rs`; `input_pair_from` / `sign_and_finalize_with_plans`
stay in `payjoin/tests/runtime.rs`. Live e2e still works
(sender 44.99999632 BTC, receiver 54.99999999 BTC, 369 sat fee).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The body in every caller is literally `wallet.get_tx(txid)`. The shape
`Txid -> Option<Transaction>` is exactly a get-tx lookup, and
`prev_tx_of(txid)` was grammatically awkward — the txid itself is the
previous tx's identifier, so the "of" had no referent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make `Step` generic over the role's `SessionEvent` and add a new
`Step::Save(Vec<E>)` variant. Each internal `.save(&persister)` call
now goes through an internal `Capturing` persister that pushes events
into an `Arc<Mutex<Vec<E>>>` on the session; the next `poll()` drains
the queue as a single `Save` step before advancing state. The caller
persists the events atomically (or skips them) and calls `poll` again.

The drive-loop drop-in for callers who don't need durability is one
line — `Step::Save(_) => {}` — so existing usage stays trivial. For
callers who do need durability, the API now actually surfaces the
events instead of routing them into a hardcoded `NoopSessionPersister`.

Per-role events are exposed as `ReceiverSessionEvent` /
`SenderSessionEvent` re-exports. `Step<E>` matches the role's session
type: `ReceiverSession::poll() -> Step<ReceiverSessionEvent>`, same
for sender.

Adds stubbed `resume_from_events` on both sessions returning
`Error::Payjoin("not yet implemented")`. Real replay lands in a
follow-up commit using the already-written `persister::Replay` helper.

Verified end-to-end: the receiver emits 1 + 9 events (`Created`,
then the 9-event batch from `process_original`), the sender emits
1 + 1 + 1 (`Created`, `PostedOriginalPsbt`, `Closed`). Live e2e still
completes in ~10s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the stubs. `ReceiverSession::resume_from_events` and
`SenderSession::resume_from_events` now feed the saved event log into
payjoin's `replay_event_log` via an internal `persister::Replay`, map
the resulting `ReceiveSession` / `SendSession` discriminated union
back into our internal `State`, and return a session ready to continue
polling from that point.

State mapping:

  Receiver:
    Initialized        -> Polling { pending_backoff: false }
    PayjoinProposal    -> Posting (will re-send the POST on next poll)
    Closed(Success)    -> Done
    Closed(_)          -> Failed
    <anything else>    -> Error (event log was not persisted atomically
                                 per `Step::Save` batch; we have no clean
                                 re-entry point mid-`process_original`)

  Sender:
    WithReplyKey            -> PostingOriginal (will re-POST the original)
    PollingForProposal      -> PollingProposal
    Closed(Success(psbt))   -> Done, with `final_tx` / `fee` recomputed by
                               running `wallet.process_psbt` on the saved
                               proposal PSBT
    Closed(_)               -> Failed

Documents the atomic-persistence contract on each method: a `Step::Save`
batch must be persisted atomically. The receiver's `process_original`
emits 9 events in one batch; if the persistence layer torn-writes them,
resume rejects the log instead of guessing how to recover.

Unit tests in `runtime/tests/resume.rs` cover the empty-log and
closed-failure paths. Mid-protocol resume needs a real `OhttpKeys` to
construct events, which payjoin gates behind `#[cfg(test)]` upstream;
that path is exercised via the live e2e example for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin changed the title Clarify candidate caches and refactor payjoin example implementation Refactor payjoin example implementation May 14, 2026
@evanlinjin evanlinjin changed the title Refactor payjoin example implementation Add payjoin v2 integration (workspace + runtime + glue crate) May 14, 2026
evanlinjin and others added 2 commits May 14, 2026 18:49
The runtime state machine moved to rust-payjoin as a workspace member
alongside payjoin and payjoin-cli. bdk_payjoin now depends on it via
a relative-path sibling checkout; the dep can switch to git+branch
once the upstream branch is published on a fork.

The relocation came with an API redesign on the upstream side that
replaced the ReceiverWallet / SenderWallet traits with a Step-and-feed
pattern: every wallet decision (broadcast suitability, SPK ownership,
contributed inputs, PSBT signing) plus HTTP responses are now
ReceiverStep / SenderStep variants the caller dispatches on. The
example's drive loops were rewritten to the new shape, and the in-tree
runtime/ crate was deleted.

Also drop the no-op restore_psbt_utxos call from the sender arm:
payjoin's process_response already restores witness_utxo and
non_witness_utxo on the sender's inputs from the original PSBT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that evanlinjin/rust-payjoin holds the payjoin-runtime crate and
the integration branch, drop the relative-path workaround in favor of
a normal git dependency. Tracks local/payjoin-runtime-accessors until
the upstream PRs land and we can move to a crates.io release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DanGould
Copy link
Copy Markdown

Would a non-blocking / sans-io change to our state machine simplify your implementation here? We're strongly considering removing the closures

payjoin/rust-payjoin#1446

@evanlinjin
Copy link
Copy Markdown
Owner Author

Would a non-blocking / sans-io change to our state machine simplify your implementation here? We're strongly considering removing the closures

payjoin/rust-payjoin#1446

Yes it would!

Even the SessionPersister trait needed to be hacked around for the runtime implementation here. Check https://github.com/evanlinjin/rust-payjoin/blob/local/payjoin-runtime-accessors/payjoin-runtime/src/persister.rs

@DanGould
Copy link
Copy Markdown

Is this still true if you use the AsyncSessionPersister?

@evanlinjin
Copy link
Copy Markdown
Owner Author

Is this still true if you use the AsyncSessionPersister?

Yes because the trait is opinionated about how to IO. Ideally, we don't encapsulate anything that handles IO.

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.

4 participants