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/wallethere 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/mpcclient.Thresholdsecurity 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— awaitsloadBrandConfig()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>overbrand.supportedChainIds.src/components/GuiProvider.tsx— passthrough today; swap toHanzoguiProvideronce@hanzo/gui@7republishes its missingdist/artifact.src/config/wagmi.ts—buildWagmiConfig()readsbrand.supportedChainIdsand resolves transports viagetBootnodeRpcUrl(chainId). Chains without a resolvable RPC are dropped — neverhttp("").src/config/queryClient.ts— 30 s stale, retries off, no focus refetch.src/store/index.ts— zustandaccount/chain/uislices owned by Foundation. Other slices (auth,send,swap,stake) are sibling files owned by other Blues.src/hooks/useAccount.ts— wagmi + zustand union; the onlyuseAccountscreens should import.src/hooks/useBrand.ts—() => brandfor 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'sownerclaim.src/lib/custody.ts— typed client for/v1/wallets,/v1/wallets/{id},/v1/wallets/{id}/sign. Matchesinternal/api/api.go+custody.*JSON exactly. AttachesAuthorization: Bearer; never sends org (server-side only).signPayloadrequires a non-emptyidempotencyKey(anti-replay) and refuses to send without one. Validates returned EVM addresses (EIP-55) at the boundary.fetchImplis injectable for tests.src/store/session.ts— lux.id session + custody wallets +signWithCustody.App.tsxcallshydrate()on boot (restore token → list wallets).src/screens/signing/SigningModal.tsx— on confirm, a tx carryingcustody.payloadHashrequests the MPC signature and resolvesSigningResult.signature; a custody failure resolvesreason:"error"(no silent noop). The local-key path (nocustodyfield) is unchanged. Tx broadcast is a typed seam — the modal returns the signature; assembling + broadcasting the signed tx viagetBootnodeRpcUrlis 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+checksumUrlfor "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 apppackage.jsonorpkgs/*. (Transitive deps via@l.x/utilsand@l.x/lxremain — 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 commit81b2ba3e). - Zero
getQuicknodeEndpointUrlreferences 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 manifests — apps/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
- NEVER write random summary files — update
LLM.mdonly. - NEVER commit symlinked files (.AGENTS.md, CLAUDE.md, etc.) — they're in
.gitignore. - NEVER introduce direct
@datadog/*/@amplitude/*/@luxamm/*deps — use the abstractions inpkgs/analyticsand@luxamm/*. - NEVER reference
getQuicknodeEndpointUrl— usegetBootnodeRpcUrl. - NEVER hardcode brand strings (
"Lux Wallet","lux.network") in app code — read from thebrandsingleton in@luxfi/wallet-brand. - NEVER write the banned forked-UI brand — use "@hanzo/gui" /
@hanzogui/*umbrella. - ALWAYS preserve BIP44 path 9000 for Lux P/X chain addresses in any key derivation (60 for EVM C-chain).
- ALWAYS keep the GPL-3.0-or-later license header — wallet inherits it.
- 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 fromapps/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.