QALS Wiki · the qalarc network⌂ qalarc.com/projects/qals
generated 2026-09-16 · qalcode autonomous research

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) · v7 2026-09-11 (passkey/WebAuthn login gate — graceful where the webview lacks WebAuthn)

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).

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

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)

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

Channels (Discord-model, under Chat)

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

Cut-line fixes (the explicit complaint)

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

Files & tests

v6 — unit convention + text-cutoff/panel sweep (2026-09-10)

2026-09-10. A full UI sweep that (a) locks in the network-wide unit convention and (b) closes every text-cutoff / panel-behaviour gap found in the audit. No features removed, no IDs renamed — smoke + audit contracts extend, never break.

The unit convention (enforced everywhere)

1 Qals = AU$1 = 100 cents = 1,000,000 doofs
1 cent = 0.01 Qals        1 doof = 0.000001 Qals

ui/app.js helpers (formatting section): qals(cents) · qalsN(cents) · aud(cents) · doofs(cents). Legacy fmtAU/fmtQ/listingPriceQ are gone; ui/audit.sh § 5 fails the build if any return. 27 qals() render sites, 7 doofs() tooltip sites.

Where units changed: wallet balance hero + badge + holds; activity feed; send/topup flows + toasts; compute price board (per-unit prices carry a doofs tooltip), buy estimate/result/toast, converter, job list + context receipt; agents balances/caps/spend; locked-content cards (price chip + Unlock button + toast); ⚡ payment bubbles; staged ⚡ chip; attach-Qals live doofs line; mock market-channel posts (0.9¢/frame → 0.009 Qals/frame (≈ 9,000 doofs)); ui/index.html labels/placeholders ("Qals balance", "Send Qals", "Qals (1 = AU$1)" inputs); brand prototype qal_wallet.html (hero, accounts, activity rows, hold math) and brand/index.html (unit convention card in § 4, "1-Qals coin" prose).

Text-cutoff sweep — found & fixed (worst offenders first)

  1. Activity feed labels (.feed li grid) — the transaction label column had no overflow rule at all; long labels silently clipped mid-glyph. Now ellipsis + minmax(0,1fr) with the amount column widened to 96px so +512.40 Qals never truncates.
  2. Find-in-conversation box (.th-search) — fixed 210px input could push the header's 🔔⚓✔ buttons out of the bar on narrow windows. Now flex: 0 1 auto + input width:100%, shrinking to a 150px floor.
  3. Balance badge (.badge-green) — nowrap pill inside the hero's side column could overflow the card; now max-width:100% + ellipsis.
  4. Payment settle ids (.pay-note) — now ellipsis so long settle ids can't widen the bubble.
  5. Brand prototype rows (qal_wallet.html) — .row .t titles/subtitles, .appbar name/address, .acct cards, .netsel, .note pill: all got min-width:0 + ellipsis; amounts got tabular-nums.

Panel / layout sweep — found & fixed

Manual checklist (v6 sign-off)

  1. bash wallet-app/ui/audit.sh56 passed, 0 failed (§ 5 = units).
  2. bash wallet-app/smoke.sh32 passed, 0 failed; cargo build green.
  3. Balance hero: 512.40 Qals + reserve-backed ✓ = AU$…; zero-balance agent shows the top-up nudge; hero numbers don't jitter (tabular-nums).
  4. Activity feed: long labels ellipsize, amounts read +2.50 Qals.
  5. Send 2.5 Qals → toast sent 2.50 Qals (= AU$2.50) → @peer · receipt.
  6. Attach ⚡ Qals → type 2.5 → doofs line ≈ 2,500,000 doofs (2.50 Qals); staged chip reads ⚡ 2.50 Qals attached; payment card in-thread is ⚡ 2.50 Qals with doofs on hover.
  7. Locked card: price chip ⚡ 0.25 Qals, hover = doofs tooltip, Unlock button reads Unlock for 0.25 Qals.
  8. Compute: price board X.XX Qals/unit (doofs on hover), converter = X.XX Qals of compute, estimate shows hold + settle in Qals.
  9. Narrow the window: context pane steps 304 → 272 → 248 → hidden (≤940px); thread-head search shrinks, buttons never pushed out.
  10. Esc closes (in order): banner, search drawer, reaction picker, attach menus, settings drawer; drawer also closes on outside click; tab switches leave no "Thread ·" header or floating popovers behind.
  11. Resize hard / short window: attach menu scrolls instead of clipping.
  12. Brand prototype (brand/wallet/qal_wallet.html): hero 512.40 Qals, = AU$512.40 below, activity rows carry Qals · … secondaries.

Files touched (v6):

v7 — passkey (WebAuthn) login gate (2026-09-11)

Settings → Security — passkey (WebAuthn) · optional "Protect with passkey" at the end of onboarding · passkey-first lock screen. Ship-green: smoke.sh 37/37 (new § 8 passkey contract) · ui/audit.sh 56/56 · cargo build + cargo test incl. 10 new wa_tests (CBOR/COSE parsing, real ES256 + Ed25519 assertion verification, tamper rejection).

Does WebAuthn work in the Tauri webview? (probed, not guessed)

On this build's platform (Linux) — NO, and that is a hard platform fact, not a design choice. Measured empirically on 2026-09-11 by driving the same engine Tauri/wry uses on Linux (WebKitGTK 2.50.4, loading tauri://localhost through a registered custom scheme, i.e. exactly how the wallet is served):

So on Linux the wallet ships the graceful state: Settings shows "passkeys unavailable here — password lock remains" (plus the upgrade path), the lock screen stays password-only, and onboarding hides the passkey option. Everything is runtime-probed (pkProbe()), so on builds where the webview does expose WebAuthn — macOS (WKWebView ≥ 13.3, Touch ID/password) or Windows (WebView2, Windows Hello; note Hello often picks RS256, which the verifier currently refuses at enroll — see below) — the identical UI activates without changes. The real upgrade for Linux is the tauri-plugin-authenticator plugin path (or a distro webkit2gtk built with WebAuthn + a roaming FIDO2 key); documented as the phase-1 option.

What shipped (Rust)

src-tauri/src/main.rs gains a self-contained local WebAuthn verifier — no server, no network, no new secrets:

Command What it does
webauthn_state enrolled? / require_passkey / credential list / storage path
webauthn_challenge 32 B CSPRNG challenge (in-memory, 120 s TTL, single-use)
webauthn_register parses attestationObject (minimal CBOR decoder), extracts attested credential id + COSE public key (ES256 -7, EdDSA -8; RS256 -257 rejected with a clear message), checks rpIdHash + UP/UV/AT flags + challenge + origin, stores to data/webauthn.json (0600)
webauthn_verify verifies the assertion signature over authData ∥ SHA256(clientDataJSON) with the stored key, UP+UV flags, rpIdHash, pinned origin, monotonic signature counter; on success stamps a 120 s in-memory freshness token
webauthn_set_require toggle the requirement (only meaningful when enrolled)
webauthn_remove remove credential — requires a fresh assertion (prove presence to demote the gate)

And the gate: vault_unlock refuses ({"ok":false,"passkey_required": true}) when a passkey is enrolled and require_passkey is set, unless a verified assertion happened within the last 120 s. This is enforced at the IPC boundary in Rust, not just hidden in the UI. The token is consumed by every successful unlock, so each auto-lock cycle demands a fresh assertion (same 5-min idle timer as before).

What shipped (UI)

Security model — what the passkey does and does NOT do (honest)

A WebAuthn passkey never exposes key material. Assertions are possession+presence proofs with a signature — you cannot derive, unwrap, or wrap a vault key with one. So the passkey here is pure authentication:

It DOES gate when the app unwraps the vault: biometric/PIN (OS platform authenticator) must assert presence before vault_unlock runs; with require_passkey on, a stolen/shoulder-surfed app password alone cannot unlock through the app
It does NOT change the crypto root: vault.bin is still AES-256-GCM under the PBKDF2 password; the mnemonic still recovers everything; the passkey adds zero at-rest protection to data/ — it gates the app, not the disk
Known trade-offs ① with a passkey enrolled the app password stays in webview memory across idle-locks (never disk; scrubbed on quit) so one-step passkey resume is possible; ② all gates are software gates — an attacker running code in your unlocked desktop session is out of scope, as before; ③ loss of the authenticator + require_passkey on = app locked out (the identity is still fully recoverable from the seed on a fresh install); documented manual recovery: remove data/webauthn.json — it is an allowlist, never a secret

The verifier is real: Rust-side unit tests sign with freshly generated P-256/Ed25519 keys and verify through the exact production path (wa_tests), including tampered-authData and tampered-signature rejection.

Files touched (v7): src-tauri/src/main.rs (WebAuthn module + gate + tests), src-tauri/Cargo.toml (p256, ed25519-dalek — verify-only crypto), ui/index.html (8 ids across lock/settings/onboarding), ui/app.js (§ v7 PASSKEYS: probe/enroll/assert + lock/settings/onboarding wiring), smoke.sh (§ 8 passkey contract). styles.css unchanged (reused classes only). vault.py untouched (the gate lives in Rust). Prior sources archived: wallet-app/archive/v7_20260911_passkey/, ui/archive/*_v7_20260911.*.

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 passwordsuccess 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)

Verification UI (Contacts)

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-mode test cents), 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-mode test cents, 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
── v7: passkey gate (local WebAuthn) ──
webauthn_state  webauthn_challenge  webauthn_register  webauthn_verify
webauthn_set_require  webauthn_remove        (vault_unlock gated by fresh assertion)

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)
Passkey (v7, optional) a WebAuthn credential in the OS platform authenticator gating the app: fingerprint/face/PIN must assert presence before the vault unwraps; with require_passkey on, a stolen password alone cannot unlock through the app the crypto root — vault.bin is still password-encrypted, the seed still recovers everything, data/ at rest is unchanged; WebAuthn never exposes key material data/webauthn.json (0600 public-key allowlist) + OS authenticator

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

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