diff --git a/README.md b/README.md index 48ae6aac03f4..979f6db13bf7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ +Bitcoin Core with experimental Payjoin support +============================================== + +This fork includes experimental Payjoin support via the Rust-Payjoin library. +See the [wallet RPC guide](doc/payjoin-rpc.md) and +[build and test guide](doc/payjoin-integration.md). + Bitcoin Core integration/staging tree -===================================== +------------------------------------- https://bitcoincore.org -This fork includes experimental Payjoin support via the Rust-Payjoin library. - For an immediately usable, binary version of the Bitcoin Core software, see https://bitcoincore.org/en/download/. diff --git a/ci/test/03_test_script.sh b/ci/test/03_test_script.sh index 8b513f00da5a..180f73fb3a99 100755 --- a/ci/test/03_test_script.sh +++ b/ci/test/03_test_script.sh @@ -191,7 +191,8 @@ fi if [ "${RUN_PAYJOIN_INTEGRATION_TESTS:-false}" = "true" ]; then integration_build="${BASE_ROOT_DIR}/build-payjoin-integration" cmake -S "${BASE_ROOT_DIR}" -B "${integration_build}" \ - "${CMAKE_ARGS[@]}" \ + "${BITCOIN_CONFIG_ALL[@]}" \ + "${BITCOIN_CONFIG[@]}" \ -DENABLE_PAYJOIN=ON \ -DBUILD_TESTS=ON \ -DBUILD_PAYJOIN_INTEGRATION_TESTS=ON \ diff --git a/doc/README.md b/doc/README.md index 0959c6c7f04e..b72e69b8bd5f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -44,6 +44,7 @@ The following are developer notes on how to build Bitcoin Core on your native pl - [Dependencies](dependencies.md) - [macOS Build Notes](build-osx.md) - [Unix Build Notes](build-unix.md) +- [Payjoin Build and Tests](payjoin-integration.md) - [Windows Build Notes](build-windows-msvc.md) - [FreeBSD Build Notes](build-freebsd.md) - [OpenBSD Build Notes](build-openbsd.md) @@ -60,6 +61,7 @@ The Bitcoin repo's [root README](/README.md) contains relevant information on th - [Translation Process](translation_process.md) - [Translation Strings Policy](translation_strings_policy.md) - [JSON-RPC Interface](JSON-RPC-interface.md) +- [Experimental Payjoin v2 Wallet RPC](payjoin-rpc.md) - [Unauthenticated REST Interface](REST-interface.md) - [BIPS](bips.md) - [Dnsseed Policy](dnsseed-policy.md) diff --git a/doc/payjoin-integration.md b/doc/payjoin-integration.md index 3c9ec7eaabe1..d471b8242194 100644 --- a/doc/payjoin-integration.md +++ b/doc/payjoin-integration.md @@ -1,24 +1,25 @@ -# Payjoin tests with upstream TestServices +# Payjoin build and tests -Native Linux tests use Core's in-process regtest wallets and actual upstream -TestServices, without an external bitcoind or a replacement SenderSession. +Reuse separate build directories for production and integration. See [wallet RPC](payjoin-rpc.md). -| Build tree / executable | Suites | Rust FFI | -| --- | --- | --- | -| `build-payjoin/bin/test_bitcoin` | payjoin_tests, payjoin_sender_tests, payjoin_transport_tests | Production depends package; no _test-utils | -| `build-payjoin-integration/bin/test_payjoin_integration` | payjoin_integration_tests | Separate _test-utils library and matching generated C++ | +## Prerequisites + +Use Linux or Ubuntu WSL from the source root: CMake 3.24+, [Unix tools](build-unix.md), +[depends prerequisites](../depends/README.md), Git, Python 3, `cargo` and `rustc` in `PATH`. +Examples use depends curl and OpenSSL; system libcurl needs 8.22+ and async DNS. Install Rust 1.85.1: -Use separate build trees and FFI packages. The integration build selects test FFI -before compiling wallet/adapter code; CMake rejects mixing test and production FFI. -Pre-exchange tests hold requests without starting TestServices; HTTP-only cases -need no wallet fixture. Receiver Progress establishes actual observation of original; -transport acceptance, HTTP submission and delivery of a response are separate stages. +```sh +git submodule update --init --recursive -- payjoin +rustup toolchain install 1.85.1 --profile minimal +RUSTUP_TOOLCHAIN=1.85.1 rustc --version +RUSTUP_TOOLCHAIN=1.85.1 cargo --version +``` -## Build and run +## Production FFI build and unit tests -Run from the source root with CMake 3.24 or newer. The commands use depends curl -and OpenSSL; without depends, curl development version 8.22 or newer is required. -This workflow supports Linux, including Ubuntu WSL, rather than a Windows cross-build. +These Core builds use Debug for testing. Keep `source_dir` and `depends_prefix` in one +shell. Prepare both FFI packages and CLI before Core; rerunning depends can recreate +the prefix. Downloads need network; cached runs use `CARGO_NET_OFFLINE=true`. ```sh source_dir="$PWD" @@ -26,78 +27,78 @@ depends_prefix="$source_dir/depends/$(./depends/config.guess)" make -C depends -j4 PAYJOIN=1 PAYJOIN_TESTS=1 NO_QT=1 NO_IPC=1 NO_ZMQ=1 NO_USDT=1 cmake -S . -B build-payjoin \ --toolchain "$depends_prefix/toolchain.cmake" \ - -DCMAKE_BUILD_TYPE=Debug -DENABLE_PAYJOIN=ON -DBUILD_TESTS=ON \ + -DCMAKE_BUILD_TYPE=Debug -DENABLE_WALLET=ON -DENABLE_PAYJOIN=ON -DBUILD_TESTS=ON \ -DBUILD_PAYJOIN_INTEGRATION_TESTS=OFF -DCMAKE_COMPILE_WARNING_AS_ERROR=ON \ -DPayjoinFFI_DIR="$depends_prefix/lib/cmake/PayjoinFFI" -cmake --build build-payjoin --target test_bitcoin -j4 +cmake --build build-payjoin --target test_bitcoin bitcoind bitcoin-cli -j4 ./build-payjoin/bin/test_bitcoin \ - --run_test=payjoin_tests,payjoin_sender_tests,payjoin_transport_tests \ + --run_test=payjoin_tests,payjoin_sender_tests,payjoin_transport_tests,payjoin_manager_tests \ --catch_system_error=no --log_level=test_suite ``` -Both FFI packages use the installed Rust 1.85.1 toolchain, Cargo-recent.lock, -`--locked` and the pinned native C++ generator. `payjoin_ffi` uses release without -_test-utils; `payjoin_ffi_test` uses dev/debug with cpp,_test-utils. They share a -recipe and use separate Cargo target directories. The generator keeps its -existing release profile and production Cargo directory. +Unit suites cover protocol and wallet/manager behavior. Sender tests use controlled transport; +HTTP tests exercise libcurl against local servers. + +## Integration build and tests -Prepare both packages before configuring either Core tree. The first invocation -may download locked dependencies; after filling the cache, use -`CARGO_NET_OFFLINE=true` with the same depends command for offline preparation. -Running depends again can recreate its prefix, so do not run it between the two -Core passes. +Production uses release FFI without `_test-utils`; integration uses debug FFI with `cpp,_test-utils`. +CMake enforces package separation. [Recipes](../depends/packages/payjoin_ffi.mk) pin Rust, lockfile and generator. +Linking is static; the shared library generates bindings. +For system dependencies, omit `--toolchain`; use prepared `PayjoinFFI_DIR`. ```sh cmake -S . -B build-payjoin-integration \ --toolchain "$depends_prefix/toolchain.cmake" \ -DCMAKE_BUILD_TYPE=Debug -DCMAKE_COMPILE_WARNING_AS_ERROR=ON \ - -DENABLE_PAYJOIN=ON -DBUILD_TESTS=ON \ + -DENABLE_WALLET=ON -DENABLE_PAYJOIN=ON -DBUILD_TESTS=ON \ -DBUILD_PAYJOIN_INTEGRATION_TESTS=ON \ -DPayjoinFFI_DIR="$depends_prefix/tests/payjoin-ffi/lib/cmake/PayjoinFFI" -cmake --build build-payjoin-integration --target test_payjoin_integration -j4 +cmake --build build-payjoin-integration --target test_payjoin_integration payjoin_test_services -j4 ctest --test-dir build-payjoin-integration \ -L payjoin_integration --output-on-failure --no-tests=error -j1 ``` -Production FFI is installed under the depends prefix; test FFI is under -`tests/payjoin-ffi`, each with `lib/libpayjoin_ffi.a`, a CMake config under -`lib/cmake/PayjoinFFI` and bindings under `share/payjoin/cpp`. The test executable -links one static FFI archive. The Rust .so is only an intermediate for binding -generation; no Payjoin loader environment is needed. - -The toolchain in these examples selects all depends libraries and is used by CI. -Core can instead use system dependencies and a separately prepared FFI package: -omit `--toolchain` and set `PayjoinFFI_DIR` to the appropriate config directory. -CMake rejects the test package when integration is off, and the production -package when integration is on. Missing bindings fail package validation or -compilation. - -## Process lifetime and CI - -CTest discovers cases from the compiled Boost listing and runs each integration -case in a separate process with a 120-second timeout. Upstream TestServices has a -global Tokio runtime without shutdown: dropping its C++ handle does not abort -spawned tasks. Process exit stops the threads/listeners; no child bitcoind is used. -Keep the per-case process isolation and bounded HTTP/receiver waits. - -The existing native Payjoin job first prepares both FFI packages in depends. -A failure preparing the test package stops the job before the ordinary pass. -The ordinary `all` build and full CTest set use production FFI; the second Core -tree selects the test package and runs the integration label. Both passes use -Rust 1.85.1 and the depends toolchain. Depends is not rerun between them. Discovery failure, empty selection -or a failed case fails the job; cases are not automatically retried. +Integration exchanges use in-process regtest wallets, real `SenderSession` and +TestServices; pre-exchange and HTTP-only cases cover narrower boundaries. +CTest runs cases in separate processes with 120-second timeouts; exit stops +TestServices tasks. Receiver observation proves original receipt. + +## Wallet RPC functional test ```sh -FILE_ENV=ci/test/00_setup_env_native_payjoin.sh ./ci/test_run_all.sh +python3 build-payjoin/test/functional/test_runner.py wallet_payjoin.py \ + --payjoin-cli="$depends_prefix/native/bin/payjoin-cli" \ + --payjoin-test-services="$source_dir/build-payjoin-integration/bin/payjoin_test_services" ``` -For a clean CI check, use an LF source snapshot with Git modes preserved and new -build/cache directories. Keep development caches intact: Docker `--no-cache` does -not clear named cache volumes. Downloaded dependency sources may be reused. +Require `Passed`. Without helper paths it reports `Skipped`; supplying either requires both. +Reload tests verify loss of RPC access and a new `request_id` scope; [recovery](payjoin-todo.md#recovery) is pending. +The exchange uses P2WPKH; P2TR and mixed cases await a [matrix](payjoin-todo.md#transaction-compatibility). -## Verification +The test runs Core against an upstream CLI receiver and isolated TestServices. +It checks the receiver input, publication, confirmation, reorg, idempotency, +cancellation and explicit fallback. Successful CLI exit is checked before mining; +`--timeout-factor` scales bounded waits. + +## BTCPay Server regtest smoke test -Rebuild after changing cases and obtain counts from the executable and CTest: +Prerequisites: both nodes share the same regtest chain; the Core sender wallet is +funded and unlocked; BTCPay Async Payjoin has a hot wallet with a confirmed spendable +UTXO; the directory and OHTTP relay are reachable. Keep the sender wallet loaded. + +Use the [RPC workflow](payjoin-rpc.md#minimal-workflow). + +| Step | Action | Expected result | +| --- | --- | --- | +| 1 | Create an invoice, select Async Payjoin and copy the complete Bitcoin URI. | URI contains the amount and `pj=`. | +| 2 | Call `sendpayjoin` with the URI, relay, unique `request_id` and fee cap. | A `payment_id` is returned. | +| 3 | Repeat the request with identical parameters. | Same `payment_id`, `duplicate=true`. | +| 4 | Poll `getpayjoin` and inspect the selected transaction. | `published`; `selected.txid` differs from `original.txid`; a receiver-owned input is present; total fee respects the cap; original is absent from the mempool. | +| 5 | Mine enough blocks for the store's confirmation policy, poll again and check BTCPay. | `confirmed`, settlement kind `selected`, `reservations.owned=false`; invoice is Settled with the invoice amount credited and a "Settled with Payjoin" event. | + +Save the invoice ID, `payment_id`, selected txid, fee and a BTCPay status screenshot. + +## Verification ```sh ./build-payjoin/bin/test_bitcoin --list_content @@ -108,8 +109,23 @@ python3 test/lint/lint-locale-dependence.py cargo run --manifest-path test/lint/test_runner/Cargo.toml ``` -Require successful exits, `No errors detected` from ordinary tests and -`100% tests passed` from integration CTest. Run full lint with its required tools -in a matching LF snapshot; make new files visible in that snapshot's index while -preserving the original index. Report skipped checks and external/submodule -failures separately from build/test results. +Require successful exits; ordinary tests report `No errors detected`, integration CTest `100% tests passed`. +Use [lint dependencies](../test/lint/README.md#dependencies) and LF snapshots with Git modes intact; +report skips, external and submodule failures. + +## Components + +`SenderManager` owns serial execution and services per wallet load. `SenderService` manages +payments, reservations, signing and publication. `SenderSession` wraps protocol and journal +transitions through Rust FFI; transport performs HTTP requests and reports delivery. + +## CI + +CI prepares both FFI packages and CLI, then production `all`, full CTest and integration. +Setup, discovery, empty selections and failed cases stop CI; test cases are not retried. +Mandatory functional CSV must show `Passed`; `Skipped` fails. +See [CI prerequisites](../ci/README.md#running-a-stage-locally). + +```sh +FILE_ENV=ci/test/00_setup_env_native_payjoin.sh ./ci/test_run_all.sh +``` diff --git a/doc/payjoin-rpc.md b/doc/payjoin-rpc.md new file mode 100644 index 000000000000..6e20e73f8fca --- /dev/null +++ b/doc/payjoin-rpc.md @@ -0,0 +1,157 @@ +# Experimental Payjoin v2 wallet RPC + +Core's experimental v2 sender uses an external receiver, directory and OHTTP relay; +[Core receiver support](payjoin-todo.md#async-v2-receiver) is planned. + +Build with wallet support and `ENABLE_PAYJOIN=ON` ([instructions](payjoin-integration.md)). +Local keys and unlocking are required for acceptance and signing; publishing a signed +transaction needs no unlock. External PSBTs, external signers, v1, GUI +and automatic fallback are unsupported. + +Supply the complete receiver Bitcoin URI (`pj` included) and separate HTTP(S) relay URL. +v1 can reserve inputs before failing preparation; use v2 and see [admission work](payjoin-todo.md#admission). + +## Minimal workflow + +Payments and `request_id` mappings last for one wallet load. Reload or restart loses RPC +access and permits reusing `request_id`; reservations may remain. See [Lifetime](#lifetime). + +Use a loaded, funded, unlocked wallet on the URI's network. Replace placeholders; this URI needs an amount. + +```sh +wallet='sender' +uri='RECEIVER_V2_URI_WITH_AMOUNT' +relay='https://relay.example' + +bitcoin-cli -rpcwallet="$wallet" -named sendpayjoin \ + uri="$uri" relay="$relay" request_id='invoice-1' + +# Copy payment_id from the reply. +payment_id='RETURNED_PAYMENT_ID' +bitcoin-cli -rpcwallet="$wallet" getpayjoin "$payment_id" +bitcoin-cli -rpcwallet="$wallet" listpayjoins +``` + +Supply `amount=...` in BTC if absent from URI. Replies below omit fields and use illustrative IDs: + +```json +{ + "payment_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "duplicate": false, + "payment": {"phase": "queued"} +} +``` + +Save `payment_id` and poll `getpayjoin`; inspect `problem`, `available_actions` and `settlement`. +Acceptance queues preparation; funding, signing or publication can still fail. Reads may miss phases. +Confirmed Payjoin: + +```json +{ + "phase": "confirmed", + "original": {"txid": "1111111111111111111111111111111111111111111111111111111111111111"}, + "selected": {"txid": "2222222222222222222222222222222222222222222222222222222222222222"}, + "settlement": {"kind": "selected", "presence": "confirmed"} +} +``` + +`settlement.kind=original` instead means the original confirmed, including after +explicit fallback or receiver broadcast. See RPC `help` for schemas and codes. + +## Sending + +| Argument | Rule | +| --- | --- | +| `amount` | BTC; required if absent from URI, otherwise must match | +| `fee_rate` | Optional sat/vB, up to three decimals; default: wallet policy | +| `max_total_fee` | Positive BTC cap on original or proposal total fee; wallet maximum is default and ceiling | +| `timeout` | Integer seconds, 1-86400, default 120; monotonic from acceptance | +| `request_id` | Optional case-sensitive 1-128 bytes without NUL; spaces preserved | + +Repeat identical parameters and `request_id` for `duplicate=true`, including after lost +replies. Mismatches fail. Duplicates skip unlock, network and fee checks without resending +or extending deadlines. Errors before acceptance leave `request_id` free; later errors retain it. + +Omitted and JSON `null` arguments match; amounts and fee rates compare by value. URI, relay and +`request_id` are not normalized; explicit defaults differ from omissions. + +## Reading results + +Reads refresh spend observations after a notification barrier and reconcile confirmed spends. +Reservations may be released when ownership and storage permit. Negotiation does not resume; +later events need another read. + +`phase` describes workflow separately from spend presence and input ownership. +`queued` covers preparation; `ready` is prepared for dispatch; `negotiating` means a request +was accepted by transport, without proving receiver receipt. For `attention`, inspect `problem` +and `available_actions`. `published` and `rejected` record publication results; +`confirmed` may mean original; `conflicted` means a confirmed competing spend. + +| Field | Meaning | +| --- | --- | +| `original`, `selected` | Signed original and publication choice; fallback selects original; no PSBTs returned | +| `observed_spend`, `settlement` | Known spend of original inputs; settlement denotes confirmation. A spend matching both transactions has kind `original` | +| `reservations.owned`, `reservations.locks_verified` | Ownership and current in-memory locks; no proof of database persistence | +| `possibly_exposed` | Receiver may hold and broadcast the signed original | +| `storage_uncertain` | Uncertain storage result blocks unsafe actions | + +## Control commands and problems + +| Command | Effect | +| --- | --- | +| `cancelpayjoin payment_id` | Stop negotiation without fallback; proven non-delivery may permit releasing inputs | +| `publishpayjoinfallback payment_id` | Select and publish saved original after ownership, storage and broadcast checks | +| `retrypayjoin payment_id retry_signing` | Sign saved receiver proposal and attempt publication on success | +| `retrypayjoin payment_id retry_publication` | Publish selected transaction again | + +Retries do not restart preparation or repeat the initial POST; timeout alone does not +forbid them. Cancellation or timeout does not prove non-delivery or allow releasing inputs. +`available_actions` are hints; commands recheck conditions and network policy. + +RPC errors report invalid calls, admission refusals, unknown IDs or storage/service failures. +Reads may have partial effects and fail without returning a payment object. + +`payment.problem` reports the latest payment problem. Control replies return `result` and `payment`, +including partial effects on failure. Use codes, not messages, for decisions. + +For `problem.code="publication"` with `available_actions=["retry_publication"]`, +resolve the cause and retry the signed transaction: + +```sh +bitcoin-cli -rpcwallet="$wallet" retrypayjoin "$payment_id" retry_publication +``` + +```json +{"result": "completed", "payment": {"phase": "published"}} +``` + +Check `result` and `payment`; `published` still needs confirmation. For `refused` or `failed`, +inspect `refusal`, `payment.problem` and updated actions. + +`problem.code=storage` differs from `storage_uncertain`; ownership or storage may need +reconciliation. No force-release RPC exists; manual unlocking is no general repair. + +## Networking + +Direct relay HTTP(S) ignores proxy environment variables; TLS verification and limits apply. +Effective Core proxies, I2P SAM, `-onlynet`, disabled DNS or inactive networking block new +payments; disabled proxies and inbound-only settings are allowed. Each HTTP handoff +rechecks policy; non-delivery cannot erase prior exposure. + +Reads and cancellation remain available. No proxy, DNS or CA RPC options or automatic direct fallback exist. + +`-walletbroadcast=0` blocks local publication, allowing exchange: the receiver can publish +the signed original. Later local publication may report `broadcast_disabled`. + +## Lifetime + +Disconnect does not cancel work. Unload stops negotiation and completes pending operations; +RPC shutdown may interrupt waits. Persisted payment records (including signed transactions), +journals, input ownership records and persistent coin locks can remain. After reload or +restart, Payjoin RPC cannot read or control previous payments; their `request_id` mappings +are lost. + +Reservations can exclude inputs from coin selection while the receiver may still +publish the signed original; unloading does not make releasing them safe. +[Recovery](payjoin-todo.md#recovery) is planned. Reorg can remove settlement without resuming +negotiation or reserving inputs again. diff --git a/doc/payjoin-todo.md b/doc/payjoin-todo.md new file mode 100644 index 000000000000..7a0e5fc33e5b --- /dev/null +++ b/doc/payjoin-todo.md @@ -0,0 +1,59 @@ +# Payjoin follow-up work + +Future work for [RPC](payjoin-rpc.md). + +## Recovery + +Payment records, journals, ownership records and coin locks can outlive RPC access. +Reservations protect potentially exposed inputs; see [Lifetime](payjoin-rpc.md#lifetime). + +Recovery must restore RPC views and `request_id` idempotency. Version records, saved request +parameters and journals for FFI upgrades; reconcile them, ownership, locks and transactions +against wallet and chain, including corrupt, unknown and expired states. Requires durable +storage and shared input ownership. + +Completion: reload, restart, crash, uncertain-write and backup restoration tests verify +deduplication, mismatch rejection, locks, ownership and safe controls. Never resume negotiation +or repeat the initial POST automatically; retain inputs while delivery, ownership or storage is uncertain. + +## Admission + +Move [typed v2 checks](../src/wallet/payjoin/sender.cpp#L182) and shared relay validation +before registration, signing or reservation: v1 or invalid relays can now pass admission. + +Completion: malformed or configuration-invalid requests leave no records, claimed +`request_id`, signatures, locks or network requests. Separate configuration faults from network failures. + +## Resources and history + +Bound sessions, queues, history and notifications across wallets; define retention and pagination. +Requires recovery and idempotency retention; pagination alone does not bound internal scans. + +Completion: bounded memory, command latency and shutdown under load; pruning preserves +idempotency and recovery records, and no wallet can starve another. + +## Reproducible depends sources + +Define archive contents excluding Git, build artifacts and junk, without touching checkout +timestamps. Requires explicit source selection and prepared Cargo dependencies. + +Completion: identical sources yield identical archives regardless of directory or junk. +Clean builds need no pre-warmed Cargo cache; separate fetching from offline builds. + +## Transaction compatibility + +Define P2WPKH, P2TR and mixed sender input, receiver contribution and change compatibility +against wallet policy and pinned FFI capabilities. + +Completion: a matrix checks signatures, fees, contribution and mempool acceptance; +unsupported combinations are explicitly rejected. + +## CI and fuzzing + +Add sanitizer jobs, Core FFI fuzzing and ordinary wallet and RPC regressions. Requires +reproducible builds with C++ and Rust instrumentation; completion: passing jobs and fuzz runs with coverage. + +## Async v2 receiver + +Build a production async wallet service, adapter and RPC around +[receiver FFI](https://github.com/payjoin/rust-payjoin/blob/f99b3031696e7f54e609615b8df6d91b29fb6f75/payjoin-ffi/src/receive/mod.rs#L538), currently used in Core tests. diff --git a/src/payjoin/client.cpp b/src/payjoin/client.cpp index e157eade3924..bee6a6350657 100644 --- a/src/payjoin/client.cpp +++ b/src/payjoin/client.cpp @@ -1030,9 +1030,9 @@ util::Expected SenderSession::Create( auto pj_uri = ParsePjUri(uri); if (!pj_uri) return util::Unexpected{std::move(pj_uri).error()}; - // TODO: Before wallet integration, validate the payment address and amount - // against the active chain (including MoneyRange/MAX_MONEY), and replace - // generic PjUri acceptance with a typed BIP77/v2 FFI check. + // The wallet validates the payment address and amount against the active + // chain in ValidatePaymentIntent. This adapter has no chain context. + // TODO: Replace generic PjUri acceptance with a typed BIP77/v2 FFI check. auto transition = BuildInitialTransition(*psbt_base64, *pj_uri, *fee_rate); if (!transition) return util::Unexpected{std::move(transition).error()}; diff --git a/src/payjoin/client.h b/src/payjoin/client.h index 9b0ef92f07ce..8e439303e496 100644 --- a/src/payjoin/client.h +++ b/src/payjoin/client.h @@ -48,6 +48,7 @@ struct PayjoinUriInfo { std::optional amount_sats; }; +/** Parse address and amount through the FFI; active-chain and v2-only admission checks belong to the caller. */ util::Expected ParsePayjoinUri(std::string_view uri); struct SenderRequest { @@ -192,6 +193,11 @@ class SenderSession * and LastError()==Internal. Do not rewrite the successful journal as Aborted. */ [[nodiscard]] util::Expected ProcessResponse(std::span response); + /** + * Discard the pending response context, allowing PrepareRequest() in the same + * phase. Return InvalidState if no request is pending. This neither cancels a + * transport request nor proves non-delivery; reconcile any attempt before resending. + */ [[nodiscard]] util::Expected DiscardPendingRequest(); SenderPhase Phase() const; @@ -227,8 +233,10 @@ class SenderSession */ [[nodiscard]] util::Expected Cancel(); /** - * Close a PendingFallback session after fallback broadcast or handoff; - * Rust treats both as terminal fallback completion. + * Close PendingFallback as SenderAborted after fallback broadcast, handoff, + * or abandonment of an unexposed payment. This only persists workflow closure; + * it does not broadcast or establish non-delivery. Storage or Internal errors + * leave the session Unusable, possibly after a durable transition. */ [[nodiscard]] util::Expected CloseFallback(); diff --git a/src/payjoin/http_transport.h b/src/payjoin/http_transport.h index 5ebae77a30ce..17bb9bb0ca92 100644 --- a/src/payjoin/http_transport.h +++ b/src/payjoin/http_transport.h @@ -16,12 +16,18 @@ namespace wallet::payjoin { struct HttpTransportOptions { + /** PEM CA bundle; empty uses libcurl's configured trust store. TLS verification remains enabled. */ std::string ca_file; + /** Explicit libcurl proxy; empty disables environment proxies. Core proxy settings are not used. */ std::string proxy; size_t max_response_bytes{1024 * 1024}; + /** Maximum number of queued and active requests. Both limits must be nonzero. */ size_t max_requests{32}; }; +/** HTTP(S) POST transport with TLS verification, no redirects, and one worker for completions. + * MakeDirectTransport() separately enforces wallet network policy. + */ class HttpSenderTransport final : public SenderTransport { public: diff --git a/src/payjoin/transport.h b/src/payjoin/transport.h index 964a409bd9df..f5efa71b574c 100644 --- a/src/payjoin/transport.h +++ b/src/payjoin/transport.h @@ -26,6 +26,7 @@ enum class Delivery { Response, Uncertain, Cancelled, + /** This request's body was not sent; earlier requests may still have disclosed the payment. */ NotSent, }; @@ -33,20 +34,37 @@ struct TransportResult { Delivery delivery{Delivery::Uncertain}; std::vector body; std::string diagnostic; + /** Transient failure hint; does not authorize repeating the initial signed-PSBT POST. */ bool retryable{false}; }; class SenderTransport { public: + /** + * May run inline during Submit, Cancel or Stop, or on another thread. + * Must not synchronously wait for work on the transport's worker, including + * calling Stop or destroying the transport from its own completion. + */ using Completion = std::function; virtual ~SenderTransport() = default; + /** + * Accepted guarantees exactly one completion, which may precede this return. + * Any other result creates no transfer and never invokes completion. + * Callers must keep callback dependencies alive until completion or Stop returns. + */ [[nodiscard]] virtual SubmitResult Submit(uint64_t id, SenderRequest request, std::chrono::milliseconds timeout, Completion completion) = 0; + /** Request cancellation; completion still reports the final delivery outcome. Cancel alone does not prove NotSent. */ virtual void Cancel(uint64_t id) = 0; + /** + * Close admission and finish all accepted requests and their completions before + * returning. No completions run afterwards; valid new submissions return Stopped. + * Repeated calls are safe. Must not be called from this transport's completion. + */ virtual void Stop() = 0; }; } // namespace wallet::payjoin diff --git a/src/wallet/payjoin/manager.h b/src/wallet/payjoin/manager.h index 91013268b36f..88cabceb81c2 100644 --- a/src/wallet/payjoin/manager.h +++ b/src/wallet/payjoin/manager.h @@ -22,6 +22,10 @@ class CWallet; namespace payjoin { std::unique_ptr MakeDirectTransport(interfaces::Chain& chain, const ArgsManager& args); +/** Owns a scheduler and serial runner for one WalletContext. The chain and + * arguments must outlive the manager. Stop before the node notification queue. + * Payments and request identifiers are scoped to one wallet load, without recovery. + */ class SenderManager { public: @@ -33,12 +37,20 @@ class SenderManager std::future Send(const std::shared_ptr& wallet, PaymentRequest request); + /** Perform SenderService::Read(); listing an unused load creates no service or transport. */ std::future Read(const std::shared_ptr& wallet, std::optional id = std::nullopt); std::future Execute(const std::shared_ptr& wallet, const uint256& id, SenderCommand command); + /** Close this load's admission and join its transport without draining + * future timers. No wallet or wallet-owner locks may be held. + */ void Unload(const std::shared_ptr& wallet); + /** Unload all services and stop the scheduler before returning. Repeated calls + * wait for the same completion. No wallet or wallet-owner locks may be held; + * do not call from the manager's runner. The node notification queue must still run. + */ void Stop(); private: diff --git a/src/wallet/payjoin/sender.cpp b/src/wallet/payjoin/sender.cpp index 23c15a69fe60..271824f83f54 100644 --- a/src/wallet/payjoin/sender.cpp +++ b/src/wallet/payjoin/sender.cpp @@ -118,7 +118,12 @@ struct Record { std::vector selected; bool exposed{false}; bool released{false}; - SERIALIZE_METHODS(Record, obj) { READWRITE(obj.uri, obj.original, obj.selected, obj.exposed, obj.released); } + + template + void Serialize(Stream& s) const + { + ::SerializeMany(s, uri, original, selected, exposed, released); + } }; struct JournalRecord { diff --git a/src/wallet/payjoin/sender.h b/src/wallet/payjoin/sender.h index 250acdcdcc80..adb227de2306 100644 --- a/src/wallet/payjoin/sender.h +++ b/src/wallet/payjoin/sender.h @@ -28,6 +28,7 @@ class SerialTaskRunner; namespace wallet { class CWallet; namespace payjoin { +/** Wallet workflow state, independent of protocol outcome, reservations, and current transaction presence. */ enum class PaymentPhase { Queued, Ready, @@ -88,20 +89,26 @@ struct SpendObservation { : transaction{std::move(tx)}, kind{spend_kind}, presence{tx_presence} {} }; +/** Published wallet state. Action flags are hints; execution rechecks current conditions, including network policy. */ struct PaymentSnapshot { uint256 id; PaymentPhase phase{PaymentPhase::Queued}; + /** Signed original, which the receiver may broadcast after disclosure. */ CTransactionRef original; + /** Transaction selected for publication; explicit fallback selects the original. */ CTransactionRef selected; bool original_saved{false}; bool owns_inputs{false}; + /** Verification of current in-memory wallet locks, not their database persistence. */ bool locks_verified{false}; bool possibly_exposed{false}; + /** A transport accepted a request; this does not establish receiver observation. */ bool transport_accepted{false}; bool wallet_recorded{false}; + /** Last recorded publication result; consult selected_presence for current observation. */ bool node_accepted{false}; bool storage_uncertain{false}; @@ -111,13 +118,16 @@ struct PaymentSnapshot { TransactionPresence original_presence{TransactionPresence::Missing}; TransactionPresence selected_presence{TransactionPresence::Missing}; + /** Wallet-known mempool or confirmed spend of original inputs, including a competing transaction. */ std::optional observed_spend; + /** Confirmed spend used for reconciliation. Original takes precedence over Selected when both transactions match. */ std::optional settlement; std::optional issue; std::string diagnostic; + /** Negotiation deadline; saved-proposal signing and publication retries have separate eligibility checks. */ std::chrono::steady_clock::time_point deadline; bool cancel_available{false}; @@ -125,6 +135,7 @@ struct PaymentSnapshot { bool publication_retry_available{false}; }; +/** Effective parameters fixed at registration, including resolved wallet defaults. */ struct PaymentIntent { std::string uri; std::string relay; @@ -135,6 +146,7 @@ struct PaymentIntent { std::chrono::milliseconds poll_interval{std::chrono::seconds{1}}; }; +/** Parsed input, separate from effective wallet defaults for idempotency. */ struct PaymentRequest { std::string uri; std::string relay; @@ -163,6 +175,7 @@ enum class RequestError { Internal, }; +/** Validate active-chain address, amount, fee and time limits. The pinned URI parser still admits v1. */ util::Expected ValidatePaymentIntent(const PaymentIntent& intent); enum class SenderCommand { @@ -181,6 +194,7 @@ enum class CommandStatus { NotFound, }; +/** Result of one operation; refusal is separate from the payment problem. */ struct ManagerResult { CommandStatus status{CommandStatus::Completed}; std::vector payments{}; @@ -190,6 +204,10 @@ struct ManagerResult { std::optional refusal{}; }; +/** Wallet, serial runner, and injected callback dependencies must outlive Stop(). + * Cancel(), PublishFallback(), RetryPublication(), RetrySigning(), and Refresh() + * queue work without waiting for completion; use Execute() instead when a result is needed. + */ class SenderService { public: @@ -210,12 +228,21 @@ class SenderService SenderService& operator=(const SenderService&) = delete; + /** Register a fixed intent, waiting for current execution but not preparation. */ [[nodiscard]] uint256 Start(PaymentIntent intent); + /** Accept without waiting for preparation or its notification barrier. + * Dropping the future does not cancel accepted work. + */ std::future Send(PaymentRequest request); + /** Refresh views in one runner task after a notification barrier. + * Later notifications may appear on the next read. + * Stop completes the read even if its barrier has not run. + */ std::future Read(std::optional id = std::nullopt); + /** Last published snapshot; does not refresh observations or wait for a notification barrier. */ [[nodiscard]] std::optional Snapshot(const uint256& id) const; void Cancel(const uint256& id); @@ -224,16 +251,28 @@ class SenderService void RetryPublication(const uint256& id); + /** Retry saved-proposal signing and, on success, attempt publication. */ void RetrySigning(const uint256& id); + /** Queue observation refresh without a notification barrier. Use Read() for a barrier. */ void Refresh(const uint256& id); + /** Return this invocation's result and post-command view asynchronously. + * Failed commands may have partial effects. Dropping the future does not + * cancel the command; Stop completes pending operations with final views. + */ std::future Execute(const uint256& id, SenderCommand command); + /** Close admission without waiting for current wallet work. */ void Close(); + /** Reconcile delivery and publish all registered results before returning. + * No wallet locks may be held. Do not call from the service's runner. + * Future payment timers are not drained; exposed inputs may remain reserved. + */ void Stop(); + /** Immutable final views after Stop(), retaining no wallet resources; null before Stop() completes. */ std::shared_ptr> Archive() const; private: diff --git a/src/wallet/rpc/payjoin.cpp b/src/wallet/rpc/payjoin.cpp index b4cc6d897ab9..c37a0beec4a0 100644 --- a/src/wallet/rpc/payjoin.cpp +++ b/src/wallet/rpc/payjoin.cpp @@ -217,7 +217,7 @@ RPCResult TransactionResult(std::string key, bool spend = false) {RPCResult::Type::STR_HEX, "wtxid", "Witness transaction identifier"}, {RPCResult::Type::STR, "presence", "missing, inactive, mempool, abandoned, mempool_conflicted, block_conflicted, or confirmed"}, }; - if (spend) fields.emplace_back(RPCResult::Type::STR, "kind", "original, selected, or other"); + if (spend) fields.emplace_back(RPCResult::Type::STR, "kind", "original, selected, or other; original takes precedence when selected is the original"); return {RPCResult::Type::OBJ, std::move(key), true, "Current transaction observation", std::move(fields)}; } @@ -229,7 +229,7 @@ RPCResult PaymentResult(std::string key) "Payment in this wallet load (no restart recovery)", { {RPCResult::Type::STR_HEX, "payment_id", "Payment identifier"}, - {RPCResult::Type::STR, "phase", "queued, ready, negotiating, cancelled, attention, published, rejected, confirmed, conflicted, or stopped"}, + {RPCResult::Type::STR, "phase", "queued, ready, negotiating, cancelled, attention, published, rejected, confirmed, conflicted, or stopped. Confirmed may refer to the original; inspect settlement.kind"}, { RPCResult::Type::OBJ, "parameters", @@ -277,7 +277,7 @@ RPCResult PaymentResult(std::string key) true, "Latest payment problem, not a command result", { - {RPCResult::Type::STR, "code", "Stable problem category"}, + {RPCResult::Type::STR, "code", "Stable problem category: invalid_intent, preparation, storage, reservation, protocol, delivery, deadline, signing, publication, broadcast_disabled, or observed_spend"}, {RPCResult::Type::STR, "message", "Diagnostic"}, }, }, @@ -342,7 +342,7 @@ RPCResult CommandResultHelp() { {RPCResult::Type::STR, "result", "completed, refused, failed, or stopped"}, PaymentResult("payment"), - {RPCResult::Type::STR, "refusal", true, "Stable command refusal reason"}, + {RPCResult::Type::STR, "refusal", true, "Stable command refusal reason: invalid_state, storage_uncertain, selected, settled, inputs_changed, or network_policy"}, {RPCResult::Type::STR, "diagnostic", true, "Command diagnostic, when available"}, }, }; @@ -374,6 +374,7 @@ RPCMethod sendpayjoin() "sendpayjoin", "EXPERIMENTAL: accept an asynchronous Payjoin v2 payment for this wallet load.\n" "Requires local private keys, an unlocked wallet, and supported direct networking.\n" + "Acceptance precedes preparation, signing, and publication; later failures appear in payment.problem.\n" "No automatic fallback or recovery after wallet reload/restart is provided.\n" "Identical request_id repeats return the accepted payment before mutable admission checks.\n" "Null and omitted arguments are equal; an explicit default differs from an omitted default.\n", @@ -496,6 +497,7 @@ RPCMethod retrypayjoin() return RPCMethod{ "retrypayjoin", "EXPERIMENTAL: retry signing of a saved receiver proposal or publication of the selected transaction.\n" + "retry_signing attempts publication after signing succeeds.\n" "Does not restart preparation or negotiation. An expired negotiation deadline does not itself forbid these actions.\n", { {"payment_id", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Payment identifier in this wallet load"}, diff --git a/src/wallet/test/payjoin_sender_tests.cpp b/src/wallet/test/payjoin_sender_tests.cpp index e8abdc7cdc1a..99c7377dcddd 100644 --- a/src/wallet/test/payjoin_sender_tests.cpp +++ b/src/wallet/test/payjoin_sender_tests.cpp @@ -259,8 +259,11 @@ class StopLockProbe public: StopLockProbe() { + auto levels = m_levels; + levels.insert_or_assign(BCLog::LOCK, BCLog::Level::Debug); + LogInstance().EnableCategory(BCLog::LOCK); - LogInstance().AddCategoryLogLevel(BCLog::LOCK, BCLog::Level::Debug); + LogInstance().SetCategoryLogLevel(levels); } ~StopLockProbe()