Référence
Protocole sur le fil
Tous les entiers sont en gros-boutiste. Les montants sont des entiers non signés dans la plus petite unité. Les heures sont en secondes Unix. L'octet de version vaut 1.
Cette page est disponible en anglais pour le moment.
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.
payment id = SHA-256(paymentBody)[0..16] // signature excludedgenesis = SHA-256(allowanceBody)[0..16] // prevHash of payment #1prevHash(n) = payment id of payment n-1The 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.
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 1 | version | u8 | Always 1 |
| 1 | 1 | type | u8 | 0x01 |
| 2 | 16 | allowanceId | bytes | Random, unique per allowance |
| 18 | 4 | issuerKid | u32 | Id of the issuer key that signed this |
| 22 | 33 | devicePub | bytes | Compressed P-256 point (02 or 03 prefix) |
| 55 | 2 | country | ASCII | ISO 3166-1 alpha-2, e.g. NG |
| 57 | 3 | currency | ASCII | ISO 4217, e.g. NGN |
| 60 | 4 | funded | u32 | Minor units |
| 64 | 4 | credit | u32 | Minor units of overdraft |
| 68 | 4 | perTxLimit | u32 | Largest single payment |
| 72 | 2 | maxPayments | u16 | Payment count limit |
| 74 | 4 | issuedAt | u32 | Unix seconds |
| 78 | 4 | expiresAt | u32 | Unix seconds |
| 82 | 64 | signature | bytes | Issuer 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.
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 1 | version | u8 | Always 1 |
| 1 | 1 | type | u8 | 0x02 |
| 2 | 16 | allowanceId | bytes | The allowance being spent |
| 18 | 2 | seq | u16 | Starts at 1, increments by 1 |
| 20 | 4 | amount | u32 | This payment, minor units |
| 24 | 4 | cumulative | u32 | Total spent including this payment |
| 28 | 8 | merchantId | bytes | From the request |
| 36 | 8 | nonce | bytes | From the request |
| 44 | 4 | time | u32 | Device clock, Unix seconds |
| 48 | 16 | prevHash | bytes | Id of payment seq-1, or the genesis hash for seq 1 |
| 64 | 64 | signature | bytes | Device 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.
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 1 | version | u8 | Always 1 |
| 1 | 1 | type | u8 | 0x03 |
| 2 | 8 | merchantId | bytes | Assigned by the partner |
| 10 | 3 | currency | ASCII | ISO 4217 |
| 13 | 4 | amount | u32 | Minor units |
| 17 | 8 | nonce | bytes | Random per request |
| 25 | 4 | time | u32 | Unix seconds |
| 29 | 1 | nameLen | u8 | 0 to 32 |
| 30 | nameLen | name | UTF-8 | Display 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.
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 1 | version | u8 | Always 1 |
| 1 | 1 | type | u8 | 0x04 |
| 2 | 146 | allowance | body + sig | Allowance body (82) followed by its signature (64) |
| 148 | 128 | payment | body + sig | Payment 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.
| Offset | Size | Field | Type | Notes |
|---|---|---|---|---|
| 0 | 1 | version | u8 | Always 1 |
| 1 | 1 | type | u8 | 0x05 |
| 2 | 16 | allowanceId | bytes | The allowance being closed |
| 18 | 2 | seq | u16 | Last sequence number spent |
| 20 | 4 | cumulative | u32 | Total spent at that point |
| 24 | 4 | time | u32 | Device clock, Unix seconds |
| 28 | 64 | signature | bytes | Device 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.
text = "PV" + base45(payload) // RFC 9285, QR alphanumeric charsetRequest <= 62 bytes -> at most 95 charactersBundle 276 bytes -> 416 charactersimport { 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.
| # | Code | Fails when |
|---|---|---|
| 1 | unknown_issuer | No cached public key for issuerKid |
| 2 | bad_issuer_signature | The certificate signature does not verify under that key |
| 3 | not_yet_valid | issuedAt is more than the skew in the future |
| 4 | expired | now is more than the skew past expiresAt |
| 5 | revoked | The allowance id is in the cached revocation list |
| 6 | wrong_allowance | The payment's allowanceId differs from the certificate's |
| 7 | bad_payer_signature | The payment signature does not verify under devicePub |
| 8 | wrong_merchant | The payment's merchantId is not this merchant's |
| 9 | request_mismatch | Nonce, amount or currency differ from the request this merchant displayed |
| 10 | bad_amount | amount is 0, cumulative is below amount, or seq is below 1 |
| 11 | over_tx_limit | amount is above perTxLimit |
| 12 | over_cap | cumulative is above funded + credit |
| 13 | too_many_payments | seq is above maxPayments |
| 14 | bad_chain | For seq 1: cumulative differs from amount, or prevHash is not the genesis hash |
| 15 | duplicate, fork, inconsistent | Against payments this merchant already holds from the same allowance (see below) |
| 16 | nonce_reused | The 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 sameseqexists, or the neighbouring payment is not linked byprevHash.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
- Decode the bundle (
decodeon failure). - Find the allowance (
unknown_allowance). - The certificate must be byte-identical to the one PayVault issued (
cert_mismatch). - Payment allowance id matches (
wrong_allowance); payer signature verifies against the stored device key (bad_payer_signature). - If the payment id is known, return
duplicatewithout changing anything. - Range checks:
bad_amount,over_tx_limit,over_cap,too_many_payments, andexpiredwhen the device time is more than 300 seconds past expiry. - 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:
- Funded value not yet settled or refunded.
- Overdraft (credit) not yet settled. Any draw creates or increases the allowance's loan.
- Recovery from the holder's wallet, hosted ledgers only, when the vault was already refunded.
- 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
prevHashdoes 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}.