Skip to content

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

@ConorOkus

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-native0.30.0-1
  • wasm-bindgen-cli0.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.

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