SmartCheq · CBDC Platform · Module Specification

Consumer Wallet

Full functional specification for the browser-based, registry-direct peer-to-peer wallet at smartcheq.com/claim. Covers balance display, guided send, recipient resolution, durable transaction history, and the complete Rails 7.1 + client-side (IndexedDB/localStorage) implementation.

Version 1.0
Updated September 2026
Status Production Reference
Stack Rails 7.1 + IndexedDB/localStorage
§1

System Overview

The Consumer Wallet is the retail, peer-to-peer counterpart to the Validation Desk. It is a single browser page (CvibClaimController#index, served at GET /claim) — no desktop client, no operator login. Ownership of a CVIB note in the registry (CvibNote.current_holder_id) is the authorization; there is no Doorkeeper token or RBAC gate on the send path.

Where the Validation Desk is a compliance officer's review queue for notes entering circulation, the Consumer Wallet is where a holder actually spends and receives them — check a balance, send money to another Holder ID, and see a permanent record of what moved, independent of whatever happens to the underlying notes afterward.

Key Responsibilities

ResponsibilityMechanism
Balance display (multi-currency)Grouped by normalized asset code; available vs. in-transit ("Sending…") tracked separately
Send MoneyPOST /api/v2/personal_pay — explicit serials or amount + asset_code (server selects notes)
Receive MoneyNo explicit claim action — server-side registry credit, picked up by the next syncFromServer() poll
Recipient resolutionRegistered-name search (GET /api/v2/holder_keys/search) or QR scan (GET /holder_ids?holder_id=)
Recent Activity (sent + received)Durable per-transaction localStorage logs, independent of current note holdings
Identity & backup managementWallet Tools modal — add/import/recover Holder ID, delete from this device
Public endpoint, registry-authorized. POST /api/v2/personal_pay takes no bearer token — a sender must simply hold the note(s) being moved (CvibNote.current_holder_id == sender_holder_id), checked server-side inside the transfer transaction. This is a deliberate design choice for a retail P2P instrument, not an oversight; it mirrors how a physical banknote works — possession is the authorization.
§2

Data Models

WalletNote — server-side browser-balance-sync cache

A lightweight mirror of a holder's notes, used to answer GET /api/v2/wallet/notes without hitting the full registry — not the source of truth for ownership (CvibNote.current_holder_id is).

FieldTypeDescription
holder_idstringOwning Holder ID — unique with serial
serialstringFK to CvibNote.serial (by value, not DB FK)
asset_codestringStored raw — may carry a Qt mint prefix, e.g. L-NGN
denominationdecimalFace value
currency_symbol / filenamestringDisplay metadata, carried over from the sender's record on transfer
sourceenump2p_receive | operator_transfer | sandbox_faucet | client_upload | bulk_claim
claimed_atdatetimeCredit timestamp — identical across every note in one transfer
sender_holder_id / sender_namestringSnapshotted at transfer time; sender_name resolves via HolderKey.name, falling back to a masked key only when no registered name exists

CvibTransfer — durable, server-side transfer ledger

One row per completed ownership change, written by both transfer paths. This is what the Validation Desk's Provenance Timeline reads (via registry_lookup) and what powers this wallet's audit trail independent of the note's later fate.

FieldTypeDescription
note_iduuidFK to cvib_notes.note_id
from_holder_id / to_holder_idstringThe two parties to this move
statusenumpending | accepted | cancelled | expired — registry-direct sends always write accepted immediately (atomic, no pending window)
handover_token_hash / expires_atstring / datetimeInherited from this table's original NFC/QR-handover design; inert placeholders for an instant registry-direct transfer
created_bystringThe sending holder (P2P) or acting operator (institutional)

Client-side storage

StoreContentsLifecycle
IndexedDB — SmartCheqDigitalCash / digitalCashOne record per currently-held note (walletNotes_)Volatile — a note's record is deleted the moment it's spent
localStorage — smartcheq_sent_activityOne entry per completed send actionDurable — written once at send time, independent of note lifecycle
localStorage — smartcheq_received_activityOne entry per note, first time it syncs to this deviceDurable — written once at first sync; survives the note being later spent
§3

Identity Card

The topmost card on the home screen. Two mutually-exclusive states: a resolved profile (identity-switcher, once a Holder ID is saved to this device) or a raw entry row (holder-raw-row, before one is).

WidgetContentDefault Visibility
identity-name-displayRegistered profile name, or "My Wallet"Always visible — the primary "is this my account" confirmation
identity-id-toggle-btn (ⓘ)Toggles the address line belowAlways visible, next to the name
identity-id-displayMasked Holder ID (maskId())Hidden by default — a technical identifier, present when needed but not competing with the name for attention
identity-active-btn (↻)Switch identity — opens the saved-profiles dropdownAlways visible
btn-open-wallet-tools (⚙️)Opens the Wallet Tools modalAlways visible, small icon — not a home-screen section of its own
Home screen density is deliberate. The page's only prominent actions are Receive and Send Money. Wallet Tools (backup, add/import identity, delete from device) used to sit inline at the same visual weight as those two actions — it's now reached only via the ⚙️ icon here, opened in a modal, with every existing action (Add Another Holder ID, Import Existing Wallet, Show Recovery QR, Delete Identity) unchanged inside it.
§4

Balance Card & Wallet Tools

Balance Display

StateTriggerShown
LoadedwalletNotes_.length > 0Notes grouped by normalized currency; per-group total, available vs. in-transit sub-line, Send/Receive actions, Registry Verified badge
Empty — "Ready to Receive"walletNotes_.length === 0Big ₦0.00 hero, RECEIVE MONEY enabled, SEND MONEY disabled (ghost)
Recent Activity is independent of the balance state above. It used to be derived live from walletNotes_, so it disappeared entirely the moment a holder's balance hit zero — even with a full send/receive history. It now always renders when transaction history exists, regardless of current balance; only the balance card and Send/Receive button states reflect zero balance. See §6.

Currency Selector

Shown only when a holder holds 2+ currencies ("Send From" pill row); silently defaults to the single currency held otherwise. Resets to unselected on every fresh open of the Send panel, forcing an explicit choice rather than guessing — the earlier version summed balances across all held currencies into one figure, which this replaced.

Wallet Tools (modal)

ActionWidgetRequires
Add Another Holder IDbtn-add-holder-idOpens the Generate Holder ID modal
Import Existing Walletbtn-import-walletLinks a Holder ID from another device/browser to this one
Show Recovery QRbtn-show-recovery-qrDisplays a QR another device can scan to import this wallet
Delete Identity from this Devicebtn-delete-identitySpend PIN — funds remain safe in the registry regardless
§5

Send Money Flow

One screen, no multi-step wizard: currency selector → recipient search → numpad, all visible at once inside #wallet-send-card. No manual note-picker — the server selects the smallest sufficient combination of notes to cover the amount.

Recipient Resolution

PathMechanism
Search by nameDebounced search against registered Holder Keys — always routes to a verified recipient, no anonymous fallback
Scan QRResolves in-place via GET /holder_ids?holder_id=, then applies exactly like a search result

Amount Entry & Settlement Accuracy

CVIB notes are fixed-denomination bearer instruments — a typed amount can't always be built exactly. previewNoteSelection() mirrors the server's exact selection algorithm client-side purely for display, so the confirm button never shows a number the server won't actually settle for.

WidgetBehaviour
wsc-round-noteLive disclosure line, shown only when the typed amount differs from the reachable settlement total
btn-send-nowTwo-tap arm/confirm (2-second window) — first tap shows the settlement warning if the amount will round; the label always shows the real total once it differs from the raw typed figure
The server, not the client, is authoritative for what gets sent. The confirm tap sends _previewTotal — the confirmed settlement figure the user just agreed to — never the raw typed amount. The server independently re-runs the same largest-first-greedy / smallest-first-topup selection; client and server can never disagree about how much actually moves.

Settlement

sendViaRegistryDirect()POST /api/v2/personal_pay. On success: sent notes removed from this device's IndexedDB, a CvibTransfer row is written per note (durable, survives any log level), the sender's activity log gets a new entry, and the full-screen receipt shows the confirmed amount plus the real, registry-confirmed remaining balance in that currency.

§6

Recent Activity

Three transaction types, merged and sorted newest-first: sent (localStorage, always durable), received (localStorage since the fix below), and pending (an outbound NFC/QR/Link share not yet claimed — the only real non-settled state this app has; registry-direct P2P sends are atomic).

Per-Entry Detail (tap to expand)

FieldAlways visible?Notes
Sender / RecipientYesResolved registered name; masked Holder ID only when no name is registered
Purpose/ReferenceYes"P2P transfer" / "Shared via Link/QR" / "Institutional Transfer" — a wallet transfer can never carry a trade-claim reference; TradeClaim is a fully separate institutional workflow
Amount & relative timeYese.g. "3 notes · P2P transfer · just now"
Exact timestampOn tapformatExactTime()
StatusOn tapSettled / Pending — awaiting claim
Note serial(s)On tapFull list for this transaction
Balance Before / AfterOn tapThe balance in that currency immediately before/after this specific transaction — "—" on any entry logged before this field existed, never a guessed figure

Durability Architecture

A note's activity record must outlive the note itself. Both logSentActivity() and logReceivedActivity() write once, at the moment of the transaction, to a localStorage log — never derived live from walletNotes_/IndexedDB, which loses a note's record entirely the moment it's spent. logReceivedActivity() is called from syncFromServer()'s pull loop, gated on "genuinely new to this device," so a note is never logged twice across repeated polls. A one-time per-browser migration (migrateReceivedLogFromWalletNotes()) backfills anything currently held that predates this log, so nothing already-visible history disappeared when the read source switched — a note already spent before the fix shipped has no surviving record anywhere and cannot be recovered.

"View All History"

The inline feed paginates 5 at a time ("Load more ↓"). A separate "View All History →" link opens a full-height scrollable modal rendering the complete, unpaginated activityTxns_ list — same tap-to-expand detail per entry, same shared renderer (activityItemHtml()) as the inline feed, so the two can never drift out of sync with each other.

§7

Endpoint-Level Specification

ActionEndpointAuthEffect
Send Money POST /api/v2/personal_pay None — registry ownership check is the authorization Explicit serials[] or amount+asset_code (server selects notes) → notes transitioned to transferredCvibTransfer written per note → both holders' WalletNote rows updated
Load Balance GET /api/v2/wallet/notes?holder_id= Optional proof-of-possession (X-Wallet-Token) for locked wallets Returns this holder's WalletNote rows; client merges into IndexedDB, never downgrading a record that already has a local PNG
Register/Update Note PUT /api/v2/wallet/notes/:serial Holder ID in body Upserts a WalletNote row — used to push a locally-claimed note's metadata to the server for cross-device sync
Resolve Recipient (QR) GET /holder_ids?holder_id= None Exact-match Holder Key lookup, used to resolve a scanned QR in-place before send
Provenance / Registry Lookup GET /api/v2/validation/registry_lookup/:serial Doorkeeper (Validation Desk caller only — not used by this wallet directly) Shared with the Validation Desk (§7 of that spec) — resolves issuer, mint timestamp, and the full CvibTransfer chain, which now includes this wallet's P2P sends alongside institutional transfers
§8

System Rules & Edge Cases

Denomination Granularity

CVIB notes are fixed denominations — a typed amount between two note values can't be built exactly. The server always settles for the nearest reachable total at or above the request (never short); the client discloses this live before the amount is confirmed and the arm/confirm button shows the real total, never the raw typed figure, once they differ.

Multi-Currency Balances

A holder's total is never summed across currencies. The balance card groups by normalized currency; the Send screen requires an explicit currency selection whenever 2+ are held, silently defaulting only when exactly one exists.

asset_code Normalization

The Qt minting engine sometimes prefixes asset codes (e.g. L-NGN); the client always displays and compares the stripped ISO 4217 form (NGN), but CvibNote.asset_code is stored raw. Server-side note selection normalizes both sides identically (PersonalPayController.normalize_asset_code, kept byte-for-byte in sync with the client's normCcy()) — an exact-match query against the raw value would otherwise silently reject nearly every amount-based send.

Sender Name Resolution

An incoming transfer's "From:" always resolves the sender's registered HolderKey.name when one exists, falling back to a masked Holder ID only when it doesn't — never the raw ID. Both P2P (personal_pay) and institutional (BulkTransferService) transfer paths resolve identically.

Recent Activity at Zero Balance

A holder who has just sent their entire balance away still has real transaction history — Recent Activity renders whenever history exists, independent of the current ₦0.00 "Ready to Receive" state. Only the balance card and Send/Receive action availability reflect zero balance.

Network / Transfer Failure

A failed personal_pay call leaves the Send button re-enabled with its original label; nothing is logged to Recent Activity and no local notes are removed — a retry is a clean re-attempt, never a partial state.

§9

End-to-End Flow

Send

Holder taps SEND MONEY → currency selected (auto if only one held) → recipient found by search or QR → amount typed, settlement previewed live → arm tap (shows rounding warning if applicable) → confirm tap → POST /api/v2/personal_pay → server selects/transitions notes, writes CvibTransfer, updates both wallets → full-screen receipt with the real settled amount and post-send balance → activity log entry written.

Receive

No explicit claim action on this path — the sender's personal_pay call already credited the recipient's WalletNote row server-side. The next time the recipient's browser runs syncFromServer() (page load, poll, or holder switch), the new note is pulled into IndexedDB and, for the first time this device has seen it, a durable received-activity entry is logged with a Balance Before/After snapshot.

Review History

Recent Activity renders on every balance load, sent + received + pending merged and sorted newest-first. A tap expands full detail on one entry; "View All History" opens the complete, unpaginated log in a modal. Both survive the underlying notes being spent, split, or otherwise consumed — this is a permanent transaction record, not a live snapshot of current holdings.

🗑️

Remove Saved Contact?

Contact Name
abc123...xyz789

⚠️ You will need their Cash Code again to send them money in the future.

CBDC Preview
Status Valid
Currency -
Amount -
Serial Number -
Uploading files...
Please wait
💸
Confirm Giving Cash
Check the details below
Amount GHS 0.00
To Cash Code ...
Cash Notes 0
⚠️

Already Transferred

The following CBDC notes have already been transferred and cannot be used again.

📱 Your Digital Cash Code QR

Let others scan this to send you money

How to use: The sender opens "Send Digital Cash" and taps the scan icon to read this QR code.

Trade Claim Submission

Submit verified trade to request cross-border liquidity.

1
Institution
2
FX & Route
3
Finalize

Claiming Institution

Select Country...
Confirm Trade Claim
Please review the details before submitting
Amount
Corridor
Institution
Beneficiary
Invoice Ref
Direction
⚖️ Notes Minted
⚠️

Once submitted, this trade claim will enter the supervisory review queue and cannot be edited. Please verify all details are correct.

Trade Claim Submitted!

Your trade claim has been successfully submitted and is now pending
review for pre-clearance by regulators.

Trade Claim ID:
TC-20260215-000000

An initial pre-clearance review shows your trade appears compliant.
It will now be routed for final approval.

Pre-cleared trade is pending review by regulators for approval.
Beneficiary funds will be held while the trade is being finalized.
Track status any time via the Issuance → Desk section.
📊 Trade Summary:
Trade Amount KES 1,000,000
Trade Direction Import into Nigeria
Beneficiary Kenya Exports Ltd
⚖️ Notes Minted
Help & Docs · Contact Support · Dashboard

Draft Saved!

Your trade claim has been saved as a draft.
You can continue editing it anytime.

Draft Reference:
TC-00000000

Your draft has been saved and can be accessed from your dashboard.
Complete the submission when you're ready.

Draft saved securely to your account.
Continue editing anytime from your dashboard.
Submit when ready via Drafts section.
📋 Draft Summary:
Amount KES 0
Direction -
Status Draft
Help & Docs · Contact Support · Dashboard

Issuance Desk

Manage and review recent cross-border trade claims.

Claim ID
Direction
Destination Corridor
Amount
Status
View

Loading trade claims...

No trade claims found

Showing 0 of 0
Help & Docs · Contact Support · Dashboard
SA
Admin Console v1.2.3

Administrator Console

System management and monitoring dashboard.

Total Corridor Volume
USD Equivalent
Pending Claims
⏳ Pending
Daily Limit Utilization
USD Equivalent
Non-Compliance Alerts
Admin viewed trade claims. Today at

Latest Trade Claim Activity

Read-only monitoring view. No transactional authority.

ID Institution Amount Corridor Status Risk
Loading claims...

Corridor Volume

0

Active Issuance Queue

0 Pending requests

Corridor Quota Limits

Corridor Corridor Limit Remaining Daily Limit Remaining Compliance Status
Loading corridor data...
Admin Console v1.2.3
🔒 System Admin

Supervisor Approval Desk

Review and authorize trade claims and governance proposals.

Pending Items Queue

0
# Claim ID Institution Amount Corridor Compliance Status Submitted
Loading claims...
Pending
Pre-Cleared
Manual Review
Approved
📖 Help & Docs · Contact Support · Dashboard