feat: port payjoin core to no_std - #1615
Conversation
Coverage Report for CI Build 31185032353Warning No base build found for commit Coverage: 86.161%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
|
Great to see this take off here. My biggest question is about |
benalleng
left a comment
There was a problem hiding this comment.
Looks like a good start, though there are some feature organizations I have some questions about.
249f524 to
04f34f6
Compare
853bb38 to
c0c2149
Compare
After reviewing the code, |
504491c to
082564e
Compare
69d72e6 to
37bb1e8
Compare
benalleng
left a comment
There was a problem hiding this comment.
Tested on a RPI Pico 2. It is a limited test that proves compilation and the ShortId round trip to begin the payjoin process.
$ sudo nix develop ~/rust-payjoin#embedded -c bash -c 'picotool uf2 convert --family absolute ~/payjoin-pico2-test/target/thumbv8m.main-none-eabihf/release/payjoin-pico2-test -t elf /tmp/main.uf2 && picotool load --ignore-partitions -x /tmp/main.uf2'
warning: Git tree '~/rust-payjoin' is dirty
Loading into Flash: [==============================] 100%
The device was rebooted to start the application.
$ cat /dev/ttyACM0
=== payjoin no_std (v2) on RP2350 ===
ShortId bytes: 4242424242424242
ShortId bech32m: GFPYYSJZGFPYY
round-trip ok: true
mailbox id bytes: 7f2f54ff94459f3a
mailbox id bech32m: 0UH4FLU5GK0N5
=== done ===I created this repo to demonstrate my steps to get here. https://github.com/benalleng/payjoin-pico2
|
I did add an additional target |
Thanks for testing this against the branch directly, and for putting together the payjoin-pico2-test repo as a reference. One note on target coverage: this is |
On |
|
Tested on a WeAct STM32F411CEU6 Black Pill (Cortex-M4F, thumbv7em-none-eabihf), the PR's declared CI target. $ sudo $(which dfu-util) -a 0 -s 0x08000000:leave -D target/thumbv7em-none-eabihf/release/payjoin-blackpill-test
dfu-util 0.11
Opening DFU capable USB device...
Device ID 0483:df11
Downloading element to address = 0x08000000, size = 134132
Erase [=========================] 100% 134132 bytes
Erase done.
Download [=========================] 100% 134132 bytes
Download done.
File downloaded successfully
Submitting leave request...
Transitioning to dfuMANIFEST stateThe test exercises ShortId round-trip and SHA256 → mailbox ID derivation on bare-metal, with no std. I created https://github.com/caarloshenriq/payjoin-blackpill-test to document the steps. |
37bb1e8 to
641926c
Compare
daa5aa6 to
4377a7b
Compare
4377a7b to
fe7a432
Compare
fe7a432 to
f786785
Compare
The v1 feature required std unconditionally, even though the protocol itself has no networking or OS dependency: it is plain byte-in/byte-out request/response handling, making it the natural fit for the no_std embedded receiver/sender work in this branch. Port the v1 send and receive paths to alloc-only: - Change the v1 feature to depend on alloc instead of std. - Gate the handful of genuinely std-only APIs (PjUri construction, SenderBuilder::new) behind std, leaving SenderBuilder::from_parts and PjParam::parse as the no_std entry points. - Replace bitcoin's Psbt FromStr/Display, which pulls in std through bitcoin's base64 feature, with direct use of a no_std-configured base64 crate on top of PSBT's existing binary (de)serialization. Shared with the v2 receive path, which used the same std-only entry point. - Fix several imports and cfg gates that were tied to std without a real std dependency (FeeRate, Version, FromStr, format!, and the query-string parsing in Params::from_query_str). - Simplify from_query_str's query parsing to avoid a percent-decoding dependency that is itself std-only; BIP78 query parameters don't need percent-decoding in practice. - Add a no_std fallback for ResponseError parsing, since the well-known JSON error format requires serde_json (std-only); it reports a generic parse failure instead of the decoded reason.
Add payjoin/tests/e2e.rs with two independent round trip tests, split into cfg-gated submodules since v1 and v2 have different feature requirements: - v1: runs entirely in memory under the no_std-compatible alloc,v1 feature set. Drives the full sender and receiver typestate chain with no OHTTP or directory involved, since v1 is plain request/response bytes. This is the test that actually exercises the no_std surface end to end, not just at compile time. - v2: cannot run under that restricted feature set, since the sender side of v2 still requires v2-ohttp/std. Instead this proves that the receiver side, driven by a real OHTTP-encapsulated request from a real sender, walks the same typestate chain already verified to compile for thumbv7em-none-eabihf. Uses a small in-memory stand-in for the directory and OHTTP relay (decapsulate, route GET/POST against a single-message mailbox, re-encapsulate) instead of the real local servers tests/integration.rs already spins up, so it stays fast and dependency-free. Both tests have the receiver contribute a real input from its own UTXO before finalizing, rather than only fee-bumping the original PSBT, so they exercise an actual payjoin (combined inputs from both parties) and not just protocol plumbing. Wire both into contrib/test.sh: the v2 module runs as part of the --all-features e2e run, and v1 gets its own explicit --no-default-features --features alloc,v1 invocation, since that's the only place the no_std guarantee is actually exercised at runtime rather than just checked at compile time.
Align cfg gates for Uri/PjUri and their bitcoin_uri-backed adapters with the crate's actual feature dependencies (std, v1, v2-ohttp), fixing build failures under alloc-only and v1-only configurations that were not caught until running check-nostd/check-nostd-v1.
f786785 to
1286678
Compare
Add a "Build embedded target v1" step alongside the existing v2 step so CI exercises both feature combinations (alloc,v1 and alloc,v2) against the thumbv7em-none-eabihf no_std target, matching the two combinations already covered by local check-nostd/check-nostd-v1 bacon jobs. Set CARGO_PROFILE_DEV_DEBUG=0 for both steps. secp256k1-sys's build script (via cc-rs) injects -fno-omit-frame-pointer and -mno-omit-leaf-frame-pointer for GCC when Cargo's DEBUG env var is set. The latter flag does not exist on arm-none-eabi-gcc, which only supports it on x86/AArch64, so the build fails whenever dependency resolution picks a cc version that adds this flag. This does not reproduce locally because a resolved Cargo.lock is not committed, so CI is exposed to any new cc release changing this behavior while a local checkout stays pinned to whatever cc version was first resolved. This only affects the payjoin core no_std compilation check in CI and has no effect on payjoin-no-std-harness firmware builds used to flash real hardware, which are built separately with debug symbols intact.
Summary
This is a implementation of
no_stdsupport for thepayjoincrate,enabling its use on embedded devices
As discussed in #942, running payjoin logic on a hardware signer enables
stronger security guarantees: the device can verify the fallback transaction,
compare it against the payjoin proposal, and only sign previously-approved
inputs — without trusting the host machine.
Feature Architecture
A new
v2-stdfeature was introduced to separate the state machine logicfrom networking dependencies:
allocv2v2-stdv2+ networking (url,ohttp,hpke,bhttp,http)stdtokio,serde_json,bitcoin/base64Verified Build Targets
cargo build -p payjoin --no-default-features --features v2,alloccargo build -p payjoin --no-default-features --features v2,alloc --target thumbv7em-none-eabihf-p payjoincargo build -p payjoin --no-default-features --features v2,stdNotes for Reviewers
Please review commit by commit:
refactor: introduce no_std/alloc feature split in payjoin coreThe main structural change — replaces
std::withcore::/alloc::and gates std-only deps behind#[cfg(feature = "std")]or#[cfg(feature = "v2-std")].fix: gate v2 std-only code behind cfg featuresExtends gating to v2 send/receive and persist. Key decisions:
HasReplyableErrornow carriesfallback_txin both configs to preserve fallback through replay;MaybeSuccessTransition::deconstructusesSaveinstead ofSaveAndCloseon success.fix: update payjoin-ffi for no_std feature splitMinimal FFI updates to match new
AsyncSessionPersisterbounds.fix: restore OHTTP test constants and enable v2 feature in test utilsKEM,KEY_ID,SYMMETRICwere dropped upstream without updating internal tests. Restores them inpayjoin-test-utils/src/v2.rs.chore: update CI, lock files and flake for no_std targetsAdds
thumbv7em-none-eabihfto CI and ARM cross-toolchain to the Nix dev shell.AI Assistance
This implementation was developed with AI assistance (Claude, Anthropic).
Pull Request Checklist
Please confirm the following before requesting review:
AI
in the body of this PR.