Aller au contenu

Référence

SDK : @payvault/protocol

Le paquet de protocole est en TypeScript, sans dépendance d'exécution hormis WebCrypto. Il fonctionne dans les navigateurs, sur React Native (avec une implémentation de WebCrypto) et sous Node 20+.

Cette page est disponible en anglais pour le moment.

Install and platforms

@payvault/protocol is currently a private workspace package in this repository and ships as TypeScript source. A published build is planned. It has no runtime dependencies and needs only WebCrypto with ECDSA P-256 and SHA-256.

Platform support
PlatformStatusNotes
Web (browser, PWA)AvailableWebCrypto needs HTTPS or localhost. Keys are non-extractable
Node 20+AvailableGlobal crypto.subtle. Use for servers, tests and tools
React NativeAvailable with a WebCrypto implementationProvide globalThis.crypto.subtle with P-256 support, and store keys in the platform keystore
Android (Kotlin)PlannedStrongBox or TEE-backed keys
iOS (Swift)PlannedSecure Enclave keys
USSD and SMS fallbackPlannedFor feature phones. Not built

What is exported

Exports of @payvault/protocol
ExportsPurpose
generateKeyPair, exportPublicKey, importPublicKeyDevice and issuer keys. Public keys are 33-byte compressed P-256
createRequest, requestToQrMerchant: build and display a payment request
parseQr, fromQrText, toQrTextDecode or frame any PayVault QR text
initWalletState, walletBalance, canSpend, splitDrawPayer: state and pre-checks
signPaymentPayer: sign the next payment and return the new state
bundleToQr, encodeBundle, decodeBundleThe 276-byte bundle
verifyBundleMerchant: offline verification, returns { ok, id, payment, cert, creditDrawn } or { ok: false, code }
signClose, encodeClose, decodeClose, verifyCloseSignatureEarly cash-out statements
encodeAllowance, decodeAllowanceCertificates
paymentId, allowanceGenesisIds and the chain anchor
toHex, fromHex, toBase64Url, fromBase64Url, randomBytesByte helpers
analyzeAllowanceServer side: fork, chain-break and overspend analysis

Device keys

Generate a key pair per device and enrol its public key with your backend. Never export the private key.

TypeScript
import { generateKeyPair, exportPublicKey, toHex } from "@payvault/protocol";// Non-extractable ECDSA P-256 key. IndexedDB can store the CryptoKeyPair// object itself, so the raw private key never enters JavaScript memory.const keys = await generateKeyPair();               // extractable = falseawait kv.set("device-keys", keys);const publicKeyHex = toHex(await exportPublicKey(keys.publicKey));

Payer: sign, persist, then show

signPayment is a pure function of the state, the request and the key. It returns a payment and the next state. Two rules keep an honest device from looking like a cheater:

  • Persist the new state before you show the QR. If the app is killed in between, the worst case is a payment that was signed but never shown.
  • Run payments one at a time. Two overlapping calls that start from the same state would sign two different payments with the same sequence number, which is exactly a fork.
TypeScript
import {  bundleToQr, canSpend, parseQr, signPayment, walletBalance,  type WalletState,} from "@payvault/protocol";let queue: Promise<unknown> = Promise.resolve();/** Serialise payments: two concurrent signs from the same state would fork. */export function pay(scannedText: string): Promise<string> {  const run = queue.then(async () => {    const scanned = parseQr(scannedText);    if (scanned.kind !== "request") throw new Error("Not a payment request");    const state = (await kv.get("wallet-state")) as WalletState;    const check = canSpend(state, scanned.request.amount, scanned.request.currency);    if (!check.ok) throw new Error(check.message);       // check.code: expired, over_cap, ...    const keys = (await kv.get("device-keys")) as CryptoKeyPair;    const { payment, state: next } = await signPayment(keys.privateKey, state, scanned.request);    await kv.set("wallet-state", next);                  // 1. persist the new state    return bundleToQr({ cert: next.cert, payment });     // 2. only then show it  });  queue = run.catch(() => undefined);  return run;}console.log(walletBalance(state)); // { cap, spent, remaining, fundedRemaining, creditRemaining, creditUsed, paymentsLeft }

Merchant: request, verify, store

Cache the network snapshot whenever there is signal. verifyBundle accepts the issuer key lookup as a function, so it can be a map, a database or an in-memory cache, and it can be asynchronous. Treat a stale cache as a business decision: the older the revocation list, the larger the window in which a bad allowance can be accepted.

TypeScript
import {  createRequest, decodePayment, encodeBundle, fromHex, importPublicKey,  parseQr, requestToQr, toBase64Url, verifyBundle,} from "@payvault/protocol";// 1. Refresh the cache whenever there is signal.export async function refreshNetwork() {  const net = await (await fetch("/api/network")).json();  await kv.set("network", { ...net, fetchedAt: Date.now() });}// 2. Build the verifier context from the cache and your own records.async function contextFor(request: PaymentRequest) {  const net = await kv.get("network");  const keyCache = new Map<number, CryptoKey>();  return {    issuerKey: async (kid: number) => {      if (!keyCache.has(kid)) {        const k = net.issuerKeys.find((x) => x.kid === kid);        if (!k) return undefined;        keyCache.set(kid, await importPublicKey(fromHex(k.publicKey)));      }      return keyCache.get(kid);    },    isRevoked: (id: string) => net.revocations.some((r) => r.allowanceId === id),    merchantId,    request,    isNonceUsed: (hex: string) => usedNonces.has(hex),    // seen: payments you already hold from the same allowance (from your outbox)  };}export async function charge(amountMinor: number) {  const request = createRequest(merchantId, amountMinor, "NGN", "Mama Ade Stores");  return { request, qr: requestToQr(request) };}export async function accept(scannedText: string, request: PaymentRequest) {  const scanned = parseQr(scannedText);  if (scanned.kind !== "bundle") throw new Error("Not a payment bundle");  const result = await verifyBundle(scanned.bundle, await contextFor(request));  if (!result.ok) return { accepted: false as const, code: result.code };  usedNonces.add(toHex(request.nonce));  await outbox.put(result.id, toBase64Url(encodeBundle(scanned.bundle))); // persist first  return { accepted: true as const, creditDrawn: result.creditDrawn };}

Passing payments you already hold

TypeScript
// Give verifyBundle the payments you already hold from the same allowance,// so it can catch duplicates, forks and impossible totals on the spot.const seen = (await outbox.byAllowance(allowanceHex)).map((b) => ({  id: b.id,  payment: decodeBundle(fromBase64Url(b.bundle)).payment,}));await verifyBundle(bundle, { ...ctx, seen });

Close statement

To let a customer cash out early, sign a close statement at the current state, stop spending, and hand the statement to your backend.

TypeScript
import { encodeClose, signClose, toBase64Url } from "@payvault/protocol";const state = (await kv.get("wallet-state")) as WalletState;const statement = await signClose(keys.privateKey, state);// The device should stop spending this allowance from now on.await markClosed(allowanceHex);           // your own flag in app storage// Send this to your backend, which posts it to POST /api/v1/allowances/closeconst close = toBase64Url(encodeClose(statement));

Errors

Decoders and signPayment throw ProtocolError with a stable code. verifyBundle does not throw for a bad payment; it returns { ok: false, code }. Reject codes are listed on the protocol page.

TypeScript
import { ProtocolError } from "@payvault/protocol";try {  parseQr(text);} catch (e) {  if (e instanceof ProtocolError) {    // e.code: not_payvault | bad_base45 | bad_version | bad_type | truncated | trailing_bytes ...  }}