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.
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
| Responsibility | Mechanism |
|---|---|
| Balance display (multi-currency) | Grouped by normalized asset code; available vs. in-transit ("Sending…") tracked separately |
| Send Money | POST /api/v2/personal_pay — explicit serials or amount + asset_code (server selects notes) |
| Receive Money | No explicit claim action — server-side registry credit, picked up by the next syncFromServer() poll |
| Recipient resolution | Registered-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 management | Wallet Tools modal — add/import/recover Holder ID, delete from this device |
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.
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).
| Field | Type | Description |
|---|---|---|
| holder_id | string | Owning Holder ID — unique with serial |
| serial | string | FK to CvibNote.serial (by value, not DB FK) |
| asset_code | string | Stored raw — may carry a Qt mint prefix, e.g. L-NGN |
| denomination | decimal | Face value |
| currency_symbol / filename | string | Display metadata, carried over from the sender's record on transfer |
| source | enum | p2p_receive | operator_transfer | sandbox_faucet | client_upload | bulk_claim |
| claimed_at | datetime | Credit timestamp — identical across every note in one transfer |
| sender_holder_id / sender_name | string | Snapshotted 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.
| Field | Type | Description |
|---|---|---|
| note_id | uuid | FK to cvib_notes.note_id |
| from_holder_id / to_holder_id | string | The two parties to this move |
| status | enum | pending | accepted | cancelled | expired — registry-direct sends always write accepted immediately (atomic, no pending window) |
| handover_token_hash / expires_at | string / datetime | Inherited from this table's original NFC/QR-handover design; inert placeholders for an instant registry-direct transfer |
| created_by | string | The sending holder (P2P) or acting operator (institutional) |
Client-side storage
| Store | Contents | Lifecycle |
|---|---|---|
IndexedDB — SmartCheqDigitalCash / digitalCash | One record per currently-held note (walletNotes_) | Volatile — a note's record is deleted the moment it's spent |
localStorage — smartcheq_sent_activity | One entry per completed send action | Durable — written once at send time, independent of note lifecycle |
localStorage — smartcheq_received_activity | One entry per note, first time it syncs to this device | Durable — written once at first sync; survives the note being later spent |
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).
| Widget | Content | Default Visibility |
|---|---|---|
| identity-name-display | Registered profile name, or "My Wallet" | Always visible — the primary "is this my account" confirmation |
| identity-id-toggle-btn (ⓘ) | Toggles the address line below | Always visible, next to the name |
| identity-id-display | Masked 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 dropdown | Always visible |
| btn-open-wallet-tools (⚙️) | Opens the Wallet Tools modal | Always visible, small icon — not a home-screen section of its own |
Balance Card & Wallet Tools
Balance Display
| State | Trigger | Shown |
|---|---|---|
| Loaded | walletNotes_.length > 0 | Notes 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 === 0 | Big ₦0.00 hero, RECEIVE MONEY enabled, SEND MONEY disabled (ghost) |
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)
| Action | Widget | Requires |
|---|---|---|
| Add Another Holder ID | btn-add-holder-id | Opens the Generate Holder ID modal |
| Import Existing Wallet | btn-import-wallet | Links a Holder ID from another device/browser to this one |
| Show Recovery QR | btn-show-recovery-qr | Displays a QR another device can scan to import this wallet |
| Delete Identity from this Device | btn-delete-identity | Spend PIN — funds remain safe in the registry regardless |
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
| Path | Mechanism |
|---|---|
| Search by name | Debounced search against registered Holder Keys — always routes to a verified recipient, no anonymous fallback |
| Scan QR | Resolves 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.
| Widget | Behaviour |
|---|---|
| wsc-round-note | Live disclosure line, shown only when the typed amount differs from the reachable settlement total |
| btn-send-now | Two-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 |
_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.
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)
| Field | Always visible? | Notes |
|---|---|---|
| Sender / Recipient | Yes | Resolved registered name; masked Holder ID only when no name is registered |
| Purpose/Reference | Yes | "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 time | Yes | e.g. "3 notes · P2P transfer · just now" |
| Exact timestamp | On tap | formatExactTime() |
| Status | On tap | Settled / Pending — awaiting claim |
| Note serial(s) | On tap | Full list for this transaction |
| Balance Before / After | On tap | The balance in that currency immediately before/after this specific transaction — "—" on any entry logged before this field existed, never a guessed figure |
Durability Architecture
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.
Endpoint-Level Specification
| Action | Endpoint | Auth | Effect |
|---|---|---|---|
| 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 transferred → CvibTransfer 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 |
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.
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.
Compliance Footer
A single condensed line by default — "⚠️ This is a simulated sandbox environment — no real value." — with a "Learn more" tap-to-expand revealing the full required legal text (Important Legal Notice + Sandbox Testing Environment disclaimers) verbatim, unchanged from what always displayed. The full text is never removed, only deferred — present when needed rather than competing with Send/Receive for the page's visual weight.