| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .. | ||
| 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:
-
Explicit
.free()in atry/finally:const card = new CardData(pan, m, y); try { // ... } finally { card.free(); } -
Consume-by-value methods automatically invalidate the JS pointer. After
vault.tokenize(card), thecardJS object's pointer is set to null and any further method call throws. No.free()needed. -
using(ES2026) if your toolchain targets ES2026 or you shimSymbol.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 afterwasm-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.