Add payjoin v2 integration (workspace + runtime + glue crate)#7
Draft
evanlinjin wants to merge 17 commits into
Draft
Add payjoin v2 integration (workspace + runtime + glue crate)#7evanlinjin wants to merge 17 commits into
evanlinjin wants to merge 17 commits into
Conversation
…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>
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>
|
Would a non-blocking / sans-io change to our state machine simplify your implementation here? We're strongly considering removing the closures |
Owner
Author
Yes it would! Even the |
|
Is this still true if you use the |
Owner
Author
Yes because the trait is opinionated about how to IO. Ideally, we don't encapsulate anything that handles IO. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 onpayjoin,bitcoin,bitcoin-ohttp.bdk_payjoin(new) — thin glue crate for callers who wantbdk_txcandidates and plans handed to the runtime.Repo restructure
Converted to a cargo workspace. The existing
bdk_txsource moves undertx/; CI scripts already invoked cargo at workspace scope and are unchanged.bdk_txAPI additionsDriven by the friction of integrating with payjoin's
InputPair::newand finalising counterparty-modified PSBTs:Input::to_psbt_pair(fallback_sequence) -> (TxIn, psbt::Input)— the same shapeSelection::create_psbtalready builds internally, exposed for handing a wallet input to an external builder.Input::expected_input_weight() -> Weight— total input weight, required by payjoin'sInputPair::newfor 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.is_spendableintotry_is_spendable -> Option<bool>andis_spendable -> bool. The oldOption<bool>forced every caller to invent its own default for the "unknowable" case; selection filters now get thefalse-default they want.PsbtParams::fallback_sequencefromENABLE_LOCKTIME_NO_RBFtoENABLE_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>— yieldsStep::{SendRequest, Backoff, Save, Done, Failed}feed_response(bytes)Two wallet traits inject wallet-aware decisions:
ReceiverWallet—is_owned,check_broadcast,contribute,process_psbt(withfallback_sequencedefaulted).SenderWallet—process_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 callresume_from_eventsto reconstruct a session mid-protocol.Re-exports the upstream
payjointypes that appear in the public surface (ReceiverBuilder,SenderBuilder,PjUri,OhttpKeys,Uri,Request) so consumers don't need a directpayjoindep.bdk_payjoin(new crate)Glue layer for callers who want
bdk_txcandidates / plans handed to the runtime:input_pair_from(&Input, fallback_sequence)/input_pairs_from(&InputCandidates, fallback_sequence)— convertbdk_txcandidates into payjoinInputPairs, 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.rsis 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.rs—input_pair_from(P2TR vs P2WPKH paths, fallback-sequence propagation) andsign_and_finalize_with_plans.runtime/tests/psbt.rs—restore_psbt_utxos(owned vs non-owned inputs, finalized-input skip rule).runtime/tests/resume.rs—resume_from_eventsfor empty-log and closed-failure cases.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).