open-standards/openpay/crates/op-wasm
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00
..
js
scripts
src
tests
Cargo.toml
README.md

OpenPay for JavaScript / TypeScript

WebAssembly bridge over the OpenPay Rust core. Same architectural shape as the iOS (Phase 8) and Android (Phase 9) bridges: opaque class handles, oracle-discipline error collapsing, and cross-platform error code alignment.

What you get

  • CardData — Validated PAN + expiration. Luhn / length / date sanity checked on construction. PAN bytes live in wasm linear memory and are never readable from JavaScript.
  • VaultRef — Opaque token reference.
  • RustVault — In-memory reference vault (development).
  • HeuristicScorer — Rule-based fraud scorer.
  • OpenPayError — Error class with .code, .kind, .message.
  • TokenizationPolicy — Configurable format / lifetime / TTL.
  • TokenFormat / TokenLifetime — TypeScript-friendly enums.

Build the wasm bundle first

# One-time setup:
cargo install wasm-pack
rustup target add wasm32-unknown-unknown

# From the crate root (`crates/op-wasm/`):
bash scripts/build-wasm.sh --target web      # browsers (ESM)
bash scripts/build-wasm.sh --target nodejs   # Node.js (CommonJS)
bash scripts/build-wasm.sh --target bundler  # webpack / rollup

The build produces pkg/op_wasm.js, pkg/op_wasm_bg.wasm, and pkg/op_wasm.d.ts (the TypeScript types).

Usage example

Browser (ESM)

import init, {
  CardData,
  RustVault,
  TokenizationPolicy,
  OpenPayError,
} from './pkg/op_wasm.js';

await init();

const vault = new RustVault('checkout');

try {
  const card = new CardData('4242424242424242', 12, 2030);
  const token = vault.tokenize(card, TokenizationPolicy.cardOnFile());
  // `card` was consumed by tokenize() — its JS pointer is now null.

  console.log('Token:', token.asString);

  // Persist token.asString to localStorage or send to server.
  localStorage.setItem('pm:default', token.asString);

  token.free();
} catch (e) {
  if (e instanceof OpenPayError) {
    console.warn(`payment failed: ${e.kind}`, e.message);
  } else {
    throw e;
  }
} finally {
  vault.free();
}

Recovering a token later

import { VaultRef } from './pkg/op_wasm.js';

const tokenStr = localStorage.getItem('pm:default');
const ref = VaultRef.fromString(tokenStr);

try {
  const card = vault.detokenize(ref);
  submitToAcquirer(card.firstSix, card.lastFour);
  card.free();
} catch (e) {
  if (e.code === 2 /* VaultLookupFailed */) {
    showError('Card not found');
  } else if (e.code === 3 /* TokenExpired */) {
    showError('Token expired — please re-enter your card');
  } else {
    throw e;
  }
} finally {
  ref.free();
}

Node.js

const { RustVault, CardData } = require('./pkg/op_wasm.js');
// No init() call needed for the nodejs target.

const vault = new RustVault('test');
const token = vault.tokenize(new CardData('4242424242424242', 12, 2030));
console.log(token.asString);
token.free();
vault.free();

The .free() protocol

wasm-bindgen does not have a finalizer mechanism — the JS engine doesn't expose one portably. Every #[wasm_bindgen]-exported Rust struct gets an explicit .free() method that must be called when the JS object is no longer needed. Forgetting to call it leaks wasm linear memory (which is never reclaimed by the JS GC).

Three idiomatic patterns:

  1. Explicit .free() in a try/finally:

    const card = new CardData(pan, m, y);
    try {
      // ...
    } finally {
      card.free();
    }
    
  2. Consume-by-value methods automatically invalidate the JS pointer. After vault.tokenize(card), the card JS object's pointer is set to null and any further method call throws. No .free() needed.

  3. using (ES2026) if your toolchain targets ES2026 or you shim Symbol.dispose:

    using vault = new RustVault('checkout');
    using card = new CardData(pan, m, y);
    // automatic .free() when scope exits
    

Error inspection

OpenPayError has three readable properties:

Property Type Description
.code number i32 discriminant, matches iOS/Android
.kind string PascalCase variant name
.message string Short human-readable description

Discriminants (stable across iOS, Android, web):

Code Kind Meaning
1 InvalidInput PAN, expiration, or token format malformed
2 VaultLookupFailed Token unknown, malformed, or auth-failed (collapsed)
3 TokenExpired Past TTL
4 TokenAlreadyConsumed Single-use, already used
5 FraudDeclined Scorer rejected
6 FraudReviewRequired Scorer flagged for human review
7 Backend Vault / rail / scorer opaque failure
8 Internal FFI-internal bug
9 Capacity Rate limit or capacity exhaustion

Note: OpenPayError does not extend Error

wasm-bindgen-exported Rust structs become standalone JS classes; they don't inherit from the native Error prototype. So e instanceof Error returns false for thrown OpenPayError instances. This is a known wasm-bindgen limitation. Pattern-match on .code or .kind instead, or wrap at the JS boundary:

import { OpenPayError } from './pkg/op_wasm.js';

export class OpenPayJsError extends Error {
  constructor(public readonly inner: OpenPayError) {
    super(inner.message);
    this.name = 'OpenPayJsError';
  }
  get code()    { return this.inner.code; }
  get kind()    { return this.inner.kind; }
}

export function safeTokenize(vault, card, policy) {
  try {
    return vault.tokenize(card, policy);
  } catch (e) {
    if (e instanceof OpenPayError) throw new OpenPayJsError(e);
    throw e;
  }
}

TypeScript

The pkg/op_wasm.d.ts file is generated automatically by wasm-bindgen from the Rust signatures. Drop pkg/ into your tsconfig.json include and TypeScript will pick it up.

Testing

Two test suites:

  • Rust-side (tests/wasm_bindgen.rs) runs inside a real wasm host:

    wasm-pack test --node              # Node.js
    wasm-pack test --headless --chrome # Chrome
    wasm-pack test --headless --firefox # Firefox
    
  • JS-side (js/test.mjs) exercises the consumer-visible classes after wasm-pack build:

    bash scripts/build-wasm.sh --target nodejs
    node js/test.mjs
    

Plain cargo test -p op-wasm runs the host-side unit tests (FfiError discriminant tests, OpenPayError construction, policy mapping).

Why no Web Crypto API integration?

The Web Crypto API (SubtleCrypto) is async-only — every operation returns a Promise. The OpenPay [Vault] trait is sync (consistent across iOS Keychain, Android Keystore, in-memory Rust, etc.). Reshaping the trait to be async would ripple through every crate; it's a coherent next step but doesn't fit Phase 10.

A future WebCryptoVault class, analogous to Phase 9's KeystoreVault, will use SubtleCrypto for hardware-accelerated AES-GCM and IndexedDB for persistence. That ships as Phase 10.1 once the async-vault story is designed.

For now: RustVault uses the same aes-gcm-siv cipher as iOS and Android. The cryptographic posture is identical across platforms.

Browser support

WebAssembly is supported by:

  • Chrome 57+ (March 2017)
  • Firefox 52+ (March 2017)
  • Safari 11+ (Sept 2017)
  • Edge 16+ (Oct 2017)

This covers ~96% of the global web audience per caniuse.com (May 2026). For older browsers, ship a server-side tokenization fallback.