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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions