feat(web): white-label custody wiring + per-brand deploy + download page

This commit is contained in:
zeekay
2026-06-23 15:51:02 -07:00
parent 64bd0f67c9
commit 10c926c22a
30 changed files with 2084 additions and 20 deletions
+26 -12
View File
@@ -5,16 +5,30 @@
# (lux-build-linux-{amd64,arm64}) and pushes to GHCR. See platform # (lux-build-linux-{amd64,arm64}) and pushes to GHCR. See platform
# docs/PLATFORM_CI.md. # docs/PLATFORM_CI.md.
# #
# Build-only: the lux web3 operator (lux.cloud) deploys the App(Wallet) backend # Build-only: the lux web3 operator (lux.cloud) deploys the App(Wallet) parts
# via the Service CR in apps/backend/k8s/wallet-backend.yaml — not platform's # via the Service CRs (apps/backend/k8s/wallet-backend.yaml for the backend;
# deploy-executor (which targets the hanzo operator only). Bump the CR's # apps/web/k8s/overlays/<brand> kustomize for the per-brand web). Bump each
# .spec.image.tag to the built {{git.sha}} to roll. # CR's pinned tag to the built {{git.sha}} to roll. The same web image
# (ghcr.io/luxfi/wallet-web) serves every brand — white-label is the runtime
# brand.json ConfigMap, not a per-brand build.
build: build:
matrix: # Custody backend — static Go binary, distroless.
- { os: linux, arch: amd64 } - matrix:
- { os: linux, arch: arm64 } - { os: linux, arch: amd64 }
dockerfile: ./apps/backend/Dockerfile - { os: linux, arch: arm64 }
context: . dockerfile: ./apps/backend/Dockerfile
image: ghcr.io/luxfi/wallet-backend context: .
tag-pattern: "{{git.sha}}" image: ghcr.io/luxfi/wallet-backend
push: true tag-pattern: "{{git.sha}}"
push: true
# Web SPA — Vite build, served by the house static SPA server
# (ghcr.io/hanzoai/spa, :3000, SPA fallback). One image, all brands.
- matrix:
- { os: linux, arch: amd64 }
- { os: linux, arch: arm64 }
dockerfile: ./apps/web/Dockerfile
context: .
image: ghcr.io/luxfi/wallet-web
tag-pattern: "{{git.sha}}"
push: true
+67
View File
@@ -73,6 +73,60 @@ GuiProvider → QueryClientProvider → WagmiProvider → RouterProvider
Screen Blues fill `src/screens/{name}/index.tsx`; today each is a labelled 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. 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) ## White-label brand pattern (canonical)
Brand config flows at runtime, not build time. Same pattern as Brand config flows at runtime, not build time. Same pattern as
@@ -217,6 +271,19 @@ brand, served from the per-brand download page on `wallet.<brand>`. The native
shells live in the `luxwallet/*` org (desktop/ios/android/extension); they embed shells live in the `luxwallet/*` org (desktop/ios/android/extension); they embed
the shared `@luxwallet/sdk` core and point at this backend for custody. the shared `@luxwallet/sdk` core and point at this backend for custody.
**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).
## Rules for AI Assistants ## Rules for AI Assistants
1. **NEVER** write random summary files — update `LLM.md` only. 1. **NEVER** write random summary files — update `LLM.md` only.
+47
View File
@@ -0,0 +1,47 @@
# wallet-web — the App(Wallet) web SPA (apps/web), the brand-aware
# self-custodial wallet UI. Built by platform native CI (NO GitHub Actions),
# served by the house static SPA server `ghcr.io/hanzoai/spa` (SPA fallback:
# index.html for every route, so client-routed paths like /download work).
# White-label brand.json is mounted over /public/brand.json by a K8s ConfigMap
# per deployment — no source fork. Build context is the monorepo root.
#
# NO nginx, NO caddy — `ghcr.io/hanzoai/spa` serves /public on :3000.
# ── Build stage: pnpm install (web app + its workspace deps) + vite build ──
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
# Workspace manifests first for a cached install layer. Only the packages the
# web app actually needs are present in the build graph; the upstream-shaped
# apps/{extension,mobile} are filtered out of the install.
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/web/package.json apps/web/
COPY pkgs/brand/package.json pkgs/brand/
COPY pkgs/analytics/package.json pkgs/analytics/
COPY pkgs/wallet/package.json pkgs/wallet/
# Install the web app's dependency closure plus @luxfi/wallet (the PQ stack
# `src/lib/pq.ts` re-exports from it — a build-time import even though it is not
# a declared dep). The `...` selector pulls each package's own deps; the
# heavy/upstream-shaped parts of @luxfi/wallet are tree-shaken by Vite (only the
# `features/wallet/pq` subtree + @noble/post-quantum are reached).
# --ignore-scripts: no native postinstalls needed for the SPA build.
RUN pnpm install \
--filter "@luxfi/wallet-web..." \
--filter "@luxfi/wallet..." \
--no-frozen-lockfile --ignore-scripts
# Sources for the web app + the workspace packages it imports.
COPY pkgs/brand/ pkgs/brand/
COPY pkgs/analytics/ pkgs/analytics/
COPY pkgs/wallet/ pkgs/wallet/
COPY apps/web/ apps/web/
# Vite build → apps/web/dist (copyBrandJson plugin writes dist/brand.json from
# pkgs/brand/brand.json; the ConfigMap overlay replaces it at deploy time).
RUN pnpm --filter "@luxfi/wallet-web" build
# ── Runtime: house static SPA server (scratch-based, :3000, /public) ──
FROM ghcr.io/hanzoai/spa
COPY --from=build /app/apps/web/dist /public
+27
View File
@@ -0,0 +1,27 @@
# ---------------------------------------------------------------------------
# wallet-web base. Pure Kustomize (NEVER Helm). Brand overlays set the
# namespace, ingress host, brand ConfigMap (the runtime brand.json), the
# `volumes` entry that backs the /public/brand.json mount, and the pinned image
# tag.
#
# IMAGE:
# ghcr.io/luxfi/wallet-web — built from apps/web/Dockerfile by platform
# native CI (.platform.yml). Pin the tag in ONE place per overlay via the
# `images:` block, e.g.:
# images:
# - name: ghcr.io/luxfi/wallet-web
# newTag: <git.sha>
# Do NOT use :latest in production overlays — K8s image caching.
# ---------------------------------------------------------------------------
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Common label on all resources WITHOUT touching selectors (Service selectors
# are kept minimal & stable by the operator).
labels:
- pairs:
app.kubernetes.io/part-of: wallet-web
includeSelectors: false
resources:
- wallet-web.yaml
+59
View File
@@ -0,0 +1,59 @@
# wallet-web — the App(Wallet) web SPA, deployed by the unified lux web3
# operator (group lux.cloud) as a Service CR and served by the house static
# SPA server (ghcr.io/hanzoai/spa, :3000, SPA fallback → index.html so the
# client-routed /download path works).
#
# White-label is a runtime brand.json overlay: each brand overlay mounts its
# own ConfigMap over /public/brand.json — NO source fork, NO build-time switch.
# Brand-specific fields (namespace, ingress host, the brand ConfigMap + volume)
# live in the per-brand overlays; this base is brand-agnostic.
#
# No secrets here — the SPA has none. IAM issuer + custody backend are PUBLIC
# config baked into brand.json (the bearer token is obtained client-side via
# lux.id OIDC PKCE; the backend verifies it).
apiVersion: lux.cloud/v1
kind: Service
metadata:
name: wallet-web
labels:
app: wallet-web
component: web
spec:
image:
repository: ghcr.io/luxfi/wallet-web
tag: latest
pullPolicy: Always
replicas: 2
strategy: RollingUpdate
ports:
- name: http
containerPort: 3000
# The brand overlay ConfigMap is mounted over /public/brand.json. The overlay
# supplies the matching `volumes` entry (configMap source) by name.
volumeMounts:
- name: brand
mountPath: /public/brand.json
subPath: brand.json
readOnly: true
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "250m"
memory: "256Mi"
livenessProbe:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
path: /
port: 3000
initialDelaySeconds: 3
periodSeconds: 5
imagePullSecrets:
- name: ghcr-luxfi
ingress:
enabled: true
tls: true
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"name": "Hanzo",
"title": "Hanzo Wallet",
"description": "Self-custodial Hanzo wallet — multi-chain, post-quantum, MPC custody.",
"shortName": "HAN",
"labsName": "Hanzo AI",
"legalEntity": "Hanzo Industries, Inc.",
"walletName": "Hanzo Wallet",
"copyrightHolder": "Hanzo Industries, Inc.",
"appDomain": "wallet.hanzo.ai",
"docsDomain": "docs.hanzo.ai",
"gatewayDomain": "api.hanzo.ai",
"helpUrl": "https://docs.hanzo.ai/wallet",
"termsUrl": "https://hanzo.ai/terms",
"privacyUrl": "https://hanzo.ai/privacy",
"downloadUrl": "https://wallet.hanzo.ai/download",
"walletApi": "https://wallet-api.hanzo.ai",
"iamIssuer": "https://hanzo.id",
"iamClientId": "hanzo-wallet",
"complianceEmail": "compliance@hanzo.ai",
"supportEmail": "hi@hanzo.ai",
"twitter": "https://x.com/hanzoai",
"github": "https://github.com/hanzoai",
"discord": "https://discord.gg/hanzo",
"logoUrl": "/brands/hanzo.svg",
"faviconUrl": "/brands/hanzo.svg",
"primaryColor": "#FFFFFF",
"defaultChainId": 36963,
"supportedChainIds": [36963, 36911, 96369, 200200, 1, 42161, 8453, 137],
"walletConnectProjectId": "",
"insightsHost": "",
"insightsApiKey": "",
"downloads": {
"mac": { "url": "https://wallet.hanzo.ai/dl/mac/Hanzo-Wallet.dmg", "version": "1.0.0", "checksumUrl": "https://wallet.hanzo.ai/dl/mac/checksums.txt" },
"windows": { "url": "https://wallet.hanzo.ai/dl/windows/Hanzo-Wallet-Setup.exe", "version": "1.0.0", "checksumUrl": "https://wallet.hanzo.ai/dl/windows/checksums.txt" },
"linux": { "url": "https://wallet.hanzo.ai/dl/linux/Hanzo-Wallet.AppImage", "version": "1.0.0", "checksumUrl": "https://wallet.hanzo.ai/dl/linux/checksums.txt" },
"ios": { "storeUrl": "https://apps.apple.com/app/hanzo-wallet" },
"android": { "storeUrl": "https://play.google.com/store/apps/details?id=ai.hanzo.wallet" },
"extension": { "storeUrl": "https://chromewebstore.google.com/detail/hanzo-wallet" }
},
"theme": {
"light": { "accent1": "#000000", "surface1": "#FFFFFF", "surface2": "#F9F9F9", "neutral1": "#131313", "neutral2": "rgba(19, 19, 19, 0.63)" },
"dark": { "accent1": "#FFFFFF", "surface1": "#000000", "surface2": "#1A1A1A", "neutral1": "#FFFFFF", "neutral2": "rgba(255, 255, 255, 0.65)" }
}
},
"chains": {
"defaultChainId": 36963,
"supported": [36963, 36911, 96369, 200200, 1, 42161, 8453, 137]
},
"rpc": {
"36963": "https://api.hanzo.ai/mainnet/ext/bc/C/rpc",
"36911": "https://api.hanzo.ai/qchain/ext/bc/C/rpc",
"96369": "https://api.lux.network/mainnet/ext/bc/C/rpc",
"200200": "https://api.zoo.network/rpc",
"1": "https://eth.llamarpc.com",
"42161": "https://arb1.arbitrum.io/rpc",
"8453": "https://mainnet.base.org",
"137": "https://polygon-rpc.com"
},
"api": { "gateway": "https://api.hanzo.ai", "insights": "" },
"walletConnect": { "projectId": "" }
}
@@ -0,0 +1,51 @@
# ---------------------------------------------------------------------------
# wallet-web — HANZO brand overlay. Host wallet.hanzo.ai, namespace
# hanzo-mainnet. This dir's `brand.json` (the SINGLE source for the Hanzo
# brand, also read by the brand-swap test) is mounted over /public/brand.json
# from a generated ConfigMap. Pin the image tag here.
#
# Same OSS image as Lux (ghcr.io/luxfi/wallet-web) — white-label is pure
# runtime brand.json (Hanzo logo + Hanzo L1 chain 36963 + hanzo.id IAM). The
# upstream image reference is fine on any brand's manifest.
# ---------------------------------------------------------------------------
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hanzo-mainnet
resources:
- ../../base
labels:
- pairs:
brand: hanzo
includeSelectors: false
configMapGenerator:
- name: wallet-web-brand
files:
- brand.json
options:
disableNameSuffixHash: true
images:
- name: ghcr.io/luxfi/wallet-web
newTag: latest
patches:
- target:
group: lux.cloud
version: v1
kind: Service
name: wallet-web
patch: |
- op: add
path: /spec/ingress/hosts
value:
- wallet.hanzo.ai
- op: add
path: /spec/volumes
value:
- name: brand
configMap:
name: wallet-web-brand
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"name": "Lux",
"title": "Lux Wallet",
"description": "Self-custodial Lux wallet — multi-chain, post-quantum, MPC custody.",
"shortName": "LUX",
"labsName": "Lux",
"legalEntity": "Lux Industries, Inc.",
"walletName": "Lux Wallet",
"copyrightHolder": "Lux Industries, Inc.",
"appDomain": "wallet.lux.network",
"docsDomain": "docs.lux.network",
"gatewayDomain": "api.lux.network",
"helpUrl": "https://docs.lux.network/wallet",
"termsUrl": "https://lux.network/terms",
"privacyUrl": "https://lux.network/privacy",
"downloadUrl": "https://wallet.lux.network/download",
"walletApi": "https://wallet-api.lux.network",
"iamIssuer": "https://lux.id",
"iamClientId": "lux-wallet",
"complianceEmail": "compliance@lux.network",
"supportEmail": "hi@lux.network",
"twitter": "https://x.com/luxfi",
"github": "https://github.com/luxfi",
"discord": "https://discord.gg/lux",
"logoUrl": "/brands/lux.svg",
"faviconUrl": "/brands/lux.svg",
"primaryColor": "#FFFFFF",
"defaultChainId": 96369,
"supportedChainIds": [96369, 96368, 200200, 36963, 36911, 494949, 1, 42161, 8453, 137, 43114],
"walletConnectProjectId": "",
"insightsHost": "",
"insightsApiKey": "",
"downloads": {
"mac": { "url": "https://wallet.lux.network/dl/mac/Lux-Wallet.dmg", "version": "1.0.0", "checksumUrl": "https://wallet.lux.network/dl/mac/checksums.txt" },
"windows": { "url": "https://wallet.lux.network/dl/windows/Lux-Wallet-Setup.exe", "version": "1.0.0", "checksumUrl": "https://wallet.lux.network/dl/windows/checksums.txt" },
"linux": { "url": "https://wallet.lux.network/dl/linux/Lux-Wallet.AppImage", "version": "1.0.0", "checksumUrl": "https://wallet.lux.network/dl/linux/checksums.txt" },
"ios": { "storeUrl": "https://apps.apple.com/app/lux-wallet" },
"android": { "storeUrl": "https://play.google.com/store/apps/details?id=network.lux.wallet" },
"extension": { "storeUrl": "https://chromewebstore.google.com/detail/lux-wallet" }
},
"theme": {
"light": { "accent1": "#000000", "surface1": "#FFFFFF", "surface2": "#F9F9F9", "neutral1": "#131313", "neutral2": "rgba(19, 19, 19, 0.63)" },
"dark": { "accent1": "#FFFFFF", "surface1": "#000000", "surface2": "#1A1A1A", "neutral1": "#FFFFFF", "neutral2": "rgba(255, 255, 255, 0.65)" }
}
},
"chains": {
"defaultChainId": 96369,
"supported": [96369, 96368, 200200, 36963, 36911, 494949, 1, 42161, 8453, 137, 43114]
},
"rpc": {
"96369": "https://api.lux.network/mainnet/ext/bc/C/rpc",
"96368": "https://api.lux.network/testnet/ext/bc/C/rpc",
"200200": "https://api.zoo.network/rpc",
"1": "https://eth.llamarpc.com",
"42161": "https://arb1.arbitrum.io/rpc",
"8453": "https://mainnet.base.org",
"137": "https://polygon-rpc.com",
"43114": "https://api.avax.network/ext/bc/C/rpc"
},
"api": { "gateway": "https://api.lux.network", "insights": "" },
"walletConnect": { "projectId": "" }
}
@@ -0,0 +1,54 @@
# ---------------------------------------------------------------------------
# wallet-web — LUX brand overlay. Host wallet.lux.network, namespace
# lux-mainnet. The Lux brand.json (this dir's `brand.json` — the SINGLE source
# of truth for the Lux brand, also read by the brand-swap test) is mounted over
# /public/brand.json from a generated ConfigMap. Pin the image tag here.
# ---------------------------------------------------------------------------
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: lux-mainnet
resources:
- ../../base
labels:
- pairs:
brand: lux
includeSelectors: false
# Brand.json → ConfigMap. Key `brand.json` = the mount subPath. The hash suffix
# is disabled because the volume reference lives in a custom (lux.cloud/v1
# Service) CR that kustomize's nameReference transformer does not rewrite — a
# stable literal name keeps the patch correct. A brand change rolls pods via
# the deploy bumping the image tag (CI sets newTag to the built sha).
configMapGenerator:
- name: wallet-web-brand
files:
- brand.json
options:
disableNameSuffixHash: true
images:
- name: ghcr.io/luxfi/wallet-web
newTag: latest
# Patch the Service CR: ingress host + the `volumes` entry that backs the
# /public/brand.json mount (the base declares the volumeMount by name `brand`).
patches:
- target:
group: lux.cloud
version: v1
kind: Service
name: wallet-web
patch: |
- op: add
path: /spec/ingress/hosts
value:
- wallet.lux.network
- op: add
path: /spec/volumes
value:
- name: brand
configMap:
name: wallet-web-brand
+63
View File
@@ -0,0 +1,63 @@
{
"brand": {
"name": "Zoo",
"title": "Zoo Wallet",
"description": "Self-custodial Zoo wallet — multi-chain, post-quantum, MPC custody.",
"shortName": "ZOO",
"labsName": "Zoo Labs Foundation",
"legalEntity": "Zoo Labs Foundation",
"walletName": "Zoo Wallet",
"copyrightHolder": "Zoo Labs Foundation",
"appDomain": "wallet.zoo.ngo",
"docsDomain": "docs.zoo.ngo",
"gatewayDomain": "api.zoo.network",
"helpUrl": "https://docs.zoo.ngo/wallet",
"termsUrl": "https://zoo.ngo/terms",
"privacyUrl": "https://zoo.ngo/privacy",
"downloadUrl": "https://wallet.zoo.ngo/download",
"walletApi": "https://wallet-api.zoo.ngo",
"iamIssuer": "https://zoo.id",
"iamClientId": "zoo-wallet",
"complianceEmail": "compliance@zoo.ngo",
"supportEmail": "hi@zoo.ngo",
"twitter": "https://x.com/zoo_labs",
"github": "https://github.com/zooai",
"discord": "https://discord.gg/zoo",
"logoUrl": "/brands/zoo.svg",
"faviconUrl": "/brands/zoo.svg",
"primaryColor": "#FFFFFF",
"defaultChainId": 200200,
"supportedChainIds": [200200, 200201, 96369, 36963, 1, 42161, 8453, 137],
"walletConnectProjectId": "",
"insightsHost": "",
"insightsApiKey": "",
"downloads": {
"mac": { "url": "https://wallet.zoo.ngo/dl/mac/Zoo-Wallet.dmg", "version": "1.0.0", "checksumUrl": "https://wallet.zoo.ngo/dl/mac/checksums.txt" },
"windows": { "url": "https://wallet.zoo.ngo/dl/windows/Zoo-Wallet-Setup.exe", "version": "1.0.0", "checksumUrl": "https://wallet.zoo.ngo/dl/windows/checksums.txt" },
"linux": { "url": "https://wallet.zoo.ngo/dl/linux/Zoo-Wallet.AppImage", "version": "1.0.0", "checksumUrl": "https://wallet.zoo.ngo/dl/linux/checksums.txt" },
"ios": { "storeUrl": "https://apps.apple.com/app/zoo-wallet" },
"android": { "storeUrl": "https://play.google.com/store/apps/details?id=ngo.zoo.wallet" },
"extension": { "storeUrl": "https://chromewebstore.google.com/detail/zoo-wallet" }
},
"theme": {
"light": { "accent1": "#000000", "surface1": "#FFFFFF", "surface2": "#F9F9F9", "neutral1": "#131313", "neutral2": "rgba(19, 19, 19, 0.63)" },
"dark": { "accent1": "#FFFFFF", "surface1": "#000000", "surface2": "#1A1A1A", "neutral1": "#FFFFFF", "neutral2": "rgba(255, 255, 255, 0.65)" }
}
},
"chains": {
"defaultChainId": 200200,
"supported": [200200, 200201, 96369, 36963, 1, 42161, 8453, 137]
},
"rpc": {
"200200": "https://api.zoo.network/rpc",
"200201": "https://api.zoo.network/testnet/rpc",
"96369": "https://api.lux.network/mainnet/ext/bc/C/rpc",
"36963": "https://api.hanzo.ai/mainnet/ext/bc/C/rpc",
"1": "https://eth.llamarpc.com",
"42161": "https://arb1.arbitrum.io/rpc",
"8453": "https://mainnet.base.org",
"137": "https://polygon-rpc.com"
},
"api": { "gateway": "https://api.zoo.network", "insights": "" },
"walletConnect": { "projectId": "" }
}
@@ -0,0 +1,50 @@
# ---------------------------------------------------------------------------
# wallet-web — ZOO brand overlay. Host wallet.zoo.ngo, namespace zoo-mainnet.
# This dir's `brand.json` (the SINGLE source for the Zoo brand, also read by
# the brand-swap test) is mounted over /public/brand.json from a generated
# ConfigMap. Pin the image tag here.
#
# Same OSS image as Lux (ghcr.io/luxfi/wallet-web) — white-label is pure
# runtime brand.json (Zoo logo + Zoo L1 chain 200200 + zoo.id IAM).
# ---------------------------------------------------------------------------
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: zoo-mainnet
resources:
- ../../base
labels:
- pairs:
brand: zoo
includeSelectors: false
configMapGenerator:
- name: wallet-web-brand
files:
- brand.json
options:
disableNameSuffixHash: true
images:
- name: ghcr.io/luxfi/wallet-web
newTag: latest
patches:
- target:
group: lux.cloud
version: v1
kind: Service
name: wallet-web
patch: |
- op: add
path: /spec/ingress/hosts
value:
- wallet.zoo.ngo
- op: add
path: /spec/volumes
value:
- name: brand
configMap:
name: wallet-web-brand
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "node --import ./tools/ts-resolve.mjs --test 'src/lib/**/*.test.ts' 'src/store/**/*.test.ts' 'src/screens/dapps/**/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@hanzo/gui": "^7.0.0", "@hanzo/gui": "^7.0.0",
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="Hanzo">
<rect width="64" height="64" rx="14" fill="currentColor" />
<path d="M20 18 L20 46 M44 18 L44 46 M20 32 L44 32" stroke="#000" stroke-width="6" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 283 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="Lux">
<rect width="64" height="64" rx="14" fill="currentColor" />
<path d="M16 22 L32 48 L48 22 Z" fill="#000" />
</svg>

After

Width:  |  Height:  |  Size: 220 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="Zoo">
<rect width="64" height="64" rx="14" fill="currentColor" />
<path d="M20 20 L44 20 L20 44 L44 44" stroke="#000" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

+8 -1
View File
@@ -10,7 +10,7 @@
* are populated. The config is memoized at module init via a lazy ref so * are populated. The config is memoized at module init via a lazy ref so
* StrictMode's double render does not re-instantiate WalletConnect. * StrictMode's double render does not re-instantiate WalletConnect.
*/ */
import { lazy, Suspense, useMemo } from "react" import { lazy, Suspense, useEffect, useMemo } from "react"
import { RouterProvider } from "react-router-dom" import { RouterProvider } from "react-router-dom"
import { QueryClientProvider } from "@tanstack/react-query" import { QueryClientProvider } from "@tanstack/react-query"
import { WagmiProvider } from "wagmi" import { WagmiProvider } from "wagmi"
@@ -18,6 +18,7 @@ import { GuiProvider } from "./components/GuiProvider"
import { buildWagmiConfig } from "./config/wagmi" import { buildWagmiConfig } from "./config/wagmi"
import { queryClient } from "./config/queryClient" import { queryClient } from "./config/queryClient"
import { router } from "./router" import { router } from "./router"
import { useSession } from "./store/session"
// Lazy-load the signing modal so the auth/portfolio bootstrap path doesn't // Lazy-load the signing modal so the auth/portfolio bootstrap path doesn't
// pull abi.ts + prices.ts into the initial bundle. The modal mounts at // pull abi.ts + prices.ts into the initial bundle. The modal mounts at
@@ -30,6 +31,12 @@ export default function App(): React.JSX.Element {
// execution before `loadBrandConfig()` finished in some test harnesses. // execution before `loadBrandConfig()` finished in some test harnesses.
const wagmiConfig = useMemo(() => buildWagmiConfig(), []) const wagmiConfig = useMemo(() => buildWagmiConfig(), [])
// Restore the lux.id session (from sessionStorage) + custody wallets on
// boot. Fire-and-forget — a failed hydrate just leaves the app logged out.
useEffect(() => {
void useSession.getState().hydrate()
}, [])
return ( return (
<GuiProvider> <GuiProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+124
View File
@@ -0,0 +1,124 @@
/**
* Brand white-label proof.
*
* Loads each of the three deployed brand overlays (the SINGLE source of truth:
* apps/web/k8s/overlays/<brand>/brand.json — exactly the file the K8s ConfigMap
* mounts over /public/brand.json per deployment) through the real
* `loadBrandConfig()` and asserts the `brand` singleton reflects THAT brand's
* identity: name, default chain, logo, custody backend, and lux.id issuer.
* This is the "correct logo + default chain per brand" verification.
*
* Runner: Node built-in (`node --test`) via tools/ts-resolve.mjs. No DOM —
* `loadBrandConfig` no-ops its document/window mutations under Node, and fetch
* fails → it falls through to the `overrides` we pass (the overlay JSON), which
* is exactly how a ConfigMap-mounted brand.json flows in production.
*/
import { test } from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, resolve } from "node:path"
import {
loadBrandConfig,
brand,
getWalletApiUrl,
getIamConfig,
getBootnodeRpcUrl,
type RuntimeConfig,
} from "@luxfi/wallet-brand"
const HERE = dirname(fileURLToPath(import.meta.url))
// Canonical per-brand overlay = the kustomize ConfigMap source for that brand.
const OVERLAYS_DIR = resolve(HERE, "../../k8s/overlays")
function overlay(name: string): RuntimeConfig {
return JSON.parse(readFileSync(resolve(OVERLAYS_DIR, name, "brand.json"), "utf8")) as RuntimeConfig
}
interface Expected {
name: string
walletName: string
defaultChainId: number
logoUrl: string
walletApi: string
iamIssuer: string
iamClientId: string
}
const EXPECTED: Record<string, Expected> = {
lux: {
name: "Lux",
walletName: "Lux Wallet",
defaultChainId: 96369,
logoUrl: "/brands/lux.svg",
walletApi: "https://wallet-api.lux.network",
iamIssuer: "https://lux.id",
iamClientId: "lux-wallet",
},
hanzo: {
name: "Hanzo",
walletName: "Hanzo Wallet",
defaultChainId: 36963,
logoUrl: "/brands/hanzo.svg",
walletApi: "https://wallet-api.hanzo.ai",
iamIssuer: "https://hanzo.id",
iamClientId: "hanzo-wallet",
},
zoo: {
name: "Zoo",
walletName: "Zoo Wallet",
defaultChainId: 200200,
logoUrl: "/brands/zoo.svg",
walletApi: "https://wallet-api.zoo.ngo",
iamIssuer: "https://zoo.id",
iamClientId: "zoo-wallet",
},
}
for (const [name, want] of Object.entries(EXPECTED)) {
test(`brand overlay: ${name} → correct identity, logo, default chain`, async () => {
await loadBrandConfig(overlay(name))
assert.equal(brand.name, want.name, "name")
assert.equal(brand.walletName, want.walletName, "walletName")
assert.equal(brand.defaultChainId, want.defaultChainId, "defaultChainId")
assert.equal(brand.logoUrl, want.logoUrl, "logoUrl")
assert.equal(getWalletApiUrl(), want.walletApi, "walletApi")
const iam = getIamConfig()
assert.equal(iam.issuer, want.iamIssuer, "iamIssuer")
assert.equal(iam.clientId, want.iamClientId, "iamClientId")
// The default chain must be in the supported set and resolve an RPC.
assert.ok(
brand.supportedChainIds.includes(want.defaultChainId),
"default chain in supportedChainIds",
)
assert.ok(getBootnodeRpcUrl(want.defaultChainId), "default chain resolves an RPC")
})
}
test("brand overlay: Lux default chain is 96369 (C-Chain), not another brand's", async () => {
await loadBrandConfig(overlay("lux"))
assert.equal(brand.defaultChainId, 96369)
assert.notEqual(brand.defaultChainId, EXPECTED.hanzo.defaultChainId)
assert.notEqual(brand.defaultChainId, EXPECTED.zoo.defaultChainId)
})
test("brand swap is total: loading zoo after lux replaces every field", async () => {
await loadBrandConfig(overlay("lux"))
assert.equal(brand.name, "Lux")
await loadBrandConfig(overlay("zoo"))
// No Lux residue after switching to Zoo.
assert.equal(brand.name, "Zoo")
assert.equal(brand.logoUrl, "/brands/zoo.svg")
assert.equal(getIamConfig().issuer, "https://zoo.id")
assert.equal(getWalletApiUrl(), "https://wallet-api.zoo.ngo")
})
test("brand overlay: downloads manifest present per brand", async () => {
await loadBrandConfig(overlay("lux"))
assert.ok(brand.downloads, "downloads present")
assert.ok(brand.downloads?.mac?.url?.includes("lux.network"), "mac url branded")
assert.ok(brand.downloads?.ios?.storeUrl, "ios store link present")
})
+241
View File
@@ -0,0 +1,241 @@
/**
* Custody client tests.
*
* Runner: Node's built-in test runner (`node --test`) — the repo's one way
* (see lib/derive.test.ts, store/send.test.ts). Node 22+ strips TS types and
* `node:test` is stdlib, so no extra dependency.
*
* We mock only `fetch` (the client takes an injectable `fetchImpl`) and seed a
* lux.id token into a sessionStorage polyfill so the REAL `lib/iam`
* `getAccessToken` path runs. Assertions:
* - bearer header attached on every request,
* - org/owner/tenant is NEVER sent by the client (server derives it),
* - exact endpoints + bodies match `apps/backend/internal/api/api.go`,
* - sign refuses without an idempotency key (anti-replay), and sends it when
* present,
* - backend `{error}` bodies become typed CustodyError,
* - returned EVM addresses are validated at the boundary.
*/
import { test, beforeEach } from "node:test"
import assert from "node:assert/strict"
import { brand } from "@luxfi/wallet-brand"
import {
createWallet,
listWallets,
getWallet,
signPayload,
CustodyError,
NotAuthenticatedError,
} from "./custody"
/* ── sessionStorage polyfill + token seeding ─────────────────────────────── */
class MemStorage {
private m = new Map<string, string>()
getItem(k: string): string | null {
return this.m.has(k) ? this.m.get(k)! : null
}
setItem(k: string, v: string): void {
this.m.set(k, String(v))
}
removeItem(k: string): void {
this.m.delete(k)
}
clear(): void {
this.m.clear()
}
}
;(globalThis as unknown as { sessionStorage: MemStorage }).sessionStorage = new MemStorage()
const SS_TOKENS = "lux-wallet/oidc/tokens"
const TEST_TOKEN = "test.jwt.token"
function seedToken(): void {
sessionStorage.setItem(
SS_TOKENS,
JSON.stringify({ accessToken: TEST_TOKEN, expiresAt: Date.now() + 3_600_000 }),
)
}
/** A recorded fetch call + a programmable response factory. */
interface Captured {
url: string
init: RequestInit
}
function mockFetch(
status: number,
body: unknown,
captured: Captured[],
): typeof fetch {
return (async (input: RequestInfo | URL, init: RequestInit = {}) => {
captured.push({ url: String(input), init })
return {
ok: status >= 200 && status < 300,
status,
text: async () => (body === undefined ? "" : JSON.stringify(body)),
} as Response
}) as unknown as typeof fetch
}
beforeEach(() => {
sessionStorage.clear()
brand.walletApi = "https://wallet-api.test"
brand.iamIssuer = "https://lux.id"
brand.iamClientId = "lux-wallet"
seedToken()
})
/* ── auth ─────────────────────────────────────────────────────────────────── */
test("unauthenticated → NotAuthenticatedError, no fetch issued", async () => {
sessionStorage.clear() // no token
const captured: Captured[] = []
await assert.rejects(
() => listWallets(mockFetch(200, { wallets: [] }, captured)),
NotAuthenticatedError,
)
assert.equal(captured.length, 0, "must not call backend without a token")
})
test("every request attaches the lux.id bearer token", async () => {
const captured: Captured[] = []
await listWallets(mockFetch(200, { wallets: [] }, captured))
const auth = new Headers(captured[0]!.init.headers).get("authorization")
assert.equal(auth, `Bearer ${TEST_TOKEN}`)
})
/* ── createWallet ───────────────────────────────────────────────────────────── */
test("createWallet: POST /v1/wallets, NO body (org from token), validates result", async () => {
const captured: Captured[] = []
const wallet = {
walletId: "w-1",
ecdsaPubKey: "0xpub",
addresses: { evm: "0x52908400098527886E0F7030069857D2E4169EE7", btc: "bc1xyz" },
}
const w = await createWallet(mockFetch(201, wallet, captured))
assert.equal(captured.length, 1)
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets")
assert.equal(captured[0]!.init.method, "POST")
// Org/owner/tenant must NEVER be sent — the server derives it from the JWT.
assert.equal(captured[0]!.init.body, undefined, "create must send no body")
assert.equal(w.walletId, "w-1")
assert.equal(w.addresses.evm, "0x52908400098527886E0F7030069857D2E4169EE7")
})
test("createWallet: rejects an invalid EVM address at the boundary", async () => {
const captured: Captured[] = []
const bad = { walletId: "w-1", addresses: { evm: "0xdeadbeef" } } // too short
await assert.rejects(
() => createWallet(mockFetch(201, bad, captured)),
(e: unknown) => e instanceof CustodyError && /invalid EVM address/.test((e as Error).message),
)
})
test("createWallet: rejects a response missing walletId", async () => {
const captured: Captured[] = []
await assert.rejects(
() => createWallet(mockFetch(201, { addresses: {} }, captured)),
(e: unknown) => e instanceof CustodyError && /missing walletId/.test((e as Error).message),
)
})
/* ── listWallets / getWallet ─────────────────────────────────────────────────── */
test("listWallets: GET /v1/wallets, unwraps { wallets }", async () => {
const captured: Captured[] = []
const wallets = [
{ walletId: "w-1", addresses: { evm: "0x52908400098527886E0F7030069857D2E4169EE7" } },
{ walletId: "w-2", addresses: {} },
]
const out = await listWallets(mockFetch(200, { wallets }, captured))
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets")
assert.equal(captured[0]!.init.method, "GET")
assert.equal(out.length, 2)
assert.equal(out[1]!.walletId, "w-2")
})
test("getWallet: GET /v1/wallets/{id} with URL-encoded id", async () => {
const captured: Captured[] = []
const wallet = { walletId: "w/1", addresses: {} }
await getWallet("w/1", mockFetch(200, wallet, captured))
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets/w%2F1")
assert.equal(captured[0]!.init.method, "GET")
})
/* ── signPayload ─────────────────────────────────────────────────────────────── */
test("signPayload: refuses without an idempotency key (no fetch issued)", async () => {
const captured: Captured[] = []
await assert.rejects(
() =>
signPayload(
"w-1",
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "" },
mockFetch(200, { signature: "0xsig" }, captured),
),
(e: unknown) => e instanceof CustodyError && /idempotencyKey required/.test((e as Error).message),
)
assert.equal(captured.length, 0)
})
test("signPayload: POST /v1/wallets/{id}/sign with exact body, returns signature", async () => {
const captured: Captured[] = []
const res = await signPayload(
"w-1",
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc123", idempotencyKey: "idem-1" },
mockFetch(200, { signature: "0xdeadbeef", sessionId: "s-9" }, captured),
)
assert.equal(captured[0]!.url, "https://wallet-api.test/v1/wallets/w-1/sign")
assert.equal(captured[0]!.init.method, "POST")
const sent = JSON.parse(String(captured[0]!.init.body))
// Exact wire shape per backend signBody — and NO org field.
assert.deepEqual(sent, {
scheme: "secp256k1",
chainId: 96369,
payloadHash: "0xabc123",
idempotencyKey: "idem-1",
})
assert.equal("org" in sent, false)
assert.equal("owner" in sent, false)
assert.equal(res.signature, "0xdeadbeef")
assert.equal(res.sessionId, "s-9")
})
test("signPayload: PQ scheme mldsa65 is carried through", async () => {
const captured: Captured[] = []
await signPayload(
"w-1",
{ scheme: "mldsa65", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "k" },
mockFetch(200, { signature: "0xsig" }, captured),
)
assert.equal(JSON.parse(String(captured[0]!.init.body)).scheme, "mldsa65")
})
/* ── error mapping ───────────────────────────────────────────────────────────── */
test("backend {error} body → typed CustodyError with status", async () => {
const captured: Captured[] = []
await assert.rejects(
() =>
signPayload(
"w-1",
{ scheme: "secp256k1", chainId: 96369, payloadHash: "0xabc", idempotencyKey: "k" },
mockFetch(400, { error: "idempotencyKey required" }, captured),
),
(e: unknown) =>
e instanceof CustodyError &&
e.status === 400 &&
/idempotencyKey required/.test((e as Error).message),
)
})
test("cross-org 404 from backend surfaces as CustodyError(404)", async () => {
const captured: Captured[] = []
await assert.rejects(
() => getWallet("someone-elses", mockFetch(404, { error: "wallet not found" }, captured)),
(e: unknown) => e instanceof CustodyError && e.status === 404,
)
})
+209
View File
@@ -0,0 +1,209 @@
/**
* Custody API client — the web app's typed seam to `apps/backend`
* (`wallet-api.<brand>`), the MPC custody server.
*
* Contract (must match `apps/backend/internal/api/api.go` exactly):
* POST /v1/wallets → 201 Wallet
* GET /v1/wallets → 200 { wallets: Wallet[] }
* GET /v1/wallets/{id} → 200 Wallet
* POST /v1/wallets/{id}/sign → 200 SignResult (body: SignBody)
*
* Auth + tenancy:
* - Every request carries `Authorization: Bearer <jwt>` from lux.id
* (`lib/iam.getAccessToken`). The backend verifies issuer + audience and
* derives the org from the token's `owner` claim.
* - The client NEVER sends an org / owner / tenant field. Tenancy is the
* server's responsibility — sending it from the client would be both
* redundant and a spoofing surface.
*
* Boundary validation:
* - Addresses returned by the backend are validated (EVM EIP-55) before use;
* we never trust a custody response blindly.
* - `sign` requires a non-empty idempotency key — a replayed key with a
* different payload is an anti-oracle hazard the backend refuses, and we
* refuse to even send one without it.
*
* No `/api/` prefix; `/v1/*` only. No second HTTP lib — `fetch`. Base URL comes
* from the brand singleton via `getWalletApiUrl()` (white-labelable).
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import { getWalletApiUrl } from "@luxfi/wallet-brand"
import { getAccessToken } from "./iam"
import { validateEvmAddress } from "./address"
/** Signing scheme — mirrors `custody.Scheme` in the Go backend. */
export type CustodyScheme = "secp256k1" | "ed25519" | "mldsa65"
/**
* Custody wallet — mirrors `custody.Wallet`. There is NO private-key field by
* design; the key is MPC-held t-of-n and never assembled. `addresses` maps a
* chain tag (evm/btc/sol) to its derived address.
*/
export interface CustodyWallet {
walletId: string
ecdsaPubKey?: string
eddsaPubKey?: string
addresses: Record<string, string>
}
/** Sign result — mirrors `custody.SignResult`. */
export interface SignResult {
signature: string
sessionId?: string
}
/** Sign request body — mirrors the backend's `signBody` (JSON field names). */
export interface SignBody {
scheme: CustodyScheme
chainId: number
/** 32-byte tx hash, hex (0x-prefixed). */
payloadHash: string
/** Anti-replay key. Required — the backend refuses sign without it. */
idempotencyKey: string
}
/** Typed custody errors — the client never silently no-ops. */
export class CustodyError extends Error {
readonly status?: number
constructor(message: string, status?: number) {
super(message)
this.name = "CustodyError"
this.status = status
}
}
/** Thrown when the caller is not authenticated (no lux.id session). */
export class NotAuthenticatedError extends CustodyError {
constructor() {
super("not authenticated — lux.id login required", 401)
this.name = "NotAuthenticatedError"
}
}
function baseUrl(): string {
const url = getWalletApiUrl()
if (!url) throw new CustodyError("brand has no walletApi configured")
return url
}
/**
* Authenticated JSON request to the custody backend. Attaches the bearer
* token, parses the JSON body, and maps the backend's `{ error }` shape to a
* typed `CustodyError`. `fetchImpl` is injectable for tests.
*/
async function request<T>(
path: string,
init: RequestInit,
fetchImpl: typeof fetch = fetch,
): Promise<T> {
const token = await getAccessToken()
if (!token) throw new NotAuthenticatedError()
const headers = new Headers(init.headers)
headers.set("authorization", `Bearer ${token}`)
headers.set("accept", "application/json")
if (init.body != null) headers.set("content-type", "application/json")
const res = await fetchImpl(`${baseUrl()}${path}`, { ...init, headers })
const text = await res.text()
const json = text ? safeJson(text) : undefined
if (!res.ok) {
const msg =
(json && typeof json === "object" && "error" in json && typeof json.error === "string"
? json.error
: `custody request failed (${res.status})`)
throw new CustodyError(msg, res.status)
}
return json as T
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
throw new CustodyError("custody returned non-JSON response")
}
}
/* ── Boundary validation ─────────────────────────────────────────────────── */
/**
* Validate a custody wallet shape from the backend. We trust the server's
* tenancy but not its bytes: the EVM address (when present) must be a
* well-formed EIP-55 address, and `walletId` must be non-empty.
*/
function assertWallet(w: unknown): CustodyWallet {
if (!w || typeof w !== "object") throw new CustodyError("malformed wallet response")
const wallet = w as CustodyWallet
if (typeof wallet.walletId !== "string" || wallet.walletId.length === 0) {
throw new CustodyError("custody wallet missing walletId")
}
if (!wallet.addresses || typeof wallet.addresses !== "object") {
throw new CustodyError("custody wallet missing addresses")
}
const evm = wallet.addresses.evm
if (evm && !validateEvmAddress(evm).ok) {
throw new CustodyError(`custody returned invalid EVM address: ${evm}`)
}
return wallet
}
/* ── API surface ─────────────────────────────────────────────────────────── */
/**
* Create an MPC custody wallet for the caller's org. The org is taken from the
* verified token server-side — never sent here. Returns the public wallet
* (addresses + pubkeys), never a private key.
*/
export async function createWallet(fetchImpl: typeof fetch = fetch): Promise<CustodyWallet> {
// POST with no body — org comes from the bearer token.
const w = await request<CustodyWallet>("/v1/wallets", { method: "POST" }, fetchImpl)
return assertWallet(w)
}
/** List the org's custody wallets. */
export async function listWallets(fetchImpl: typeof fetch = fetch): Promise<CustodyWallet[]> {
const res = await request<{ wallets: CustodyWallet[] }>("/v1/wallets", { method: "GET" }, fetchImpl)
const wallets = Array.isArray(res?.wallets) ? res.wallets : []
return wallets.map(assertWallet)
}
/** Fetch one custody wallet by id (org-scoped server-side; cross-org → 404). */
export async function getWallet(id: string, fetchImpl: typeof fetch = fetch): Promise<CustodyWallet> {
if (!id) throw new CustodyError("wallet id required")
const w = await request<CustodyWallet>(`/v1/wallets/${encodeURIComponent(id)}`, { method: "GET" }, fetchImpl)
return assertWallet(w)
}
/**
* Request an MPC threshold signature over a 32-byte payload hash. The idempotency
* key is REQUIRED (anti-replay) — we refuse to send a request without one rather
* than let the backend reject it after a round-trip. Returns the signature; no
* private key is ever reconstructed.
*/
export async function signPayload(
walletId: string,
body: SignBody,
fetchImpl: typeof fetch = fetch,
): Promise<SignResult> {
if (!walletId) throw new CustodyError("wallet id required")
if (!body.idempotencyKey) throw new CustodyError("idempotencyKey required")
if (!body.payloadHash) throw new CustodyError("payloadHash required")
return request<SignResult>(
`/v1/wallets/${encodeURIComponent(walletId)}/sign`,
{ method: "POST", body: JSON.stringify(body) },
fetchImpl,
)
}
/**
* Mint an idempotency key for a sign request. A UUID is sufficient — the key
* only needs to be unique per logical sign attempt so a network retry of the
* SAME payload is deduplicated server-side, while a different payload under a
* reused key is refused.
*/
export function newIdempotencyKey(): string {
return crypto.randomUUID()
}
+264
View File
@@ -0,0 +1,264 @@
/**
* lux.id OIDC — Authorization Code + PKCE for the SPA.
*
* The wallet's ONE identity provider is lux.id (per brand: lux→https://lux.id,
* hanzo→https://hanzo.id, zoo→https://zoo.id). Issuer + client id come from the
* brand singleton (`getIamConfig()`), so a white-label points at its own IdP
* with no code change. The backend (apps/backend) verifies the resulting bearer
* against this same issuer + audience (the client id, by the `<org>-<app>`
* convention) and derives the org from the token's `owner` claim — the client
* NEVER sends an org.
*
* Flow (public client, no secret):
* 1. `beginLogin()` mints a PKCE verifier + state, stashes them in
* sessionStorage, and navigates to the IdP's /authorize.
* 2. The IdP redirects back to `<origin>/auth/callback?code=…&state=…`.
* 3. `completeLogin()` validates state, exchanges the code at /token (sending
* the PKCE verifier), and returns the token set.
*
* Token storage: access + refresh tokens live in sessionStorage so a tab
* reload survives without a re-login, but they are gone when the tab closes
* (no localStorage — narrower exposure than the wallet's at-rest secrets, which
* are encrypted). The raw token is never logged and never sent anywhere but the
* configured IdP (token exchange) and the brand's own custody backend.
*
* Endpoints are discovered from the issuer's
* `/.well-known/openid-configuration`, matching how the Go verifier discovers
* JWKS — one issuer, one discovery document.
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import { getIamConfig } from "@luxfi/wallet-brand"
const SCOPE = "openid profile email"
/** OIDC discovery document — only the fields we use. */
interface Discovery {
authorization_endpoint: string
token_endpoint: string
}
/** Token set as returned by the IdP /token endpoint. */
export interface TokenSet {
/** The bearer JWT sent to the custody backend as `Authorization: Bearer`. */
accessToken: string
/** Refresh token (if the IdP issues one) for silent renewal. */
refreshToken?: string
/** Absolute expiry (epoch ms) computed from `expires_in`. */
expiresAt: number
}
/** sessionStorage keys — namespaced so they never collide with wallet state. */
const SS_VERIFIER = "lux-wallet/oidc/pkce-verifier"
const SS_STATE = "lux-wallet/oidc/state"
const SS_TOKENS = "lux-wallet/oidc/tokens"
/** The callback path the IdP redirects to. Registered as a client redirect URI. */
export const OIDC_CALLBACK_PATH = "/auth/callback"
function redirectUri(): string {
return `${window.location.origin}${OIDC_CALLBACK_PATH}`
}
/* ── PKCE primitives (RFC 7636) — Web Crypto only, no deps ───────────────── */
/** base64url without padding, per RFC 7636 §A. */
function base64url(bytes: Uint8Array): string {
let bin = ""
for (const b of bytes) bin += String.fromCharCode(b)
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function randomUrlSafe(byteLen: number): string {
const buf = new Uint8Array(byteLen)
crypto.getRandomValues(buf)
return base64url(buf)
}
/** S256 code challenge = base64url(SHA-256(verifier)). */
async function codeChallengeS256(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return base64url(new Uint8Array(digest))
}
/* ── Discovery ───────────────────────────────────────────────────────────── */
let discoveryCache: { issuer: string; doc: Discovery } | null = null
async function discover(issuer: string): Promise<Discovery> {
if (discoveryCache && discoveryCache.issuer === issuer) return discoveryCache.doc
const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`
const res = await fetch(url, { headers: { accept: "application/json" } })
if (!res.ok) throw new IamError(`OIDC discovery failed (${res.status})`)
const doc = (await res.json()) as Partial<Discovery>
if (!doc.authorization_endpoint || !doc.token_endpoint) {
throw new IamError("OIDC discovery document missing endpoints")
}
const full: Discovery = {
authorization_endpoint: doc.authorization_endpoint,
token_endpoint: doc.token_endpoint,
}
discoveryCache = { issuer, doc: full }
return full
}
/** Typed IAM error — auth flow never silently no-ops. */
export class IamError extends Error {
constructor(message: string) {
super(message)
this.name = "IamError"
}
}
/* ── Public flow ─────────────────────────────────────────────────────────── */
/**
* Start the login redirect. Mints PKCE + state, persists them, and navigates
* the browser to the IdP's authorize endpoint. Does not return (navigation).
*/
export async function beginLogin(): Promise<void> {
const { issuer, clientId } = getIamConfig()
if (!issuer || !clientId) throw new IamError("brand has no iamIssuer/iamClientId configured")
const { authorization_endpoint } = await discover(issuer)
const verifier = randomUrlSafe(32)
const state = randomUrlSafe(16)
sessionStorage.setItem(SS_VERIFIER, verifier)
sessionStorage.setItem(SS_STATE, state)
const challenge = await codeChallengeS256(verifier)
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri(),
scope: SCOPE,
state,
code_challenge: challenge,
code_challenge_method: "S256",
})
window.location.assign(`${authorization_endpoint}?${params.toString()}`)
}
/**
* Complete the login on the callback route. Validates `state`, exchanges the
* authorization code (with the stashed PKCE verifier) for tokens, persists
* them, and returns the set. Throws `IamError` on any mismatch or failure.
*/
export async function completeLogin(search: string): Promise<TokenSet> {
const { issuer, clientId } = getIamConfig()
if (!issuer || !clientId) throw new IamError("brand has no iamIssuer/iamClientId configured")
const q = new URLSearchParams(search)
const err = q.get("error")
if (err) throw new IamError(`IdP returned error: ${err}`)
const code = q.get("code")
const state = q.get("state")
const expectedState = sessionStorage.getItem(SS_STATE)
const verifier = sessionStorage.getItem(SS_VERIFIER)
// One-shot: clear immediately so a replayed callback can't reuse them.
sessionStorage.removeItem(SS_STATE)
sessionStorage.removeItem(SS_VERIFIER)
if (!code) throw new IamError("callback missing authorization code")
if (!state || !expectedState || state !== expectedState) {
throw new IamError("OIDC state mismatch — possible CSRF")
}
if (!verifier) throw new IamError("missing PKCE verifier — login not initiated here")
const { token_endpoint } = await discover(issuer)
const tokens = await exchange(token_endpoint, {
grant_type: "authorization_code",
code,
redirect_uri: redirectUri(),
client_id: clientId,
code_verifier: verifier,
})
persist(tokens)
return tokens
}
/**
* Return a valid access token, refreshing if it is within `skewMs` of expiry
* and a refresh token is available. Returns null when no session exists (caller
* triggers `beginLogin()`).
*/
export async function getAccessToken(skewMs = 30_000): Promise<string | null> {
const tokens = load()
if (!tokens) return null
if (Date.now() < tokens.expiresAt - skewMs) return tokens.accessToken
if (!tokens.refreshToken) {
// Expired and unrenewable — drop it so the app re-authenticates cleanly.
clearSession()
return null
}
try {
const refreshed = await refresh(tokens.refreshToken)
return refreshed.accessToken
} catch {
clearSession()
return null
}
}
/** True when a (possibly-expiring) session is present. */
export function hasSession(): boolean {
return load() !== null
}
/** Clear the OIDC session (logout). Does not touch wallet at-rest state. */
export function clearSession(): void {
sessionStorage.removeItem(SS_TOKENS)
}
/* ── Token exchange + persistence ────────────────────────────────────────── */
async function refresh(refreshToken: string): Promise<TokenSet> {
const { issuer, clientId } = getIamConfig()
const { token_endpoint } = await discover(issuer)
const tokens = await exchange(token_endpoint, {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
})
// Some IdPs omit a new refresh token on renewal — keep the old one.
if (!tokens.refreshToken) tokens.refreshToken = refreshToken
persist(tokens)
return tokens
}
async function exchange(tokenEndpoint: string, body: Record<string, string>): Promise<TokenSet> {
const res = await fetch(tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
})
if (!res.ok) throw new IamError(`token endpoint ${res.status}`)
const json = (await res.json()) as {
access_token?: string
refresh_token?: string
expires_in?: number
}
if (!json.access_token) throw new IamError("token response missing access_token")
return {
accessToken: json.access_token,
refreshToken: json.refresh_token,
// Default to 5 min when the IdP omits expires_in.
expiresAt: Date.now() + (json.expires_in ?? 300) * 1000,
}
}
function persist(tokens: TokenSet): void {
sessionStorage.setItem(SS_TOKENS, JSON.stringify(tokens))
}
function load(): TokenSet | null {
const raw = sessionStorage.getItem(SS_TOKENS)
if (!raw) return null
try {
const t = JSON.parse(raw) as TokenSet
if (typeof t.accessToken === "string" && typeof t.expiresAt === "number") return t
return null
} catch {
return null
}
}
+12
View File
@@ -16,6 +16,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from "react-router-do
import { AppShell } from "./components/AppShell" import { AppShell } from "./components/AppShell"
const Auth = lazy(() => import("./screens/auth")) const Auth = lazy(() => import("./screens/auth"))
const Callback = lazy(() => import("./screens/auth/Callback"))
const Portfolio = lazy(() => import("./screens/portfolio")) const Portfolio = lazy(() => import("./screens/portfolio"))
const Send = lazy(() => import("./screens/send")) const Send = lazy(() => import("./screens/send"))
const Receive = lazy(() => import("./screens/receive")) const Receive = lazy(() => import("./screens/receive"))
@@ -25,6 +26,7 @@ const Stake = lazy(() => import("./screens/stake"))
const DApps = lazy(() => import("./screens/dapps")) const DApps = lazy(() => import("./screens/dapps"))
const Confidential = lazy(() => import("./screens/confidential")) const Confidential = lazy(() => import("./screens/confidential"))
const Settings = lazy(() => import("./screens/settings")) const Settings = lazy(() => import("./screens/settings"))
const Download = lazy(() => import("./screens/download"))
function ScreenFallback(): React.JSX.Element { function ScreenFallback(): React.JSX.Element {
return ( return (
@@ -53,10 +55,20 @@ const SCREEN_ROUTES: RouteObject[] = [
{ path: "dapps/*", element: <DApps /> }, { path: "dapps/*", element: <DApps /> },
{ path: "confidential/*", element: <Confidential /> }, { path: "confidential/*", element: <Confidential /> },
{ path: "settings/*", element: <Settings /> }, { path: "settings/*", element: <Settings /> },
{ path: "download/*", element: <Download /> },
{ path: "*", element: <Navigate to="/portfolio" replace /> }, { path: "*", element: <Navigate to="/portfolio" replace /> },
] ]
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{
// OIDC callback renders bare (no AppShell) — it runs before login.
path: "/auth/callback",
element: (
<Suspense fallback={<ScreenFallback />}>
<Callback />
</Suspense>
),
},
{ {
path: "/", path: "/",
element: ( element: (
+73
View File
@@ -0,0 +1,73 @@
/**
* OIDC callback — the redirect target for lux.id (`/auth/callback`).
*
* Validates `state`, exchanges the authorization code for tokens (via
* `useSession.completeCallback` → `lib/iam.completeLogin`), then navigates to
* the portfolio. On failure it shows the error and a link back to start over.
*
* Renders bare (no AppShell) — it runs before the app is "logged in".
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { useBrand } from "../../hooks/useBrand"
import { useSession } from "../../store/session"
export default function Callback(): React.JSX.Element {
const brand = useBrand()
const navigate = useNavigate()
const completeCallback = useSession((s) => s.completeCallback)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
completeCallback(window.location.search)
.then(() => {
if (!cancelled) navigate("/portfolio", { replace: true })
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
})
return () => {
cancelled = true
}
}, [completeCallback, navigate])
return (
<div style={wrap}>
{brand.logoUrl ? (
<img src={brand.logoUrl} alt="" width={40} height={40} aria-hidden="true" style={{ marginBottom: 16 }} />
) : null}
{error ? (
<>
<h1 style={title}>Sign-in failed</h1>
<p style={msg}>{error}</p>
<a href="/" style={link}>
Back to {brand.walletName || "wallet"}
</a>
</>
) : (
<>
<h1 style={title}>Signing you in</h1>
<p style={msg}>Completing {brand.name || "lux.id"} authentication.</p>
</>
)}
</div>
)
}
const wrap: React.CSSProperties = {
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "var(--surface1, #000)",
color: "var(--neutral1, #fff)",
padding: 24,
textAlign: "center",
}
const title: React.CSSProperties = { fontSize: 20, fontWeight: 600, margin: "0 0 8px" }
const msg: React.CSSProperties = { fontSize: 14, color: "var(--neutral2, #888)", margin: "0 0 16px" }
const link: React.CSSProperties = { color: "var(--accent1, #fff)", fontSize: 14 }
+168
View File
@@ -0,0 +1,168 @@
/**
* Download screen — the per-brand native-download HOST surface served at
* `wallet.<brand>/download`.
*
* Renders the brand's logo + name and a grid of every native target:
* macOS · Windows · Linux · iOS · Android · Browser extension.
*
* The binary URLs + versions come from `brand.downloads` (filled per brand in
* `brand.json`, published by the native CI). Direct binaries use `url`; store
* distributions (iOS/Android/extension) use `storeUrl`. Each card surfaces the
* version and a "Verify signature" link to the published checksums when a
* `checksumUrl` is present. A platform with no link degrades to "Coming soon".
*
* All brand strings come from the `brand` singleton (never hardcoded).
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import type { BrandDownload, BrandDownloads } from "@luxfi/wallet-brand"
import { useBrand } from "../../hooks/useBrand"
interface PlatformDef {
key: keyof BrandDownloads
label: string
/** What the primary button installs/opens. */
action: string
/** Hint shown under the title when no link exists. */
format: string
}
// Display order + per-platform metadata. The `action` label distinguishes a
// direct download from a store visit.
const PLATFORMS: PlatformDef[] = [
{ key: "mac", label: "macOS", action: "Download .dmg", format: "Apple silicon + Intel" },
{ key: "windows", label: "Windows", action: "Download installer", format: ".exe / .msi" },
{ key: "linux", label: "Linux", action: "Download", format: ".AppImage / .deb" },
{ key: "ios", label: "iOS", action: "App Store", format: "iPhone + iPad" },
{ key: "android", label: "Android", action: "Get it on Google Play", format: ".apk / Play" },
{ key: "extension", label: "Browser extension", action: "Add to browser", format: "Chrome · Firefox · Safari" },
]
export default function Download(): React.JSX.Element {
const brand = useBrand()
const downloads = brand.downloads ?? {}
const name = brand.walletName || brand.name || "Wallet"
return (
<div style={wrap}>
<header style={head}>
{brand.logoUrl ? (
<img src={brand.logoUrl} alt="" width={48} height={48} aria-hidden="true" />
) : null}
<div>
<h1 style={title}>Download {name}</h1>
<p style={subtitle}>
Self-custodial {brand.name || ""} wallet for every platform. Verify each
build's signature before installing.
</p>
</div>
</header>
<ul style={grid}>
{PLATFORMS.map((p) => (
<PlatformCard key={p.key} def={p} dl={downloads[p.key]} />
))}
</ul>
<footer style={foot}>
Trouble installing? See{" "}
<a href={brand.helpUrl || "#"} style={link} target="_blank" rel="noreferrer">
{brand.docsDomain || "docs"}
</a>
.
</footer>
</div>
)
}
function PlatformCard({ def, dl }: { def: PlatformDef; dl?: BrandDownload }): React.JSX.Element {
// Direct binary first (mac/win/linux), then store link (ios/android/ext).
const href = dl?.url ?? dl?.storeUrl
const available = Boolean(href)
return (
<li style={card}>
<div style={cardTop}>
<span style={platLabel}>{def.label}</span>
{dl?.version ? <span style={ver}>v{dl.version}</span> : null}
</div>
<span style={fmt}>{def.format}</span>
{available ? (
<a style={dlBtn} href={href} target="_blank" rel="noreferrer">
{def.action}
</a>
) : (
<span style={soon} aria-disabled="true">
Coming soon
</span>
)}
{dl?.checksumUrl ? (
<a style={verify} href={dl.checksumUrl} target="_blank" rel="noreferrer">
Verify signature
</a>
) : null}
</li>
)
}
const wrap: React.CSSProperties = { maxWidth: 920, margin: "0 auto", display: "grid", gap: 24 }
const head: React.CSSProperties = { display: "flex", gap: 16, alignItems: "center" }
const title: React.CSSProperties = { fontSize: 24, fontWeight: 700, margin: 0 }
const subtitle: React.CSSProperties = {
fontSize: 14,
color: "var(--neutral2, #888)",
margin: "6px 0 0",
maxWidth: 560,
}
const grid: React.CSSProperties = {
listStyle: "none",
padding: 0,
margin: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
}
const card: React.CSSProperties = {
display: "grid",
gap: 8,
padding: 16,
border: "1px solid var(--surface3, #222)",
borderRadius: 12,
background: "var(--surface2, #0c0c0c)",
}
const cardTop: React.CSSProperties = { display: "flex", justifyContent: "space-between", alignItems: "baseline" }
const platLabel: React.CSSProperties = { fontSize: 16, fontWeight: 600 }
const ver: React.CSSProperties = {
fontSize: 12,
color: "var(--neutral2, #888)",
fontFamily: "ui-monospace, SFMono-Regular, monospace",
}
const fmt: React.CSSProperties = { fontSize: 12, color: "var(--neutral2, #888)" }
const dlBtn: React.CSSProperties = {
display: "inline-block",
textAlign: "center",
marginTop: 4,
padding: "10px 14px",
background: "var(--accent1, #fff)",
color: "var(--surface1, #000)",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
textDecoration: "none",
}
const soon: React.CSSProperties = {
display: "inline-block",
textAlign: "center",
marginTop: 4,
padding: "10px 14px",
background: "transparent",
color: "var(--neutral2, #666)",
border: "1px solid var(--surface3, #222)",
borderRadius: 8,
fontSize: 14,
}
const verify: React.CSSProperties = { fontSize: 12, color: "var(--neutral2, #888)", textDecoration: "underline" }
const foot: React.CSSProperties = { fontSize: 13, color: "var(--neutral2, #888)" }
const link: React.CSSProperties = { color: "var(--accent1, #fff)" }
+21
View File
@@ -0,0 +1,21 @@
/**
* `/download` route — per-brand native download host. Single screen.
*
* Mounted under `<Routes>` from `apps/web/src/router.tsx`:
*
* import DownloadRoutes from "./screens/download"
* …
* <Route path="/download/*" element={<DownloadRoutes />} />
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import { Routes, Route } from "react-router-dom"
import Download from "./Download"
export default function DownloadRoutes() {
return (
<Routes>
<Route index element={<Download />} />
</Routes>
)
}
+35 -4
View File
@@ -22,20 +22,24 @@ import { type DecodedCall } from "../../lib/abi"
import RiskIndicator, { assessRisk, type RiskAssessment } from "./RiskIndicator" import RiskIndicator, { assessRisk, type RiskAssessment } from "./RiskIndicator"
import TxSummary from "./TxSummary" import TxSummary from "./TxSummary"
import { useSigningStore } from "../../store/signing" import { useSigningStore } from "../../store/signing"
import { useSession } from "../../store/session"
export default function SigningModal() { export default function SigningModal() {
const pending = useSigningStore((s) => s.pending) const pending = useSigningStore((s) => s.pending)
const confirm = useSigningStore((s) => s.confirm) const confirm = useSigningStore((s) => s.confirm)
const reject = useSigningStore((s) => s.reject) const reject = useSigningStore((s) => s.reject)
const cancel = useSigningStore((s) => s.cancel) const cancel = useSigningStore((s) => s.cancel)
const resolveWith = useSigningStore((s) => s.resolveWith)
const [decoded, setDecoded] = useState<DecodedCall | null>(null) const [decoded, setDecoded] = useState<DecodedCall | null>(null)
const [risk, setRisk] = useState<RiskAssessment | null>(null) const [risk, setRisk] = useState<RiskAssessment | null>(null)
const [signing, setSigning] = useState(false)
// Reset decode state on every new tx. // Reset decode + signing state on every new tx.
useEffect(() => { useEffect(() => {
setDecoded(null) setDecoded(null)
setRisk(null) setRisk(null)
setSigning(false)
}, [pending?.from, pending?.to, pending?.data, pending?.value]) }, [pending?.from, pending?.to, pending?.data, pending?.value])
// Re-assess whenever decode result changes. // Re-assess whenever decode result changes.
@@ -54,6 +58,33 @@ export default function SigningModal() {
return () => window.removeEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey)
}, [pending, cancel]) }, [pending, cancel])
// Confirm handler. For a custody tx, request the MPC threshold signature
// from the backend and resolve with it; otherwise resolve the local-key
// approval (the caller signs with the in-RAM mnemonic). A custody failure
// resolves with reason:"error" — never a silent noop.
const onConfirm = async () => {
if (!pending) return
if (!pending.custody) {
confirm()
return
}
setSigning(true)
try {
const res = await useSession.getState().signWithCustody({
chainId: pending.chainId,
payloadHash: pending.custody.payloadHash,
scheme: pending.custody.scheme,
})
resolveWith({ approved: true, signature: res.signature })
} catch (e) {
resolveWith({
approved: false,
reason: "error",
error: e instanceof Error ? e.message : String(e),
})
}
}
if (!pending) return null if (!pending) return null
return ( return (
@@ -90,11 +121,11 @@ export default function SigningModal() {
<TxSummary tx={pending} onDecoded={setDecoded} /> <TxSummary tx={pending} onDecoded={setDecoded} />
<footer style={footer}> <footer style={footer}>
<button type="button" onClick={reject} style={rejectBtn}> <button type="button" onClick={reject} style={rejectBtn} disabled={signing}>
Reject Reject
</button> </button>
<button type="button" onClick={confirm} style={confirmBtn}> <button type="button" onClick={onConfirm} style={confirmBtn} disabled={signing}>
Confirm {signing ? "Signing…" : "Confirm"}
</button> </button>
</footer> </footer>
</section> </section>
+170
View File
@@ -0,0 +1,170 @@
/**
* Session slice — lux.id auth + MPC custody wallet state.
*
* This is the bridge between the brand's lux.id IdP (`lib/iam`) and the brand's
* custody backend (`lib/custody`). It owns:
* - the login lifecycle (`login` → IdP redirect; `completeCallback` on
* return; `logout`),
* - the org's custody wallets (created/listed via the backend; the org is
* derived server-side from the verified token — never sent here),
* - the active custody wallet used for signing,
* - `signWithCustody(...)` — the REAL sign-hash round-trip to the backend's
* MPC threshold signer.
*
* No secrets are persisted here. The lux.id tokens live in sessionStorage
* (managed by `lib/iam`); this store keeps only the public wallet (addresses +
* pubkeys) and a transient status. On reload, `hydrate()` restores
* authentication from the sessionStorage token and re-lists wallets.
*
* GPL-3.0-or-later — inherited from the wallet monorepo.
*/
import { create } from "zustand"
import {
beginLogin,
completeLogin,
clearSession,
hasSession,
} from "../lib/iam"
import {
createWallet as apiCreateWallet,
listWallets as apiListWallets,
signPayload,
newIdempotencyKey,
type CustodyWallet,
type CustodyScheme,
type SignResult,
} from "../lib/custody"
export type SessionStatus = "idle" | "loading" | "ready" | "error"
/** A custody sign request from a screen — payloadHash is the 32-byte tx hash. */
export interface CustodySignArgs {
chainId: number
/** 0x-prefixed 32-byte hash of the unsigned tx. */
payloadHash: string
/** Defaults to secp256k1 (EVM). */
scheme?: CustodyScheme
}
export interface SessionState {
/** True when a lux.id session token is present. */
authenticated: boolean
/** The org's custody wallets (public; no keys). */
wallets: CustodyWallet[]
/** The wallet used for signing. Defaults to the first wallet. */
activeWallet: CustodyWallet | null
status: SessionStatus
error: string | null
/** Restore auth from the persisted token and re-list wallets. */
hydrate: () => Promise<void>
/** Begin the lux.id OIDC login (navigates away). */
login: () => Promise<void>
/** Finish login on the /auth/callback route, then load wallets. */
completeCallback: (search: string) => Promise<void>
/** Clear the lux.id session and custody state. */
logout: () => void
/** Create a new MPC custody wallet for the org and select it. */
createWallet: () => Promise<CustodyWallet>
/** Reload the org's wallets from the backend. */
loadWallets: () => Promise<void>
/** Choose the active signing wallet by id. */
selectWallet: (walletId: string) => void
/**
* Sign a payload hash with the active custody wallet via the backend MPC
* signer. Mints the required idempotency key. Throws if no wallet is active.
*/
signWithCustody: (args: CustodySignArgs) => Promise<SignResult>
}
export const useSession = create<SessionState>((set, get) => ({
authenticated: false,
wallets: [],
activeWallet: null,
status: "idle",
error: null,
hydrate: async () => {
if (!hasSession()) {
set({ authenticated: false })
return
}
set({ authenticated: true })
await get().loadWallets()
},
login: async () => {
set({ status: "loading", error: null })
// Navigates away — control does not return on success.
await beginLogin()
},
completeCallback: async (search) => {
set({ status: "loading", error: null })
try {
await completeLogin(search)
set({ authenticated: true })
await get().loadWallets()
} catch (e) {
set({ status: "error", error: errMsg(e) })
throw e
}
},
logout: () => {
clearSession()
set({ authenticated: false, wallets: [], activeWallet: null, status: "idle", error: null })
},
createWallet: async () => {
set({ status: "loading", error: null })
try {
const wallet = await apiCreateWallet()
const wallets = [...get().wallets, wallet]
set({ wallets, activeWallet: get().activeWallet ?? wallet, status: "ready" })
return wallet
} catch (e) {
set({ status: "error", error: errMsg(e) })
throw e
}
},
loadWallets: async () => {
set({ status: "loading", error: null })
try {
const wallets = await apiListWallets()
set({
wallets,
// Keep the current selection if still present; else first wallet.
activeWallet:
get().activeWallet && wallets.some((w) => w.walletId === get().activeWallet?.walletId)
? get().activeWallet
: (wallets[0] ?? null),
status: "ready",
})
} catch (e) {
set({ status: "error", error: errMsg(e) })
throw e
}
},
selectWallet: (walletId) => {
const wallet = get().wallets.find((w) => w.walletId === walletId) ?? null
set({ activeWallet: wallet })
},
signWithCustody: async ({ chainId, payloadHash, scheme = "secp256k1" }) => {
const wallet = get().activeWallet
if (!wallet) throw new Error("no active custody wallet")
return signPayload(wallet.walletId, {
scheme,
chainId,
payloadHash,
idempotencyKey: newIdempotencyKey(),
})
},
}))
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e)
}
+31 -2
View File
@@ -52,12 +52,28 @@ export interface UnsignedTx {
summary?: string summary?: string
/** dApp metadata, present only when origin === "dapp". */ /** dApp metadata, present only when origin === "dapp". */
dapp?: DAppCaller dapp?: DAppCaller
/**
* MPC custody signing. When set, confirm requests an MPC threshold signature
* over `payloadHash` from the custody backend (the active session wallet)
* rather than signing locally with the in-RAM mnemonic. The signature is
* returned in `SigningResult.signature`. Absent local-key signing path.
*/
custody?: {
/** 0x-prefixed 32-byte hash of the unsigned tx to sign. */
payloadHash: string
/** Signing scheme; defaults to secp256k1 (EVM). */
scheme?: "secp256k1" | "ed25519" | "mldsa65"
}
} }
export interface SigningResult { export interface SigningResult {
approved: boolean approved: boolean
/** Set when approved=false: "rejected" | "closed" | "timeout". */ /** Set when approved=false: "rejected" | "closed" | "timeout" | "error". */
reason?: "rejected" | "closed" | "timeout" reason?: "rejected" | "closed" | "timeout" | "error"
/** Hex signature when a custody tx was approved + signed. */
signature?: string
/** Error detail when reason === "error". */
error?: string
} }
interface PendingEntry extends UnsignedTx { interface PendingEntry extends UnsignedTx {
@@ -72,6 +88,12 @@ export interface SigningState {
reject: () => void reject: () => void
/** Generic close — used by ESC, backdrop click, beforeunload. */ /** Generic close — used by ESC, backdrop click, beforeunload. */
cancel: (reason: SigningResult["reason"]) => void cancel: (reason: SigningResult["reason"]) => void
/**
* Resolve the pending request with a full result. The modal uses this for
* the custody path (carries `signature`/`error`); the store stays a pure
* data slice the async MPC call lives in the modal/hook, not here.
*/
resolveWith: (result: SigningResult) => void
} }
export const useSigningStore = create<SigningState>((set, get) => ({ export const useSigningStore = create<SigningState>((set, get) => ({
@@ -111,6 +133,13 @@ export const useSigningStore = create<SigningState>((set, get) => ({
p.resolve({ approved: false, reason }) p.resolve({ approved: false, reason })
set({ pending: null }) set({ pending: null })
}, },
resolveWith: (result) => {
const p = get().pending
if (!p) return
p.resolve(result)
set({ pending: null })
},
})) }))
/** /**
+38
View File
@@ -0,0 +1,38 @@
/**
* TS resolve hook for the Node built-in test runner.
*
* The repo's `*.test.ts` files use bundler-style imports (no `.ts` extension),
* which Vite resolves at build time but Node's ESM loader does not. This
* registers a synchronous resolve hook that maps an extensionless relative
* specifier (`./foo`, `../bar`) to `./foo.ts` / `.tsx` / `.js` or
* `./foo/index.ts`, so `node --test` can load them. Node 22+ strips the TS
* types natively no transpiler dependency.
*
* Used by the `test` script: `node --import ./tools/ts-resolve.mjs --test ...`.
*
* GPL-3.0-or-later inherited from the wallet monorepo.
*/
import { registerHooks } from "node:module"
import { existsSync } from "node:fs"
import { fileURLToPath, pathToFileURL } from "node:url"
import { dirname, resolve as pathResolve } from "node:path"
registerHooks({
resolve(specifier, context, nextResolve) {
if ((specifier.startsWith("./") || specifier.startsWith("../")) && !/\.[a-z]+$/i.test(specifier)) {
const parentPath = context.parentURL ? fileURLToPath(context.parentURL) : process.cwd()
const base = pathResolve(dirname(parentPath), specifier)
const candidates = [
base + ".ts",
base + ".tsx",
base + ".js",
base + "/index.ts",
base + "/index.tsx",
]
for (const c of candidates) {
if (existsSync(c)) return { url: pathToFileURL(c).href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
},
})
+3
View File
@@ -15,6 +15,9 @@
"termsUrl": "https://lux.network/terms", "termsUrl": "https://lux.network/terms",
"privacyUrl": "https://lux.network/privacy", "privacyUrl": "https://lux.network/privacy",
"downloadUrl": "https://wallet.lux.network/download", "downloadUrl": "https://wallet.lux.network/download",
"walletApi": "https://wallet-api.lux.network",
"iamIssuer": "https://lux.id",
"iamClientId": "lux-wallet",
"complianceEmail": "compliance@lux.network", "complianceEmail": "compliance@lux.network",
"supportEmail": "hi@lux.network", "supportEmail": "hi@lux.network",
"twitter": "https://x.com/luxfi", "twitter": "https://x.com/luxfi",
+103
View File
@@ -38,6 +38,35 @@ export interface BrandTheme {
scrim?: string scrim?: string
} }
/**
* A single platform's downloadable native build. The binary `url` and the
* native-CIpublished `version` are filled per brand from `brand.json`. Store
* links (`storeUrl`) cover iOS/Android/extension distribution. A
* `checksumUrl` points at the published checksums/signature for verification.
* Any field may be absent the download page degrades to "coming soon".
*/
export interface BrandDownload {
/** Direct binary URL (dmg/exe/msi/AppImage/deb/apk). */
url?: string
/** Published version string, e.g. "1.4.2". */
version?: string
/** App/Play/extension store link (preferred for iOS/Android/extension). */
storeUrl?: string
/** URL of the published checksums + signature for "verify signature". */
checksumUrl?: string
}
/** Per-platform native download manifest, white-labeled per brand. */
export interface BrandDownloads {
mac?: BrandDownload
windows?: BrandDownload
linux?: BrandDownload
ios?: BrandDownload
android?: BrandDownload
/** Browser extension (Chrome/Firefox/Safari store links). */
extension?: BrandDownload
}
export interface BrandConfig { export interface BrandConfig {
/** Product name — used in titles, headers, splash screens */ /** Product name — used in titles, headers, splash screens */
name: string name: string
@@ -75,6 +104,24 @@ export interface BrandConfig {
walletConnectProjectId: string walletConnectProjectId: string
insightsHost: string insightsHost: string
insightsApiKey: string insightsApiKey: string
/**
* Custody backend base URL (apps/backend). lux https://wallet-api.lux.network.
* One explicit, white-labelable config value the custody client reads it
* via `getWalletApiUrl()`. Never trailing-slashed.
*/
walletApi: string
/**
* lux.id OIDC issuer for this brand. lux https://lux.id; hanzo →
* https://hanzo.id; zoo → https://zoo.id. Read via `getIamConfig()`.
*/
iamIssuer: string
/**
* OIDC client id, by the `<org>-<app>` IAM convention. lux `lux-wallet`.
* This is the backend's expected audience too.
*/
iamClientId: string
/** Per-platform native download manifest (download page reads this). */
downloads?: BrandDownloads
/** Theme color overrides for dark and light modes */ /** Theme color overrides for dark and light modes */
theme?: { theme?: {
light?: BrandTheme light?: BrandTheme
@@ -130,6 +177,9 @@ export const brand: BrandConfig = {
walletConnectProjectId: "", walletConnectProjectId: "",
insightsHost: "", insightsHost: "",
insightsApiKey: "", insightsApiKey: "",
walletApi: "",
iamIssuer: "",
iamClientId: "",
} }
export let runtimeConfig: RuntimeConfig | null = null export let runtimeConfig: RuntimeConfig | null = null
@@ -194,6 +244,23 @@ export async function loadBrandConfig(overrides?: Partial<RuntimeConfig>): Promi
brand.copyrightHolder = brand.legalEntity brand.copyrightHolder = brand.legalEntity
} }
// Custody backend + IAM are explicit per-brand values, but derive sane
// defaults from the gateway domain so a minimal brand.json still works.
// `api.lux.network` → wallet-api host `wallet-api.lux.network`, issuer
// `https://<rootDomain>.id` style is brand-specific so we only derive the
// wallet-api host here; iamIssuer/iamClientId must be set explicitly.
if (!brand.walletApi && brand.gatewayDomain) {
const root = brand.gatewayDomain.replace(/^api\./, "")
brand.walletApi = `https://wallet-api.${root}`
}
// Strip any accidental trailing slash — the custody client joins paths raw.
if (brand.walletApi) {
brand.walletApi = brand.walletApi.replace(/\/+$/, "")
}
if (brand.iamIssuer) {
brand.iamIssuer = brand.iamIssuer.replace(/\/+$/, "")
}
if (config.chains) { if (config.chains) {
brand.defaultChainId = config.chains.defaultChainId ?? brand.defaultChainId brand.defaultChainId = config.chains.defaultChainId ?? brand.defaultChainId
brand.supportedChainIds = config.chains.supported ?? brand.supportedChainIds brand.supportedChainIds = config.chains.supported ?? brand.supportedChainIds
@@ -216,6 +283,16 @@ export async function loadBrandConfig(overrides?: Partial<RuntimeConfig>): Promi
link.href = config.brand.faviconUrl link.href = config.brand.faviconUrl
} }
} }
// Overwrite the static (Lux-default) meta description so a white-label
// deploy never serves a stale brand in <head>.
if (config.brand?.description) {
const meta = document.querySelector("meta[name='description']") as HTMLMetaElement | null
if (meta) meta.content = config.brand.description
}
if (config.brand?.primaryColor) {
const tc = document.querySelector("meta[name='theme-color']") as HTMLMetaElement | null
if (tc) tc.content = config.brand.primaryColor
}
if (brand.theme && typeof window !== "undefined") { if (brand.theme && typeof window !== "undefined") {
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches
const bt = prefersDark ? brand.theme.dark : brand.theme.light const bt = prefersDark ? brand.theme.dark : brand.theme.light
@@ -276,3 +353,29 @@ export function getBootnodeRpcUrl(chainId: number): string | undefined {
export function getApiUrl(key: keyof RuntimeConfig["api"]): string { export function getApiUrl(key: keyof RuntimeConfig["api"]): string {
return runtimeConfig?.api?.[key] ?? "" return runtimeConfig?.api?.[key] ?? ""
} }
/**
* Custody backend base URL for this brand the ONLY way the web app resolves
* `apps/backend`. lux `https://wallet-api.lux.network`. White-labels set
* `brand.json:brand.walletApi`; absent, it is derived from `gatewayDomain`.
* Returns "" only when neither is configured (caller must guard).
*/
export function getWalletApiUrl(): string {
return brand.walletApi
}
/** OIDC config for this brand's lux.id IAM (issuer + client id). */
export interface IamConfig {
issuer: string
clientId: string
}
/**
* lux.id OIDC config for this brand. lux `{issuer: https://lux.id,
* clientId: lux-wallet}`. The clientId is the backend's expected audience by
* the `<org>-<app>` convention. Both come from `brand.json` (no derivation
* issuers are brand-specific TLDs).
*/
export function getIamConfig(): IamConfig {
return { issuer: brand.iamIssuer, clientId: brand.iamClientId }
}