Files
wallet/LLM.md
T

20 KiB

@luxfi/wallet

Project: luxfi/wallet — canonical Lux Wallet upstream (web + mobile + extension + backend). Org: Lux Industries Inc. (luxfi). Status: apps/web builds clean. apps/backend (Go MPC custody server) builds

  • tests green (17 tests). apps/{extension,mobile} retain upstream-shaped src that requires an app-level refactor to compile against current @l.x/* npm packages. The canonical bones (pkgs/{wallet,brand,analytics}) are stable.

The freshly-scaffolded MIT TS shared core lives in a SEPARATE org — github.com/luxwallet/* (@luxwallet/{chains,rpc,crypto,keyring,tx,sdk},

  • connect, ui, desktop, native ios/android/mobile-rn). That is the cross-target shared core; luxfi/wallet here is the GPL product monorepo (web/extension/mobile FE + this Go custody backend + PQ stack). The backend's custody port mirrors @luxwallet/keyring's account model + the lux/mpc client.Threshold security contract.

Canonical structure

luxfi/wallet/
├── apps/
│   ├── web/             — Vite 8 SPA, React 19, brand-aware. Builds in <100ms.
│   ├── backend/         — Go MPC custody server (App(Wallet) backend). See below.
│   ├── extension/       — Chrome/Firefox MV3 (upstream-shaped; app-refactor pending).
│   └── mobile/          — React Native + Expo (upstream-shaped; app-refactor pending).
├── pkgs/
│   ├── wallet/          — `@luxfi/wallet` — canonical wallet feature bones.
│   ├── brand/           — `@luxfi/wallet-brand` — runtime white-label config.
│   └── analytics/       — `@luxfi/wallet-analytics` — provider-pluggable analytics.
├── SCREENS.md           — UX spec freeze (2025-12-15).
└── LEGACY.md            — Sibling repos (wallet-legacy, wwallet, xwallet) to archive.

Build commands (verified working)

pnpm install --no-frozen-lockfile
pnpm --dir apps/web build      # Vite SPA — clean. Output in apps/web/dist/.

apps/{extension,mobile} build via Nx targets that depend on workspace packages still upstream-shaped (@universe/*, wallet/* direct paths) and will not type-check or build until refactored to consume the npm-published @l.x/* and the workspace @luxfi/wallet. Track in this file when fixed.

Foundation slice (apps/web shell)

apps/web is the Vite SPA shell. Provider tree:

GuiProvider → QueryClientProvider → WagmiProvider → RouterProvider
  • src/main.tsx — awaits loadBrandConfig() before first render.
  • src/App.tsx — wraps the four providers above.
  • src/router.tsx — react-router-dom v7 with lazy-loaded screen modules.
  • src/components/AppShell.tsx — top bar + side drawer + Outlet.
  • src/components/ChainSwitcher.tsx — native <select> over brand.supportedChainIds.
  • src/components/GuiProvider.tsx — passthrough today; swap to HanzoguiProvider once @hanzo/gui@7 republishes its missing dist/ artifact.
  • src/config/wagmi.tsbuildWagmiConfig() reads brand.supportedChainIds and resolves transports via getBootnodeRpcUrl(chainId). Chains without a resolvable RPC are dropped — never http("").
  • src/config/queryClient.ts — 30 s stale, retries off, no focus refetch.
  • src/store/index.ts — zustand account / chain / ui slices owned by Foundation. Other slices (auth, send, swap, stake) are sibling files owned by other Blues.
  • src/hooks/useAccount.ts — wagmi + zustand union; the only useAccount screens should import.
  • src/hooks/useBrand.ts() => brand for React-friendly typing.

Screen Blues fill src/screens/{name}/index.tsx; today each is a labelled placeholder so the build is clean and merges are pure file replacements.

Custody + lux.id auth wiring (apps/web)

The web app talks to the MPC custody backend (apps/backend, wallet-api.<brand>) authenticated via lux.id. One way, three thin layers:

  • src/lib/iam.ts — lux.id OIDC Authorization-Code + PKCE (Web Crypto only, no dep). Issuer + clientId come from the brand (getIamConfig()); the callback is /auth/callback. Tokens live in sessionStorage (survive a tab reload, gone on close; never localStorage, never logged). getAccessToken() refreshes on expiry. The Go backend verifies this same issuer + audience and derives the org from the token's owner claim.
  • src/lib/custody.ts — typed client for /v1/wallets, /v1/wallets/{id}, /v1/wallets/{id}/sign. Matches internal/api/api.go + custody.* JSON exactly. Attaches Authorization: Bearer; never sends org (server-side only). signPayload requires a non-empty idempotencyKey (anti-replay) and refuses to send without one. Validates returned EVM addresses (EIP-55) at the boundary. fetchImpl is injectable for tests.
  • src/store/session.ts — lux.id session + custody wallets + signWithCustody. App.tsx calls hydrate() on boot (restore token → list wallets).
  • src/screens/signing/SigningModal.tsx — on confirm, a tx carrying custody.payloadHash requests the MPC signature and resolves SigningResult.signature; a custody failure resolves reason:"error" (no silent noop). The local-key path (no custody field) is unchanged. Tx broadcast is a typed seam — the modal returns the signature; assembling + broadcasting the signed tx via getBootnodeRpcUrl is the calling slice's job (not yet wired into Send/Swap; no // TODO, the seam is explicit).

Brand gained three explicit fields (in pkgs/brand/src/index.ts + every brand.json): walletApi (custody base URL), iamIssuer, iamClientId (<org>-wallet), plus an optional downloads manifest. Helpers: getWalletApiUrl(), getIamConfig().

/download screen (per-brand host)

src/screens/download/ renders the per-brand native-download page served at wallet.<brand>/download (client-routed; the SPA server's index.html fallback covers it). Logo + name from the brand singleton; a grid of macOS/Windows/ Linux/iOS/Android/extension reads brand.downloads (binary url or storeUrl

  • version + checksumUrl for "Verify signature"); a missing platform shows "Coming soon". Binaries are published by the native CI fleet (NO GHA).

Tests (apps/web) — Node built-in runner

pnpm --dir apps/web test. Runner = Node's stdlib node:test (the repo's one way; no vitest dep) via tools/ts-resolve.mjs, a synchronous resolve hook that maps bundler-style extensionless .ts imports for the loader (Node 22+ strips types natively). The script covers src/{lib,store,screens/dapps}/**/*.test.ts (63 tests). src/screens/_shared/runtime.test.ts is a JSX import probe for tsc --noEmit (its own header), not a runtime test — it stays in typecheck. New suites: lib/custody.test.ts (12 — bearer attached, no-org, exact endpoints/bodies, idempotency required, boundary validation, error mapping), lib/brand.test.ts (6 — each brand overlay → correct name/chain/logo/walletApi/ issuer, total swap, downloads present).

White-label brand pattern (canonical)

Brand config flows at runtime, not build time. Same pattern as ~/work/lux/exchange/pkgs/config/src/brand.ts.

@luxfi/wallet-brand/brand.json   ─copy→  apps/web/dist/brand.json
                                                │
                                                ↓
                                  K8s ConfigMap mount overlays
                                                │
                                                ↓
                                  loadBrandConfig() fetches /brand.json
                                                │
                                                ↓
                                  mutates `brand` singleton + CSS vars
                                                │
                                                ↓
                                  React renders against final brand

White-labels (Liquidity, Zoo, Pars) override /brand.json via a K8s ConfigMap — no source fork required. Theme tokens are CSS custom properties (--accent1, --surface1, etc.) so the same DOM tree renders any brand.

Analytics pattern (canonical)

@luxfi/wallet-analytics provides a provider-pluggable AnalyticsDriver interface. No third-party SDK loaded by default. Hanzo Insights is the intended default driver (registered at app bootstrap). White-labels swap drivers at boot via setAnalyticsDriver(...). Surface matches what wallet call sites expect (init/track/identify/Identify/...).

RPC pattern (canonical)

getBootnodeRpcUrl(chainId) from @luxfi/wallet-brand is the only way to resolve an RPC endpoint. Default is https://<gatewayDomain>/v1/rpc/<chainId> with runtimeConfig.rpc[<chainId>] overrides honored first. No Quicknode, no Alchemy direct. White-labels point at their own gateway (e.g., gw.lux.exchange/v1/rpc/96369).

Direct dependency hygiene

  • Zero @datadog/* direct deps in any app package.json or pkgs/*. (Transitive deps via @l.x/utils and @l.x/lx remain — those are upstream packages and will be cleaned in their own pkg releases.)
  • Zero @amplitude/* direct deps.
  • Zero @luxamm/* direct deps (forked to @luxamm/* per commit 81b2ba3e).
  • Zero getQuicknodeEndpointUrl references in our source — only the string literal 'quicknode' exists in error-matching test fixtures.

Workspace catalog (pnpm-workspace.yaml)

@hanzogui/*-fork.1 aliases for React Native packages. The @hanzogui prefix is the internal umbrella name. Product brand for the GUI library is @hanzo/gui.

Known transitive type errors (NOT our bug)

@l.x/api, @l.x/lx, @l.x/utils, @l.x/config ship as raw .ts files and reference a rootless utilities/src/* module that doesn't exist on disk. This means tsc --noEmit against pkgs/wallet/ traces into node_modules/.pnpm/@l.x+* and produces ~5000 errors. These are upstream-publishing bugs in the @l.x/* packages — they need to ship .d.ts artifacts and self-resolved imports. Filed as a follow-up; lux/wallet does not patch upstream packages.

The web SPA builds because Vite tree-shakes — only used surface is touched.

Sibling repos (see LEGACY.md)

  • ~/work/lux/wallet-legacy — OneKey-fork lineage. BRAND_PACKAGE pattern absorbed → archive.
  • ~/work/lux/wwallet — bespoke SDK line. Superseded by @l.x/api → archive.
  • ~/work/lux/xwallet — OKX-fork lineage. Hardware support already removed → archive.
  • ~/work/lux/dwallet — Desktop product. Independent, do not fold in.

apps/backend — App(Wallet) custody server (Go)

The wallet's BACKEND. A small Go HTTP service that gives the wallet a custody account model with MPC custody — NO plaintext keys, ever. The private key is split t-of-n across the MPC nodes (lux → mpc.lux.network) and is never assembled; this service stores only public keys + addresses.

apps/backend/
├── cmd/wallet-backend/main.go   — env config, graceful shutdown, JSON logging
└── internal/
    ├── iam/      — lux.id OIDC bearer verify (JWKS discovery, RS256/ES256,
    │              alg-confusion-safe). Org from the `owner` claim. (golang-jwt/v5)
    ├── custody/  — the custody PORT (Custodian iface) + MPC HTTP adapter to
    │              lux/mpc (/keygen + /sign). Mirrors lux/mpc client.Threshold's
    │              security contract (idempotency required, org-scoped). PQ-ready
    │              (Scheme mldsa65 is first-class).
    ├── store/    — wallet↔org↔addresses (no key field). Org-scoped reads (a
    │              cross-org get is ErrNotFound — no existence disclosure).
    └── api/      — /v1/* HTTP (no /api/ prefix). IAM-gated, org-scoped:
                    POST /v1/wallets · GET /v1/wallets · GET /v1/wallets/{id} ·
                    POST /v1/wallets/{id}/sign · GET /v1/health (open).

Run: cd apps/backend && go test ./... (17 tests). Build standalone with GOWORK=off go build ./cmd/wallet-backend (the monorepo go.work also lists it). Deploy: build-only .platform.yml (repo root) → ghcr.io/luxfi/wallet-backend; the lux operator rolls apps/backend/k8s/wallet-backend.yaml (lux.cloud/v1 Service + KMSSecret for MPC_SERVICE_TOKEN), ingress wallet-api.lux.network.

White-label is per-tenant + per-env injection — IAM_ISSUER (lux → lux.id), IAM_AUDIENCE (<org>-wallet), MPC_ENDPOINT (lux → mpc.lux.network; shared hanzo-mpc or BYOMPC). Same binary, any brand. PQ crypto is NOT duplicated here — ML-DSA/SLH-DSA live in pkgs/wallet/.../pq and on the consensus/precompile side; the custody layer just carries mldsa65 as a scheme through to MPC.

App(Wallet) spec — for the unified operator (App Kind)

The unified web3 operator's App Kind deploys the wallet, white-labeled by domain. The wallet App is three deployable groups, one OSS core, branded per tenant. The product KIND (wallet) is orthogonal to its SOURCE (native = our OSS default | override = a tenant fork): a tenant points source.repo/source.ref at any OSS wallet to replace our native one — same decomplect as pluggable-MPC.

Group What Artifact Host (per brand)
Web wallet apps/web Vite SPA, runtime brand.json (no source fork) ghcr.io/luxfi/wallet-web wallet.<brand>
Custody backend apps/backend Go MPC custody server ghcr.io/luxfi/wallet-backend wallet-api.<brand>
Native binaries luxwallet/* shells (desktop/ios/android/extension) over @luxwallet/sdk signed installers (per platform), GitHub Release wallet.<brand>/download

App(Wallet) CR shape (operator renders Service(s) + Ingress + KMSSecret; chainDefault and the brand configMap are the only per-tenant deltas):

apiVersion: lux.cloud/v1
kind: App
metadata: { name: lux-wallet }       # <brand>-wallet
spec:
  kind: wallet               # curated view: balances/send/receive/PQ identity
  source: { mode: native }   # native luxwallet | { mode: override, repo, ref }
  brand:  lux                # selects the brand.json overlay + native artifacts
  web:    { image: ghcr.io/luxfi/wallet-web,     host: wallet.lux.network }
  backend:{ image: ghcr.io/luxfi/wallet-backend, host: wallet-api.lux.network }
  download: { host: wallet.lux.network/download } # per-brand signed-binary page
  iam:    https://lux.id               # per-tenant (lux→lux.id; hanzo→hanzo.id; zoo→zoo.id)
  mpc:    https://mpc.lux.network      # per-tenant: shared hanzo-mpc | BYOMPC
  chainDefault: 96369                  # per brand: lux 96369 · hanzo 36963 · zoo 200200

Per-brand defaults (the only values that differ; everything else is one image):

Brand chainDefault web host iam gateway
lux 96369 (C-Chain) wallet.lux.network lux.id api.lux.network
hanzo 36963 (Hanzo L1) wallet.hanzo.ai hanzo.id api.hanzo.ai
zoo 200200 (Zoo) wallet.zoo.ngo zoo.id api.zoo.network

Custody = the MPC Kind (shared hanzo-mpc or BYO). The wallet App references {iam, mpc} endpoints; the operator wires the KMSSecret (MPC_SERVICE_TOKEN). The backend custody adapter speaks the lux/mpc HTTP wire POST /keygen + POST /sign (mpcd internal API, default :9800, bearer MPC_INTERNAL_API_KEY). /sign is LIVE (lux/mpc cmd/mpcd, idempotent + t-of-n-quorum-gated, the threshold signature never assembles a key) — the backend↔MPC sign path is a real threshold signature, not a mock.

Web deploy manifestsapps/web/k8s/ is a Kustomize base + 3 brand overlays (overlays/{lux,hanzo,zoo}). Base = lux.cloud/v1 Service for ghcr.io/luxfi/wallet-web (port 3000, imagePullSecrets: ghcr-luxfi, / health). Each overlay sets namespace, ingress host (wallet.{lux.network,hanzo.ai,zoo.ngo}), brand: <b> label, the pinned image tag, and a configMapGenerator over that dir's brand.json (brand volume → /public/brand.json, disableNameSuffixHash since the volume ref is in a custom CR). The brand brand.json files in overlays/<b>/ are the SINGLE source — the brand-swap test reads them directly. Build: kubectl kustomize apps/web/k8s/overlays/<b>. Serving = house ghcr.io/hanzoai/spa (scratch, :3000, SPA fallback → index.html, so /download client-routes). Apps/web image

  • backend image both built by .platform.yml (now a 2-entry build list).

Native binaries + download — the signed installers are PER-BRAND artifacts (a signed installer is immutable, so brand = logo + chainDefault is BAKED at build, unlike the web app's runtime brand.json). Each luxwallet/* shell has a canonical .github/workflows/release.yml (tag v* / dispatch) that builds on the arcd fleet (NO GHA) with a BRAND matrix axis (lux|hanzo|zoo, brand data resolved from @luxwallet/brand@luxwallet/chains), then hands the unsigned artifact to the hanzoai/ci-signing/.github/workflows/sign-<plat>.yml@v1 reusable workflow and publishes the signed artifact + checksum to a GitHub Release. Platform → arcd pool → signer:

Platform shell arcd pool ci-signing artifact
macOS desktop (Electron) luxfi-macos-arm64 sign-macos notarized .dmg
Windows desktop luxfi-windows-amd64 sign-windows Authenticode .exe
Linux desktop luxfi-linux-{amd64,arm64} sign-linux .AppImage + .asc
iOS ios (Xcode/Swift) dbc-luxfi-macos sign-ios App-Store .ipa
Android android (Gradle/Kotlin) luxfi-linux-amd64 sign-android signed .aab
Extension extension (MV3) luxfi-linux-amd64 publish-{chrome,firefox,safari} store pkg + .xpi

The /download screen (apps/web/src/screens/download/) renders the per-brand page (brand logo + name) from a downloads block in brand.json ({ <platform>: { url|storeUrl, version, checksumUrl } }); missing platforms show "Coming soon". The native artifacts' Release-asset URLs feed that block, so wallet.<brand>/download is the signed-binary host for every platform.

Rules for AI Assistants

  1. NEVER write random summary files — update LLM.md only.
  2. NEVER commit symlinked files (.AGENTS.md, CLAUDE.md, etc.) — they're in .gitignore.
  3. NEVER introduce direct @datadog/*/@amplitude/*/@luxamm/* deps — use the abstractions in pkgs/analytics and @luxamm/*.
  4. NEVER reference getQuicknodeEndpointUrl — use getBootnodeRpcUrl.
  5. NEVER hardcode brand strings ("Lux Wallet", "lux.network") in app code — read from the brand singleton in @luxfi/wallet-brand.
  6. NEVER write the banned forked-UI brand — use "@hanzo/gui" / @hanzogui/* umbrella.
  7. ALWAYS preserve BIP44 path 9000 for Lux P/X chain addresses in any key derivation (60 for EVM C-chain).
  8. ALWAYS keep the GPL-3.0-or-later license header — wallet inherits it.
  9. ALWAYS route PQ crypto through pkgs/wallet/src/features/wallet/pq/ — ML-DSA / ML-KEM / SLH-DSA / hybrid / HD-PQ derivation / domain separators / precompile encoders all live there. Re-export from apps/web/src/lib/pq.ts.

PQ stack (LP-4200, FIPS 203/204/205)

Canonical entry: pkgs/wallet/src/features/wallet/pq/index.ts. Web facade: apps/web/src/lib/pq.ts. React hook: apps/web/src/hooks/usePQIdentity.ts.

Module Purpose
pqAccount.ts shape, cSHAKE AccountID, MLDSAProvider iface, HD-path strings
domain.ts FIPS-204 ctx strings (evm-precompile, x-chain-utxo, …)
mldsa.ts ML-DSA-44/65/87 providers via @noble/post-quantum
mlkem.ts ML-KEM-512/768/1024 KEM providers
slhdsa.ts SLH-DSA-128f/192f/192s/256f providers
hybrid.ts Ed25519∥ML-DSA + secp256k1∥ML-DSA containers (luxfi/sdk-compat)
hdPq.ts BIP32 → expandChildSeed → ML-DSA/SLH-DSA keypair
precompile.ts eth_call wrappers for 0x012201/02/03

Tests run via pnpm --dir pkgs/wallet exec jest --config jest.pq.config.js (standalone — bypasses the upstream-shaped jest-expo preset that's still pending refactor). 71 tests passing as of 2026-05-18.


Single source of truth for AI assistants on luxfi/wallet. Update this file when behavior changes; never spawn parallel .md documents.