Merchants can verify a Zenith-signed payment in under a second with no internet. Zenith limits the risk and reconciles everything when the network returns.
Hackathon prototype. Not a live Zenith Bank channel. The bank signing key is a file here; in production it belongs in the bank's HSM.
A customer says they have paid. The merchant's POS has no network, so it cannot ask the bank. The merchant either releases goods and risks a fake or reused receipt, or turns away a genuine customer.
An immediate cryptographic decision that a payment proof was issued by Zenith, for this merchant, for the amount they asked for, and has not been used before. Final settlement is confirmed when connectivity returns. We do not claim live settlement while offline; we bound the risk and reconcile automatically.
Medieval tally sticks were split lengthwise. Each side kept one half, and only matching halves were genuine. Offline Proof works the same way.
- Ask. The merchant enters an amount. The app generates a random request ID (
tx), stores aREQUESTentry in its tamper-evident log, and shows a QR/link. Works fully offline. - Pay. The customer opens the link, reviews the request and pays through a Zenith channel (simulated by
bank.html). Zenith signs a receipt for exactly thattx, merchant and amount. - Verify offline. The merchant scans the receipt. The app checks the Ed25519 signature, the merchant, expiry, that the
txis a request this device made, that the amount matches, and that it was not used before. It shows a checklist and the time taken. - Reconcile. When online, the merchant sends its log. The bank verifies the hash chain, matches both halves of every tally, recovers payments the merchant never scanned, flags anything inconsistent, and scores the risk.
Merchant PWA (offline) Zenith (online)
---------------------- ---------------
1. REQUEST tx, amount --QR/link--> Customer pays (app/USSD/data)
|
3. verify signed receipt <--QR/code-- 2. sign receipt for that tx
(no network needed)
|
| network returns
v
4. POST /api/sync -------------------> chain check, match tallies,
(chained log) recover, flag, score risk
-> dashboard.html
The signed receipt format is unchanged from the original prototype; only who chooses tx moved to the merchant.
| Attack | Result on the merchant's screen |
|---|---|
| Amount altered after signing | Fake or altered receipt |
| Receipt for another merchant | Paid to another merchant |
| Same receipt scanned twice | Already accepted |
| Genuine receipt for a payment this device never requested | Not a payment you requested |
| Paid a different amount than requested | Amount does not match |
| Old receipt | Receipt expired |
| Request left unpaid too long | Payment request expired (settles at sync if paid) |
Because a receipt only matches the request held on the device that created it, a second offline device cannot accept the same receipt. Fully disconnected devices can never share state, so exposure is also capped by the offline allowance (₦50,000 per device in the demo; in production the bank sets and signs it per device).
Signed fields: kid, tx, amt, cur, m, p, ts, exp (amount in kobo, payer shown as ***1234). Token: NPP1.<base64url message>.<base64url Ed25519 signature>. Receipts last 10 minutes with 5 minutes of clock tolerance. Requests stay open for 15 minutes.
Every event (requests, checks, settlements) is appended to a hash-chained log in the merchant's browser. Each entry commits to the one before it, so editing or deleting an entry is detected locally ("Verify log") and by the bank at sync.
/api/sync verifies the chain, then for each entry:
- Accepted receipt matched to the bank's record and to a request on that device: confirmed.
- Accepted receipt the bank never issued for this merchant, with no matching request, or accepted twice: flagged.
- Open request the customer paid but the merchant never scanned (phone died, receipt lost): recovered and settled.
- Open request nobody paid: reported as unpaid.
dashboard.html (operator key required) shows receipts issued, reconciled, awaiting sync, fraud signals, per-merchant risk with reasons, and recent syncs.
risk.js is an Isolation Forest (unsupervised anomaly detection) plus hard rules, scoring each merchant log on volume, reject/forged/duplicate rates, payment sizes, bursts and night activity. It is trained on simulated normal activity. Retrain on real pilot data before relying on it. Request and settlement entries are excluded from scoring.
npm install
npm test
npm start # http://localhost:9100- Open
/merchant.htmlonce while online so it stores the bank key and caches the app. - Enter an amount and press Create payment request.
- Open the link on a second device (or tab) and press Pay. A signed receipt QR appears.
- Go offline. On the merchant app, scan the receipt (or paste the code): green checklist.
- Try Make forged copy, scan the same receipt twice, or pay without a request. All are rejected.
- Back online, Send log to bank, then open
/dashboard.html(operator keyzenith-demo).
Camera scanning and the service worker need HTTPS or localhost; pasting the code works anywhere. See docs/DEPLOY.md for getting a public HTTPS link.
| Variable | Default | Purpose |
|---|---|---|
PORT |
9100 |
HTTP port |
OPERATOR_KEY |
zenith-demo |
Key for /api/dashboard (change it when hosting) |
DATA_DIR |
./data |
Where the signing key, receipts and dashboard data are stored |
Issue and sync are rate limited per IP.
server.js Bank simulator: signing, issue, sync/reconcile, dashboard API
risk.js Isolation Forest-style fraud scoring and hard rules
test.js Crypto, tally, log-chain and risk tests
public/
index.html Landing page
merchant.html/js Offline merchant app: request, verify, log, sync
bank.html/js Customer pay page (simulated Zenith channel)
dashboard.html/js Bank reconciliation dashboard
npp.js Shared signing, verification, tally and log-chain logic
sw.js Service worker for offline use
style.css Zenith-style theme
docs/ Demo script and deployment notes
/api/issuesimulates the customer's authenticated Zenith channel. In production the bank signs only after a real, authorised debit.- The customer must reach Zenith to pay (data, USSD or the bank app); the merchant's side is what works offline.
- The signing key is a file. Production: HSM/KMS, key rotation and revocation, signed key manifests.
- Merchant and device identity are demo values. Production: device enrollment, per-device keys, signed merchant configuration and bank-set offline limits.
- Storage is JSON files and browser localStorage. Production: a database on the server, IndexedDB on the device.
- Expiry uses the device clock. Request IDs are single-use regardless, and the bank reconciles every entry.
- The fraud model is trained on synthetic data.
