Skip to content

JsonSenderSessionPersister[Async] crashes WASM heap in real browsers #1508

Closed

Description

@ConorOkus

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 bounds

The sync variant (new MemSenderPersister() + initial.save(...)) traps identically.

Environment

  • payjoin (PDK) — 1.0-rc.2
  • uniffi-bindgen-react-native — 0.30.0-1
  • wasm-bindgen-cli — 0.2.108
  • Build: upstream's payjoin-ffi/javascript, with ubrn.config.yaml patched from target: nodejs to target: web so wasm-bindgen emits a browser loader (the upstream web: block defaulting to nodejs looks like a separate bug — without the patch the emitted index.js does require('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.payjoin shape (use mod.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-bindgen version drift (pinned to 0.2.108).
  • Persister method shape (matches upstream test fixtures verbatim).

Suggested fix

Two paths:

  1. 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.
  2. Fix uniffi-bindgen-react-native's wasm-bindgen-target try_lift for 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

  1. xstoicunicornx commented on Apr 28, 2026

    @xstoicunicornx
    Collaborator

    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.js entrypoint, 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.js file.

    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

  2. spacebear21 commented on Apr 28, 2026

    @spacebear21
    Collaborator

    whether payjoin should have one Node NPM package and another browser/web NPM package? Or if there is a way to package both together?

    I think it's possible to package both together, which would be preferable imo. According to the robot it's feasible with the exports field 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.

  3. xstoicunicornx commented on May 27, 2026

    @xstoicunicornx
    Collaborator

    Hey Conor - were you still seeing this with the latest NPM package?

  4. xstoicunicornx commented on Aug 29, 2026

    @xstoicunicornx
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions