Skip to content

feat: port payjoin core to no_std#1615

Draft
caarloshenriq wants to merge 8 commits into
payjoin:masterfrom
caarloshenriq:feat/payjoin-nostd
Draft

feat: port payjoin core to no_std#1615
caarloshenriq wants to merge 8 commits into
payjoin:masterfrom
caarloshenriq:feat/payjoin-nostd

Conversation

@caarloshenriq

Copy link
Copy Markdown
Contributor

Summary

This is a implementation of no_std support for the payjoin crate,
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-std feature was introduced to separate the state machine logic
from networking dependencies:

Feature Description
alloc bare metal support, state machine logic only
v2 async payjoin session logic without networking
v2-std v2 + networking (url, ohttp, hpke, bhttp, http)
std full std support with tokio, serde_json, bitcoin/base64

Verified Build Targets

  • cargo build -p payjoin --no-default-features --features v2,alloc
  • cargo build -p payjoin --no-default-features --features v2,alloc --target thumbv7em-none-eabihf-p payjoin
  • cargo build -p payjoin --no-default-features --features v2,std

Notes for Reviewers

Please review commit by commit:

refactor: introduce no_std/alloc feature split in payjoin core
The main structural change — replaces std:: with core::/alloc:: and gates std-only deps behind #[cfg(feature = "std")] or #[cfg(feature = "v2-std")].

fix: gate v2 std-only code behind cfg features
Extends gating to v2 send/receive and persist. Key decisions: HasReplyableError now carries fallback_tx in both configs to preserve fallback through replay; MaybeSuccessTransition::deconstruct uses Save instead of SaveAndClose on success.

fix: update payjoin-ffi for no_std feature split
Minimal FFI updates to match new AsyncSessionPersister bounds.

fix: restore OHTTP test constants and enable v2 feature in test utils
KEM, KEY_ID, SYMMETRIC were dropped upstream without updating internal tests. Restores them in payjoin-test-utils/src/v2.rs.

chore: update CI, lock files and flake for no_std targets
Adds thumbv7em-none-eabihf to 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:

@coveralls

coveralls commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 27779438342

Coverage decreased (-0.6%) to 84.654%

Details

  • Coverage decreased (-0.6%) from the base build.
  • Patch coverage: 104 uncovered changes across 14 files (292 of 396 lines covered, 73.74%).
  • 56 coverage regressions across 6 files.

Uncovered Changes

Top 10 Files by Coverage Impact Changed Covered %
payjoin/src/core/send/v2/mod.rs 51 27 52.94%
payjoin/src/core/persist.rs 89 75 84.27%
payjoin/src/core/uri/v2.rs 50 36 72.0%
payjoin/src/core/send/v2/error.rs 11 0 0.0%
payjoin/src/core/receive/v2/mod.rs 19 10 52.63%
payjoin/src/core/send/error.rs 22 14 63.64%
payjoin/src/core/ohttp.rs 7 1 14.29%
payjoin/src/core/uri/mod.rs 105 100 95.24%
payjoin/src/core/error.rs 6 2 33.33%
payjoin/src/core/time.rs 5 1 20.0%
Total (22 files) 396 292 73.74%

Coverage Regressions

56 previously-covered lines in 6 files lost coverage.

File Lines Losing Coverage Coverage
payjoin/src/core/persist.rs 31 92.32%
payjoin/src/core/receive/v2/mod.rs 16 88.68%
payjoin/src/core/receive/v2/error.rs 4 38.18%
payjoin/src/core/send/v2/error.rs 2 30.23%
payjoin/src/core/uri/error.rs 2 4.55%
payjoin/src/core/send/error.rs 1 42.79%

Coverage Stats

Coverage Status
Relevant Lines: 14753
Covered Lines: 12489
Line Coverage: 84.65%
Coverage Strength: 370.52 hits per line

💛 - Coveralls

@DanGould

DanGould commented Jun 7, 2026

Copy link
Copy Markdown
Member

Great to see this take off here. My biggest question is about v2-std which expresses that it's about "networking" but really it's the wire serialization. Is it possible to use the library without that? I'm not sure it is.

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a good start, though there are some feature organizations I have some questions about.

Comment thread payjoin/Cargo.toml
Comment thread payjoin-test-utils/Cargo.toml Outdated
Comment thread payjoin/Cargo.toml Outdated
Comment thread flake.nix Outdated
Comment thread flake.nix Outdated
Comment thread payjoin/Cargo.toml Outdated
@benalleng benalleng linked an issue Jun 8, 2026 that may be closed by this pull request
@benalleng benalleng removed a link to an issue Jun 8, 2026
Comment thread .github/workflows/rust.yml Outdated
@caarloshenriq

Copy link
Copy Markdown
Contributor Author

My biggest question is about v2-std which expresses that it's about "networking" but really it's the wire serialization. Is it possible to use the library without that? I'm not sure it is.

After reviewing the code, v2-std as a standalone feature had no clear use case, any consumer that needs OHTTP networking will reach for v2-ohttp or io, and v2-std alone didn't even compile correctly due to untested feature combinations. I've collapsed v2-std into v2-ohttp, simplifying the feature hierarchy to: allocv2v2-ohttpio.

@caarloshenriq
caarloshenriq force-pushed the feat/payjoin-nostd branch 2 times, most recently from 504491c to 082564e Compare June 12, 2026 18:28
@caarloshenriq
caarloshenriq force-pushed the feat/payjoin-nostd branch 2 times, most recently from 69d72e6 to 37bb1e8 Compare June 18, 2026 18:03
@caarloshenriq
caarloshenriq requested a review from benalleng June 18, 2026 18:43

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@benalleng

Copy link
Copy Markdown
Collaborator

I did add an additional target thumbv8m.main-none-eabihf in the embedded toolchain and picotool as an additional dev shell dep for working on the pico 2 directly but not totally sure if we need to make these first party requirements in our shell just for the pico

@caarloshenriq

Copy link
Copy Markdown
Contributor Author

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

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 thumbv8m.main-none-eabihf, while CI targets thumbv7em-none-eabihf. Both are ARM no_std, but different architecture revisions, so an M4F test would still be ood to have before considering hardware coverage complete. I bought an STM32F411 board for that.

@caarloshenriq

Copy link
Copy Markdown
Contributor Author

I did add an additional target thumbv8m.main-none-eabihf in the embedded toolchain and picotool as an additional dev shell dep for working on the pico 2 directly but not totally sure if we need to make these first party requirements in our shell just for the pico

On thumbv8m and picotool in the embedded devShell: agreed these probably shouldn't be first-party requirements just for Pico support. The PR's declared target is thumbv7em-none-eabihf, so I'd lean toward keeping the devShell scoped to that and letting Pico-specific tooling live in your payjoin-pico2-test repo instead. Open to hearing if there's a reason to keep them in-tree though, if you think other contributors testing on Pico hardware would benefit.

@caarloshenriq

Copy link
Copy Markdown
Contributor Author

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 state

The 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.

@benalleng benalleng mentioned this pull request Jul 3, 2026
@benalleng benalleng added the enhancement New feature or request label Jul 16, 2026
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.
@caarloshenriq
caarloshenriq marked this pull request as draft July 16, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants