Skip to content

Reference

Wire protocol

All integers are big-endian. Amounts are unsigned integers in minor units. Times are Unix seconds. Version byte is 1.

This page describes the implementation in packages/protocol. Every message starts with a version byte and a type byte. Decoders reject an unknown version, a wrong type, truncated input and trailing bytes.

Cryptography

  • Signatures are ECDSA on P-256 with SHA-256, encoded as raw r||s, 64 bytes. P-256 is what phone secure hardware supports natively and what WebCrypto can keep non-extractable.
  • Public keys travel compressed: 33 bytes, with a 0x02 or 0x03 prefix.
  • A signature covers the message body only, that is the bytes before the signature, including version and type.
  • Hashes are SHA-256 truncated to 16 bytes.
identifiers
payment id = SHA-256(paymentBody)[0..16]        // signature excludedgenesis    = SHA-256(allowanceBody)[0..16]      // prevHash of payment #1prevHash(n) = payment id of payment n-1

The payment id excludes the signature on purpose. ECDSA signatures are malleable, and this way a re-signed copy of the same payment cannot be turned into a second, distinct id.

Allowance certificate (82 B body + 64 B signature)

Signed by the partner's issuer key. The whole certificate is 146 bytes.

Allowance certificate layout
OffsetSizeFieldTypeNotes
01versionu8Always 1
11typeu80x01
216allowanceIdbytesRandom, unique per allowance
184issuerKidu32Id of the issuer key that signed this
2233devicePubbytesCompressed P-256 point (02 or 03 prefix)
552countryASCIIISO 3166-1 alpha-2, e.g. NG
573currencyASCIIISO 4217, e.g. NGN
604fundedu32Minor units
644creditu32Minor units of overdraft
684perTxLimitu32Largest single payment
722maxPaymentsu16Payment count limit
744issuedAtu32Unix seconds
784expiresAtu32Unix seconds
8264signaturebytesIssuer ECDSA over bytes 0..81, raw r||s

Payment (64 B body + 64 B signature)

Signed by the device key named in the allowance. The whole payment is 128 bytes.

Payment layout
OffsetSizeFieldTypeNotes
01versionu8Always 1
11typeu80x02
216allowanceIdbytesThe allowance being spent
182sequ16Starts at 1, increments by 1
204amountu32This payment, minor units
244cumulativeu32Total spent including this payment
288merchantIdbytesFrom the request
368noncebytesFrom the request
444timeu32Device clock, Unix seconds
4816prevHashbytesId of payment seq-1, or the genesis hash for seq 1
6464signaturebytesDevice ECDSA over bytes 0..63, raw r||s

Payment request (30 to 62 B, unsigned)

Created by the merchant. It is not signed: it only tells the payer what to sign. The payment's signature and the merchant's checks bind the answer to it.

Payment request layout
OffsetSizeFieldTypeNotes
01versionu8Always 1
11typeu80x03
28merchantIdbytesAssigned by the partner
103currencyASCIIISO 4217
134amountu32Minor units
178noncebytesRandom per request
254timeu32Unix seconds
291nameLenu80 to 32
30nameLennameUTF-8Display name. Longer names are truncated by the encoder

Bundle (276 B)

What the payer shows and the merchant scans. It is the two signed structures back to back.

Bundle layout
OffsetSizeFieldTypeNotes
01versionu8Always 1
11typeu80x04
2146allowancebody + sigAllowance body (82) followed by its signature (64)
148128paymentbody + sigPayment body (64) followed by its signature (64)

Close statement (28 B body + 64 B signature)

Signed by the device. The whole statement is 92 bytes.

Close statement layout
OffsetSizeFieldTypeNotes
01versionu8Always 1
11typeu80x05
216allowanceIdbytesThe allowance being closed
182sequ16Last sequence number spent
204cumulativeu32Total spent at that point
244timeu32Device clock, Unix seconds
2864signaturebytesDevice ECDSA over bytes 0..27, raw r||s

QR framing

A QR payload is the literal text PV followed by the Base45 encoding (RFC 9285) of the message bytes. Base45 uses only the QR alphanumeric character set, which packs densely and survives camera decoders that mangle binary data. Requests and bundles are the two message types shown as QR codes.

framing
text  = "PV" + base45(payload)          // RFC 9285, QR alphanumeric charsetRequest  <= 62 bytes  ->  at most 95 charactersBundle    276 bytes   ->  416 characters
parsing
import { parseQr } from "@payvault/protocol";const scanned = parseQr(text);// { kind: "request", request } | { kind: "bundle", bundle }// Throws ProtocolError("not_payvault") when the text does not start with "PV".

Offline verification rules

verifyBundle runs these checks in this order and returns the first failure. It needs only the bundle, the cached issuer keys and revocations, the merchant's own state and the clock. The default tolerated clock skew is 300 seconds, because device clocks are not trusted.

Offline verification rules, in order
#CodeFails when
1unknown_issuerNo cached public key for issuerKid
2bad_issuer_signatureThe certificate signature does not verify under that key
3not_yet_validissuedAt is more than the skew in the future
4expirednow is more than the skew past expiresAt
5revokedThe allowance id is in the cached revocation list
6wrong_allowanceThe payment's allowanceId differs from the certificate's
7bad_payer_signatureThe payment signature does not verify under devicePub
8wrong_merchantThe payment's merchantId is not this merchant's
9request_mismatchNonce, amount or currency differ from the request this merchant displayed
10bad_amountamount is 0, cumulative is below amount, or seq is below 1
11over_tx_limitamount is above perTxLimit
12over_capcumulative is above funded + credit
13too_many_paymentsseq is above maxPayments
14bad_chainFor seq 1: cumulative differs from amount, or prevHash is not the genesis hash
15duplicate, fork, inconsistentAgainst payments this merchant already holds from the same allowance (see below)
16nonce_reusedThe nonce was already used, per the merchant's own record

Checks against payments already seen

  • duplicate: the same payment id was already accepted.
  • fork: another payment with the same seq exists, or the neighbouring payment is not linked by prevHash.
  • inconsistent: the cumulative totals cannot both be true (a later payment must start at or above an earlier one's cumulative).

On success the result includes creditDrawn, the part of this payment that comes from the overdraft.

Reconciliation rules

The server sees every payment for an allowance, across all merchants. It runs two stages.

Stage 1: per-payment ingestion

  1. Decode the bundle (decode on failure).
  2. Find the allowance (unknown_allowance).
  3. The certificate must be byte-identical to the one PayVault issued (cert_mismatch).
  4. Payment allowance id matches (wrong_allowance); payer signature verifies against the stored device key (bad_payer_signature).
  5. If the payment id is known, return duplicate without changing anything.
  6. Range checks: bad_amount, over_tx_limit, over_cap, too_many_payments, and expired when the device time is more than 300 seconds past expiry.
  7. Hosted ledgers must know the merchant (unknown_merchant); API submitters may only submit for their own allowances or merchants (forbidden).

Stage 2: allocation and analysis

An accepted payment is honoured. The amount is allocated in a fixed order:

  1. Funded value not yet settled or refunded.
  2. Overdraft (credit) not yet settled. Any draw creates or increases the allowance's loan.
  3. Recovery from the holder's wallet, hosted ledgers only, when the vault was already refunded.
  4. The risk pool, for whatever remains.

Then all payments for the allowance are re-analysed:

  • Payments are de-duplicated by id and sorted by sequence number.
  • A fork is more than one distinct payment with the same sequence number.
  • A chain break is a payment whose prevHash does not equal the previous payment's id, or whose cumulative is not the previous cumulative plus its amount.
  • An overspend is total honoured amount above funded + credit.
  • After close is a payment with a sequence number above a declared close statement.

Implicated payments are marked flagged but stay honoured. Any fork, chain break, after-close payment or overspend with a real loss revokes the allowance (it enters the revocation list), freezes the holder's credit profile and emits fraud.detected. Fraud cases are visible on GET /api/v1/allowances/{id}.