Repository navigation
JsonSenderSessionPersister[Async] crashes WASM heap in real browsers #1508
Description
Summary
In a real browser using PDK 1.0-rc.2 built via uniffi-bindgen-react-native 0.30.0-1 + wasm-bindgen --target web, both .save(persister) and .saveAsync(asyncPersister) on InitialSendTransition trap with RuntimeError: memory access out of bounds. Failure is at the FFI lift of the foreign callback — the persister's JS methods (save/load/close) are never invoked.
Everything that doesn't take a foreign callback works: PDK init, Uri.parse, checkPjSupported, new SenderBuilder(...), buildRecommended(...). Persister object shape is identical to the upstream InMemorySenderPersister[Async] test fixtures. Those fixtures pass under tsx --test (Node), but PDK's WASM bundle doesn't appear to be exercised in a real browser anywhere in CI — we may be the first browser exposure.
Related: #1389 (same pain on receiver-side validation), #1446 (open draft applying the architectural fix to receive-side), #1287 (added saveAsync with the apparent intent of unblocking WASM, but the async path lifts the same foreign handle and traps at the same point).
Stack trace (sync; async traps at the same lift)
RuntimeError: memory access out of bounds
at <Arc<Arc<dyn JsonSenderSessionPersister>> as Drop>::drop
at <dyn JsonSenderSessionPersister as FfiConverterArc>::try_lift
at uniffi_payjoin_ffi_fn_method_initialsendtransition_save::{closure#0}
Reading bottom-up: Rust enters save, try_lift on the foreign handle faults, and the partial Arc<Arc<...>> is unwound (the Drop frame).
Reproduction
import * as mod from 'payjoin'
await mod.uniffiInitAsync()
const pdk = mod.default.payjoin
const pjUri = pdk.Uri.parse(BIP21_URI).checkPjSupported() // ✓
const builder = new pdk.SenderBuilder(psbtBase64, pjUri) // ✓
const initial = builder.buildRecommended(feeRateSatPerVb) // ✓
class MemSenderPersisterAsync {
events = []
async save(event) { this.events.push(event) }
async load() { return this.events }
async close() {}
}
await initial.saveAsync(new MemSenderPersisterAsync())
// RuntimeError: memory access out of boundsThe sync variant (new MemSenderPersister() + initial.save(...)) traps identically.
Environment
payjoin(PDK) —1.0-rc.2uniffi-bindgen-react-native—0.30.0-1wasm-bindgen-cli—0.2.108- Build: upstream's
payjoin-ffi/javascript, withubrn.config.yamlpatched fromtarget: nodejstotarget: webso wasm-bindgen emits a browser loader (the upstreamweb:block defaulting tonodejslooks like a separate bug — without the patch the emittedindex.jsdoesrequire('fs')and crashes immediately in any browser). - Vite 5 dev server, Chrome and Safari both reproduce.
uniffiInitAsync()is awaited before any FFI call;payjoin.default.initialize()runs and registers all callback vtables.
Ruled out
- Wrong
mod.payjoinshape (usemod.default.payjoin— works in both node and web entries). - GC of method-chain temporaries (named locals across awaits — no change).
- Bare
pj=vs full BIP 21 URI (parse already succeeds). uniffiInitAsync()not awaited / vtables not registered (verified).wasm-bindgenversion drift (pinned to 0.2.108).- Persister method shape (matches upstream test fixtures verbatim).
Suggested fix
Two paths:
- Apply Non-blocking Interface for Payjoin State Machine #1446's "return event from transition, persist in JS, feed back" pattern to the sender persister. Same architectural pain that FFI callback traits force non async #1389 surfaced for receiver validation; same fix shape avoids the foreign-callback FFI path entirely.
- Fix
uniffi-bindgen-react-native's wasm-bindgen-targettry_liftfor foreign callback objects. Broader scope — likely fixes receiver validation callbacks in browsers too.
Happy to test candidate fixes against our repro and contribute a Playwright smoke test if that helps prevent regressions — javascript.yml currently builds the WASM bundle but doesn't browser-execute it.
Thanks for the great library.
Activity
xstoicunicornx commentedon Apr 28, 2026
I wasn't able to recreate this same issue, but maybe you can take a look at what I am doing that is different from you.
I created Browser Payjoin which uses custom built payjoin NPM package and also has some custom mods for the
index.web.jsentrypoint, see the notes section.Was able to build and save a sender session and confirm it was advanced to the next state.
One of the custom mods was specific for Vite, so I am not sure that can be supported in this repo.
The other custom mod for editing the file directories is something that I'll open an issue for in the Uniffi Bindgen React Native repo since thats the library that is generating the
index.web.jsfile.As far as the custom built payjoin NPM package, I do think it raises the question of whether payjoin should have one Node NPM package and another browser/web NPM package? Or if there is a way to package both together? @spacebear21 @DanGould
spacebear21 commentedon Apr 28, 2026
I think it's possible to package both together, which would be preferable imo. According to the robot it's feasible with the
exportsfield in package.json:{ "exports": { ".": { "react-native": "./dist/rn/index.js", "browser": "./dist/web/index.js", "node": "./dist/web/index.js", "default": "./dist/web/index.js" } } }However I'm not sure what friction we might run into with uniffi-bindgen-react-native specifics, install footprint, and package size.
xstoicunicornx commentedon May 27, 2026
Hey Conor - were you still seeing this with the latest NPM package?
xstoicunicornx commentedon Aug 29, 2026
Built a payjoin demo using the javascript web bindings: https://github.com/xstoicunicornx/browser-payjoin
Note: the Javascript bindings are still lacking an ergonomic interface for its async runtime, a better interface will be landing with #1446.
Closing for now, feel free to re-open if this is still an issue.