Rust desktop wallet: chat + compute + money in one app · wallet-app/README.md
Qal Wallet — the Qoll guards your coins
Created 2026-09-08 by qalcode (project: crypto_currency) · v2 2026-09-09 (desktop-first) · v3 2026-09-09 (messenger-grade chat + channels) · v5 2026-09-09 (Slack-class social: workspaces · threads · reactions · presence · search)
A Rust Tauri 2 desktop wallet that unifies wallet + chat + compute: your B-QALS balance, your encrypted qalchat conversations (with ⚡ value-attached messages), and a live view of what your tokens do — compute on the Qals P2P marketplace at live prices.
User directive this app implements: "the message chat system could basically be one and the same with it. The compute section can show the value of this token as compute."
Desktop-first 3-pane layout (1280×800 min, resizable, maximise button), dark qalarc theme, vanilla HTML/CSS/JS — no npm, no frontend build step.
┌───────────┬──────────────────────────────┬──────────────────┐
│ SIDEBAR │ MAIN COLUMN │ CONTEXT PANE │
│ logo/nav │ active tab content │ selected peer's │
│ service │ (wallet, thread, contacts, │ verification │
│ pills │ compute, files, agents) │ state / job │
│ │ │ receipt+anchor / │
│ 🔒 lock │ │ my qoll code │
│ @handle │ │ │
│ qoll-code │ │ │
└───────────┴──────────────────────────────┴──────────────────┘
src-tauri/src/main.rs — thin command layer:
ureq HTTP ──► loopd :8823 (HMAC X-Sig) · relay :8830 · qmarket :8824
std::process ─► qalchat/qalchat.py (all chat/file crypto)
├ qalchat/vault.py (app password → AES-GCM vault)
└ seedkit/derive.py (BIP-39 → qal-derivation v1)
Keyboard: Ctrl/Cmd 1–6 switch panes · Ctrl/Cmd L lock · Esc dismiss banner / close drawer · Enter sends.
qoll codes — the primary addressing UX
Every identity gets a short human code derived deterministically from its
keys: qoll-XXXX-XX (lowercase). invites.id_code() reuses the exact
qalchat-fp-v1 fingerprint preimage — a code is simply the first 6 base32
chars of the contact's fingerprint, lowercased, so code and fingerprint can
never disagree (unit-tested byte-for-byte in Rust vs python).
- Your code is shown big: onboarding success screen ("your messaging code — share it, people find you with it"), the sidebar account card, and the context pane (click to copy).
- Add a person: type their handle → wallet resolves their keys via the
relay keyserver → shows their code → pins with an explicit TOFU
warning ("this key is whatever the keyserver answered FIRST — confirm
the qoll code out-of-band"). Pasting a full
qalchat://invite?…URI takes the stronger signed-invite path instead. - Codes are an addressing/spot-check aid, not a security boundary — the full fingerprint + invite signature remain the verification instruments.
Value attached to messages, first-class
The composer has an attach menu (📎): 📷 Image · 📄 File · ⚡ Attach
Qals · 🔒 Locked content. Attaching value is now exactly like attaching
a picture: pick ⚡, set an amount (live doof equivalent shown —
1 QALS = 1,000,000 doof), hit Confirm & attach — a removable gold chip
stages on the composer and the loopd transfer fires with your next message.
In the thread it renders as the permanent gold value badge (⚡ 0.01 QALS)
with the settle id — hover shows the doof equivalent. Plain ⚡ Pay from
the same row sends payment-only, as before.
v3 — the Chat tab is a real messenger (Telegram/Signal-grade)
The chat tab is now a two-column shell inside the desktop 3-pane layout: a conversation rail + the open thread. Still vanilla JS, no npm.
Bubbles & thread
- Own messages right-aligned green-tinted, peer messages left; sender
line, timestamps,
#ctrand per-message hash in the meta row. - Ticks: outgoing
✓(delivered via relay) →⚓(included in an on-chain anchor) once you anchor the conversation. - Day separators (Today / Yesterday / date), auto-scroll, and a "↓ new messages" pill when messages land while you're scrolled up.
- Composer: paperclip attach menu, 😀 emoji quick row, Shift+Enter newline, auto-growing input.
Attach menu (the Signal/Telegram model — value is just an attachment)
| Option | What it does |
|---|---|
| 📷 Image | path (or a received file) → encrypted send → inline image bubble (click to zoom) |
| 📄 File | encrypted send → file card with size + sha256 (+ anchor tx when anchored) |
| ⚡ Attach Qals | amount → doof preview → confirmation → gold staged chip → fires with the message |
| 🔒 Locked content | price + optional preview + file → sealed; renders as a blurred locked card with a price badge |
Locked content (qalpub protocol)
- Received locked messages (real
qalpay.py send-lockedmanifests, or the mock marker) render as blurred preview cards: price badge (hover = doof), "Unlock for X Qals" button →qalpay.py buy-unlock <id> --as <me>(intent → pay → escrow redeem → decrypt → sha-verify → saved into your downloads) → card flips to unlocked inline (progress bar → success animation; images render in place, text files scroll inline). - Outbound: Seal & send uploads blobs, registers the listing and drops a LOCKED-MANIFEST into the peer's thread — one button, priced content.
Media rendering
Received files (qalchat.py recv-files) hydrate into their message bubbles:
images inline (bounded 4 MB reads, img-src data: CSP already allowed),
everything else as sha256 file cards. Outbound attachments are remembered so
your own sends render the same way.
Find / mute / unread
- 🔍 find-in-conversation (Enter cycles hits, current hit highlighted).
- 🔕 mute per conversation (kills unread badges; persisted in settings).
- Unread badges per conversation row + a total badge on the sidebar Chat item. Muted conversations don't count.
Channels (Discord-model, under Chat)
- Rail section Channels: browse the qalpub public index (
#prefixed rows, topic, post count / member hint, anchored-digest badge showing the last digest tx). - Open a channel → post timeline with author (loopd prefixes stripped),
timestamps and
sig ✓/sig ⚠verification chips (tampered posts are shown but marked), attachment posts render as file cards. Post with Enter. Context pane shows the channel's digest state. Note in the rail: "servers / roles coming".
qalpub protocol wiring — real vs mock
The full contract is wired for real; a --mock dev toggle keeps the UI
alive when the qalpub server is down:
| Command | Contract | Wallet command |
|---|---|---|
qalpay.py listings --json --as H |
[{listing_id,title,price_cents,preview_*,unlocks[]}] |
qalpay_listings |
qalpay.py buy-unlock <id> --as H --out DIR |
✔ UNLOCKED <id> → <path> |
qalpay_buy_unlock |
qalpay.py send-locked <to> <file> --price-cents N [--title][--preview][--public] --as H |
listing + LOCKED-MANIFEST | qalpay_send_locked |
qalchannels.py list --json |
{channels:[{channel_id,name,topic,posts_count,last_digest}]} |
qalch_list |
qalchannels.py read <ch> --json |
{posts:[{author,body,ts,sig_ok,…}]} |
qalch_read |
qalchannels.py post <ch> <text> --as H |
ok | qalch_post |
qalchannels.py digest <ch> |
anchored digest (tx parsed for the badge) | qalch_digest |
Mode toggle (Settings drawer → qalpub protocol): auto (default —
real CLIs when detected, mock otherwise) / mock (force the in-app mock
store: channels, posts and demo listings incl. a real-image unlock flip) /
real (require the CLIs; pending states otherwise). Every degraded state is
explicit in the UI (live / mock / pending chips) — never silent.
QALPUB_HOME env overrides the qalpub dir (default: qalpub/ next to
qalchat/).
v4 — the "qal-fly" design system (Firefly-derived UI, 2026-09-09)
User complaint: "UI of all the apps should be improved. some lines get cut and some displays are not good. Review and redo many of the app design features… Look at the repos with Firefly app for reference on design styles."
The whole UI was rebuilt against Firefly (IOTA's official wallet) — its
design system lives in repos/firefly/packages/shared (tailwind.config.js,
Tile/Box/Pill components, dashboard Sidebar). Extracted values are credited in
the token block at the top of ui/styles.css:
| Token family | Values (Firefly source) |
|---|---|
| Surfaces | bg #0B1524 (Shimmer bg lifted) · rail #0E1A2C · cards #15233B (gray-1000) · raised #1B2D4B (gray-900) · hover #223457 (gray-850) |
| Accents | primary teal #17E1D5 (turquoise-500 / Shimmer highlight) · active-nav blue #108CFF (blue-500) |
| Status | success #61E897 (green-500, confirmed/backed only per brand rule v1) · warning #FFB526 · danger #FF6755 · Qals gold #FFD166 kept |
| Type | Inter + Fira Mono stacks · 8px spacing grid (--q-1..--q-8) · radius 6/10/14/20 · Firefly elevation-1..4 shadows |
Layout changes
- Sidebar: Firefly-style grouped nav — section labels WALLET / SOCIAL / MARKET / SYSTEM, icon+label items with blue active state, service pills, and an account chip at the bottom (avatar initial · @handle · qoll-code).
- Main area: page headers (title + subtitle + context actions) and content
in a 12-col responsive grid (
.cols2spans 6/6, collapses to full width). - Balance hero: Firefly dashboard-style summary card — big tabular balance, AU$ reserve badge, G-QALS line, and a quick-action tile row (Send / Mint / Buy compute).
- Context pane: detail panel with divider sections (
.cx-card), key/value rows — no more boxes-in-boxes. - Components: 36px buttons (teal primary / ghost / gold / destructive + focus rings), filled-dark inputs with focus glow, status badges/chips, toast system, skeleton loaders on async lists, empty states with CTA.
Cut-line fixes (the explicit complaint)
- Global anti-clip discipline:
min-width: 0on flex children holding text,overflow: hidden + text-overflow: ellipsisfor one-liners,word-break: break-wordfor message bodies/titles — no fixed-height text containers anywhere (min-height only). .mono-trunc+ JSmid()helper: hashes/fingerprints/tx ids render as middle-ellipsis0x1234…abcd(full value on hover viatitle).font-variant-numeric: tabular-numson balances, amounts, prices, timestamps — numbers no longer jitter or overflow their column.- Chat polish: bubbles max-width 72% with 10-12px padding, consecutive
messages grouped (
.grp), meta row (time + ✓/⚓) moved outside the bubble edge, locked cards keep blur + lock overlay (unlock flips clear), attach menu is a floating popover, channel rows show # icon + digest-tx on hover, 30 inline styles in index.html replaced with utility classes.
Audit
bash wallet-app/ui/audit.sh (45 checks): token block present · zero inline
styles in index.html · all 218 class references in app.js+index.html defined
in styles.css · mono-trunc + mid() wired · anti-clip + tabular-nums rules
present. Screenshot of the running app: ui/audit-v3.png. Prior UI files are
archived at ui/archive/ (*_v3q_20260909.*).
v5 — Slack-class social layer (workspaces · threads · reactions · presence)
2026-09-09. Chat graduated from "channels" to a full Slack-class interface
on the qalpub v2 backend (see qalpub/README.md § C). All new traffic
rides three generic IPC bridges (qalpub_get/post/delete, native ureq →
http://127.0.0.1:8832, path-locked to the local origin because the webview
CSP forbids direct fetch). Signature-required writes (posts + thread replies)
still go through the signed-CLI path (qalch_post, now with reply_to).
What the Chat tab does now
- Workspace switcher at the top of the rail:
⚑ namedropdown (persisted in settings) ++→ inline Create/Join row. A selected workspace scopes the channel tree (and search + context) to it; "— all channels —" keeps the legacy flat view. - Channel tree with unread dots —
posts_counttracked against a per-channel seen marker (S.chSeen); opening a channel marks it read.+next to Channels creates a channel inside the active workspace. - Threads — click any channel post → the right context pane becomes the
thread: parent card (sig chip, reactions), replies list, and a reply
composer with
@mentionautocomplete. Replies are real signed posts (--reply-to); reply counts show inline as a💬 n repliesbadge. - Reactions — hover a post → action bar (🙂 react · 💬 thread · 📌 pin).
The picker offers 👍❤️😂🎯🔥👀 plus custom shortcodes
:qoll: 🦘,:doof: 🎺,:qals: ⚡,:rocket: 🚀. Chips show count + who (title), mine highlighted; clicking a chip toggles (dup adds are 409-refused server-side, the UI pre-checks and DELETEs instead). - Pins — 📌 on the hover bar toggles; pinned posts render in a pinned strip under the channel header; click a pin → opens its thread.
- @mention autocomplete — in the channel composer, DM composer, and
thread composer: type
@→ candidate list (workspace members + contacts + conversation peers), ↑↓/Tab/Enter/click to complete, esc closes. Mentions in rendered posts are highlighted (qalpubalso parses them server-side intomentions[], queryable via/mentions). - Search — a search box in the SOCIAL rail header (Enter): workspace- scoped public-post search → results drawer (channel, author, time, sig ✓/⚠, highlighted snippet). Clicking a result opens the channel AND its thread.
- Presence — green/amber/grey dots in the DM list and member lists (context pane + workspace card), fed by qalpub beacons (90 s TTL, polled every ~12 s). The wallet beacons its own liveness while you're on the Chat tab. Honest: relay-observed, not attested.
- Typing indicator (DMs) — typing in the DM composer sends 5 s-TTL
beacons (throttled 3 s); the peer's thread header shows
✍ typing….
Files & tests
ui/index.html— rail workspace bar + create/join rows, channel create row, pinned strip, mention popups (DM/channel/thread), reaction picker, search drawer, typing chip.ui/app.js— v5 social section (~450 lines): workspaces, threads, reactions, pins, search, presence/typing, autocomplete; rail/posts/context renderers extended.ui/styles.css— § 22 "v5 SOCIAL LAYER": all new components on--q-*tokens, same anti-clip discipline (ellipsis on chip/pin/member one-liners).src-tauri/src/main.rs—qalpub_get/qalpub_post/qalpub_delete(ureq bridge,QALPUB_URLenv override) +qalch_post(..., reply_to).- Tests green:
ui/audit.sh45 passed, 0 failed (253 classes checked) ·smoke.sh32 passed, 0 failed (192 ids wired, 37 chat contract ids) ·cargo buildgreen · backend suitestest_workspaces.sh54 green andtest_qalpub.sh42 green (no regression). - Prior sources archived:
wallet-app/archive/v5_20260909_*/(main.rs, README) andui/archive/*_v4q_20260909.*(app.js, index.html, styles.css).
v2 — onboarding, vault, verification
Onboarding flow (first screen)
| Path | Steps |
|---|---|
| Create account | pick handle → seedkit mnemonic (12 words, BIP-39, show-once grid with copy + "I wrote it down" checkbox) → identity derived via qalchat.py init --mnemonic … (qal-derivation v1, loopd agent auto-bound) → set app password → success screen: your qoll code |
| Restore from seed | 12/24-word entry → same deterministic derivation, keys byte-identical to the originals |
| Import identity file | paste identity JSON (refuses public-only files) or unwrap an encrypted export container |
Existing installs (identities but no vault) get a one-click "Set an app password" path. The mnemonic is displayed ONCE and discarded from memory on the very next step — the wallet never writes a seed stub (the CLI's 0600 stub is vaulted too).
Password lock (vault)
qalchat/vault.py— the app password derives a key (PBKDF2-HMAC-SHA256, 200 000 iterations, per-install 32-byte salt indata/vault.salt) that AES-256-GCM-encryptsqalchat/data/vault.binholding every identity's secret fields (x25519_priv,ed25519_priv,loopd.api_secret, seed stubs).- At rest (locked): identity files on disk keep public fields only; seed stubs are neutralized. Unlock decrypts in memory and re-materializes the files (0600) for the session so the qalchat CLI keeps working; lock re-seals (adopting identities created during the session) and strips again.
- Wrong password: 5 tries → 60 s cooldown, persisted in
data/vault.state.jsonacross restarts. - Auto-lock after 5 min idle (configurable 1–120 min) + 🔒 sidebar lock.
- Settings → Export identity (encrypted): portable AES-GCM container in
~/Downloadswith its own password; unwrap from onboarding → Import. - Passwords travel stdin only (never argv, never logged). The frontend holds the password in a JS variable for the session (so LOCK can re-seal); never localStorage, scrubbed on lock.
Verification UI (Contacts)
- Contacts list: qoll codes + full fingerprints + pin source
(
invite/code/tofu) / relay / age. - My invite: QR rendered by invites.py's own renderer (python
qrcode→ PNG → data-URL) + signed URI + fingerprint for the out-of-band check. - Add a person box: handle-resolve (TOFU-warned) or paste invite URI
(signed, verified before pinning).
--forcere-pin gated behind a checkbox. - Key substitution = red app-wide banner: "⚠ contact key changed — communication blocked" — fired when any send/recv hits qalchat's exit 4. The relay is demoted to tamper detector; delivery is refused, never silently re-pinned.
Files tab
Encrypted transfer via qalchat (send-file ≤8 MB chunked, recv-files
sha256-verified, auto-anchored on receive — the ⚓ tx renders inline per
file and in the context pane). The tab probes the CLI at runtime
(files_capable) and shows a graceful "files are coming online" stub if
the subcommands are absent. Listing reads the canonical files --json index
(fallback: download-dir scan).
Tabs
| Pane | What ships |
|---|---|
| Wallet | B-QALS balance + reserve-backed ✓ badge, G-QALS line, holds (115% pre-auth), Send (HMAC-signed), Topup (paper mint), activity feed — context: my identity + code |
| Chat | messenger rail (search · conversations · channels), thread (3 s poll, recv merged across every relay, bubbles + ticks + day separators + media + locked cards), attach menu (📷 / 📄 / ⚡ Qals / 🔒 locked), find-in-conversation, mute, unread badges — context: peer's pin state or channel digest |
| Compute | live price board, converter, buy (hold → execute → settle → anchor), job history — context: clicked job's receipt + anchor tx/object/sha256 |
| Contacts | codes, fingerprints, invites, add-person — context: selected contact's full verification state |
| Files | send to pinned contact, received list with anchor txs — context: selected file's sha256 + anchor |
| Agents | loopd agents, caps, revoke/restore (only while the vault holds the secret) |
Settings (sidebar account card → drawer): loopd / multi-relay list / qmarket URLs, identity selector, auto-lock minutes, vault status, export identity.
Command surface (src-tauri → frontend)
svc_status health of the three services (sidebar pills)
relay_probe per-URL health for the multi-relay list
loopd_report loopd_receipts loopd_account
loopd_transfer (signs w/ local identity secret — X-Sig over raw body)
loopd_topup (paper mint, Phase 0)
loopd_revoke (self-revoke only — needs the local secret)
market_providers market_jobs market_earnings market_buy
relay_envelopes (ciphertext fetch for a handle)
chat_send chat_recv chat_pay chat_anchor chat_verify ── delegate to CLI
qalchat_cli (raw passthrough, e.g. `recover --force`)
list_identities owned_agent_ids qalchat_home_info read_history
── v2 ──
vault_status vault_setup vault_unlock vault_lock (passwords via STDIN)
seed_mnemonic (seedkit/derive.py mnemonic — 12 words)
account_create (qalchat init --mnemonic …; deterministic restore)
import_identity_json import_identity_container export_identity
my_id_code (qoll-XXXX-XX + fingerprint — works while sealed)
list_contacts (pins + fingerprints + codes, no subprocess)
contact_pin_code (keyserver resolve → TOFU-warned code pin)
invite_create (qalchat invite → URI + fingerprint + QR data-URL)
invite_accept (qalchat accept; signature verified before pinning)
files_capable file_send files_recv files_list files_reveal
── v3: qalpub protocol + inline media ──
qalpub_capable qalpay_listings qalpay_buy_unlock qalpay_send_locked
qalch_list qalch_read qalch_post qalch_digest
resolve_received_path file_data_url
Security model — what protects what
| Layer | Secret | Protects | Does NOT | Where it lives |
|---|---|---|---|---|
| App password | PBKDF2-SHA256 ×200k + per-install salt → AES-256-GCM | key material at rest on this device (stolen laptop / copied data/) |
recover the identity elsewhere; stop a live attacker while unlocked; weak passwords are weak | data/vault.bin — identity files hold public fields only while locked |
| Mnemonic (12 words) | BIP-39 seed → qal-derivation v1 → every key, deterministically | recovery of everything on any device: identity, chat ratchet (relay replay), loopd binding + balance | protect the device at rest; anyone who sees the words owns the identity; no recovery if lost | your paper (shown ONCE; never stored by the wallet — the CLI stub is vaulted) |
| Contact pins | invite fingerprints + pinned pubkeys (ed25519-signed invites) | relay key substitution / MITM: different key for a pinned handle → exit 4, delivery blocked, red banner | encrypt anything; survive a compromised out-of-band channel (verify codes/fingerprints by voice/IRL) | data/pinned/<me>/*.json + identity pins (public — never vaulted) |
All three are independent and all three matter: the password guards the device, the mnemonic guards against the device dying, the pins guard the wire. Losing any one costs exactly what it protects — nothing more. The qoll code is a presentation of the pin/fingerprint layer, not a fourth secret.
Unchanged from v1: the wallet holds no crypto of its own (all X25519/Ed25519/AES-GCM lives in qalchat.py, delegated via subprocess); chat is ciphertext-blind at the relay; loopd signing reads identity secrets only in-process and never returns them across the IPC boundary; revoke is self-service only.
Run
# dev (uses the static ui/ — no devUrl, no npm):
cd wallet-app/src-tauri && cargo tauri dev
# production debug build:
cd wallet-app/src-tauri && cargo tauri build --debug
# plain build (the acceptance test):
cd wallet-app/src-tauri && cargo build
# full smoke (binary + services + vault round-trip + unit tests
# + v3 chat UI contract + qalpub protocol probe):
bash wallet-app/smoke.sh
Prereqs: loopd (:8823) + relay (:8830, python3 qalchat/qalchat.py serve) +
qmarket (:8824); chain on :9000 only for anchor/verify (via qalpipe).
QALCHAT_HOME locates the identity store; vault.py follows the same
QALCHAT_DIR env var as qalchat.py.
Tests
wallet-app/test_vault.py— sandboxed vault.py round-trip: setup → strip-at-rest → wrong-password counting → 5-try lockout → cooldown → unlock → re-seal with adoption → export/unwrap → status counts. 18 checks.cargo test— Rust fingerprint and qoll id_code must byte-match the python reference (invites.py), plus base64 vectors.wallet-app/smoke.sh— wires it all together (32 checks green as of v3): binary, services,--version, vault round-trip, cargo tests, v3 chat UI contract (every id app.js touches exists in index.html — 37 messenger-layout ids asserted) and the qalpub protocol probe (listings/buy-unlock/readcontracts against the real CLIs when present, explicit mock note otherwise).- v3 additionally verified headless (chromium): tab layering (a v2 bug where
#tab-chatrendered over other tabs — fixed), attach menu, emoji insert, bubbles/day-separators/ticks, payment badge, real-format locked card with price parse, search highlight, staged ⚡ chip, channel render with sig chips. - v2 sources preserved in
wallet-app/archive/v2_20260909/(v1 inarchive/v1_20260909/).
Phase-0 architecture note (deliberate)
All crypto/chat operations delegate to the Python CLI via
std::process::Command — wire compatibility with live identities/relay on
day one. Native Rust crypto (x25519-dalek, hkdf, aes-gcm, ed25519-dalek)
remains the phase-1 milestone. The Rust layer does native HTTP + HMAC
(hmac + sha2) for loopd money rails. The vault is python-side
(cryptography AESGCM + hashlib PBKDF2) so CLI and wallet share one
implementation; the only Rust "crypto" is the sha256+b32 presentation
layer for fingerprints/codes (unit-tested against the python reference).
Security notes
- The wallet holds no secrets of its own. Keys stay in qalchat identity files (0600 — and stripped to public fields whenever the vault is locked); loopd secrets stay in loopd / vault.bin. Secrets are read only to sign in-process and never returned across the IPC boundary.
- Chat is ciphertext-blind at the relay; the wallet renders decrypted local history only.
- Passwords/mnemonics are never logged, never in argv, never persisted.
- Revoke is self-service only.
loopd_transfersigns exactly the bytes sent. - Topup is unauthenticated by design in Phase 0 (paper mint).
- Known trade-offs (honest list): identity secrets are materialized at 0600
while unlocked (the CLI subprocess must read them); mnemonic restore passes
words as argv to
qalchat init --mnemonic(the CLI's interface) — momentarypsexposure on a single-user machine, removed in phase 1 when derivation moves in-process.