diff --git a/.platform.yml b/.platform.yml index 46230a4b..594e52c0 100644 --- a/.platform.yml +++ b/.platform.yml @@ -5,16 +5,30 @@ # (lux-build-linux-{amd64,arm64}) and pushes to GHCR. See platform # docs/PLATFORM_CI.md. # -# Build-only: the lux web3 operator (lux.cloud) deploys the App(Wallet) backend -# via the Service CR in apps/backend/k8s/wallet-backend.yaml — not platform's -# deploy-executor (which targets the hanzo operator only). Bump the CR's -# .spec.image.tag to the built {{git.sha}} to roll. +# Build-only: the lux web3 operator (lux.cloud) deploys the App(Wallet) parts +# via the Service CRs (apps/backend/k8s/wallet-backend.yaml for the backend; +# apps/web/k8s/overlays/ kustomize for the per-brand web). Bump each +# 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: - matrix: - - { os: linux, arch: amd64 } - - { os: linux, arch: arm64 } - dockerfile: ./apps/backend/Dockerfile - context: . - image: ghcr.io/luxfi/wallet-backend - tag-pattern: "{{git.sha}}" - push: true + # Custody backend — static Go binary, distroless. + - matrix: + - { os: linux, arch: amd64 } + - { os: linux, arch: arm64 } + dockerfile: ./apps/backend/Dockerfile + context: . + image: ghcr.io/luxfi/wallet-backend + 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 diff --git a/LLM.md b/LLM.md index 268bb35f..021d4042 100644 --- a/LLM.md +++ b/LLM.md @@ -73,6 +73,60 @@ GuiProvider → QueryClientProvider → WagmiProvider → RouterProvider 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.`) +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` +(`-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./download` (client-routed; the SPA server's index.html fallback +covers it). Logo + name from the `brand` singleton; a grid of macOS/Windows/ +Linux/iOS/Android/extension reads `brand.downloads` (binary `url` or `storeUrl` ++ `version` + `checksumUrl` for "Verify signature"); a missing platform shows +"Coming soon". Binaries are published by the native CI fleet (NO GHA). + +## Tests (apps/web) — Node built-in runner + +`pnpm --dir apps/web test`. Runner = Node's stdlib `node:test` (the repo's one +way; no vitest dep) via `tools/ts-resolve.mjs`, a synchronous resolve hook that +maps bundler-style extensionless `.ts` imports for the loader (Node 22+ strips +types natively). The script covers `src/{lib,store,screens/dapps}/**/*.test.ts` +(63 tests). `src/screens/_shared/runtime.test.ts` is a JSX import probe for +`tsc --noEmit` (its own header), not a runtime test — it stays in `typecheck`. +New suites: `lib/custody.test.ts` (12 — bearer attached, no-org, exact +endpoints/bodies, idempotency required, boundary validation, error mapping), +`lib/brand.test.ts` (6 — each brand overlay → correct name/chain/logo/walletApi/ +issuer, total swap, downloads present). + ## White-label brand pattern (canonical) Brand config flows at runtime, not build time. Same pattern as @@ -217,6 +271,19 @@ brand, served from the per-brand download page on `wallet.`. The native shells live in the `luxwallet/*` org (desktop/ios/android/extension); they embed 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: ` 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//` are the SINGLE +source — the brand-swap test reads them directly. Build: `kubectl kustomize +apps/web/k8s/overlays/`. 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 1. **NEVER** write random summary files — update `LLM.md` only. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 00000000..9d0bd247 --- /dev/null +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/k8s/base/kustomization.yaml b/apps/web/k8s/base/kustomization.yaml new file mode 100644 index 00000000..0e31a0c1 --- /dev/null +++ b/apps/web/k8s/base/kustomization.yaml @@ -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: +# 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 diff --git a/apps/web/k8s/base/wallet-web.yaml b/apps/web/k8s/base/wallet-web.yaml new file mode 100644 index 00000000..5bfb3a80 --- /dev/null +++ b/apps/web/k8s/base/wallet-web.yaml @@ -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 diff --git a/apps/web/k8s/overlays/hanzo/brand.json b/apps/web/k8s/overlays/hanzo/brand.json new file mode 100644 index 00000000..839ac88b --- /dev/null +++ b/apps/web/k8s/overlays/hanzo/brand.json @@ -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": "" } +} diff --git a/apps/web/k8s/overlays/hanzo/kustomization.yaml b/apps/web/k8s/overlays/hanzo/kustomization.yaml new file mode 100644 index 00000000..64d1f49f --- /dev/null +++ b/apps/web/k8s/overlays/hanzo/kustomization.yaml @@ -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 diff --git a/apps/web/k8s/overlays/lux/brand.json b/apps/web/k8s/overlays/lux/brand.json new file mode 100644 index 00000000..7edd8bb8 --- /dev/null +++ b/apps/web/k8s/overlays/lux/brand.json @@ -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": "" } +} diff --git a/apps/web/k8s/overlays/lux/kustomization.yaml b/apps/web/k8s/overlays/lux/kustomization.yaml new file mode 100644 index 00000000..fdce9b7d --- /dev/null +++ b/apps/web/k8s/overlays/lux/kustomization.yaml @@ -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 diff --git a/apps/web/k8s/overlays/zoo/brand.json b/apps/web/k8s/overlays/zoo/brand.json new file mode 100644 index 00000000..79e1002f --- /dev/null +++ b/apps/web/k8s/overlays/zoo/brand.json @@ -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": "" } +} diff --git a/apps/web/k8s/overlays/zoo/kustomization.yaml b/apps/web/k8s/overlays/zoo/kustomization.yaml new file mode 100644 index 00000000..36d24cf0 --- /dev/null +++ b/apps/web/k8s/overlays/zoo/kustomization.yaml @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index b4bc10ca..3b07450b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,8 @@ "dev": "vite", "build": "vite build", "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": { "@hanzo/gui": "^7.0.0", diff --git a/apps/web/public/brands/hanzo.svg b/apps/web/public/brands/hanzo.svg new file mode 100644 index 00000000..cf1a153a --- /dev/null +++ b/apps/web/public/brands/hanzo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/brands/lux.svg b/apps/web/public/brands/lux.svg new file mode 100644 index 00000000..6f9b0063 --- /dev/null +++ b/apps/web/public/brands/lux.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/brands/zoo.svg b/apps/web/public/brands/zoo.svg new file mode 100644 index 00000000..f40b404c --- /dev/null +++ b/apps/web/public/brands/zoo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8c563ed0..134acc5c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,7 +10,7 @@ * are populated. The config is memoized at module init via a lazy ref so * 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 { QueryClientProvider } from "@tanstack/react-query" import { WagmiProvider } from "wagmi" @@ -18,6 +18,7 @@ import { GuiProvider } from "./components/GuiProvider" import { buildWagmiConfig } from "./config/wagmi" import { queryClient } from "./config/queryClient" import { router } from "./router" +import { useSession } from "./store/session" // 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 @@ -30,6 +31,12 @@ export default function App(): React.JSX.Element { // execution before `loadBrandConfig()` finished in some test harnesses. 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 ( diff --git a/apps/web/src/lib/brand.test.ts b/apps/web/src/lib/brand.test.ts new file mode 100644 index 00000000..04f956cd --- /dev/null +++ b/apps/web/src/lib/brand.test.ts @@ -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.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 = { + 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") +}) diff --git a/apps/web/src/lib/custody.test.ts b/apps/web/src/lib/custody.test.ts new file mode 100644 index 00000000..6c75bf9e --- /dev/null +++ b/apps/web/src/lib/custody.test.ts @@ -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() + 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, + ) +}) diff --git a/apps/web/src/lib/custody.ts b/apps/web/src/lib/custody.ts new file mode 100644 index 00000000..8291245f --- /dev/null +++ b/apps/web/src/lib/custody.ts @@ -0,0 +1,209 @@ +/** + * Custody API client — the web app's typed seam to `apps/backend` + * (`wallet-api.`), 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 ` 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 +} + +/** 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( + path: string, + init: RequestInit, + fetchImpl: typeof fetch = fetch, +): Promise { + 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 { + // POST with no body — org comes from the bearer token. + const w = await request("/v1/wallets", { method: "POST" }, fetchImpl) + return assertWallet(w) +} + +/** List the org's custody wallets. */ +export async function listWallets(fetchImpl: typeof fetch = fetch): Promise { + 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 { + if (!id) throw new CustodyError("wallet id required") + const w = await request(`/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 { + 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( + `/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() +} diff --git a/apps/web/src/lib/iam.ts b/apps/web/src/lib/iam.ts new file mode 100644 index 00000000..05c883e4 --- /dev/null +++ b/apps/web/src/lib/iam.ts @@ -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 `-` + * 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 `/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 { + 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 { + 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 + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 + } +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 60ef4dc7..00a3fa49 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -16,6 +16,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from "react-router-do import { AppShell } from "./components/AppShell" const Auth = lazy(() => import("./screens/auth")) +const Callback = lazy(() => import("./screens/auth/Callback")) const Portfolio = lazy(() => import("./screens/portfolio")) const Send = lazy(() => import("./screens/send")) const Receive = lazy(() => import("./screens/receive")) @@ -25,6 +26,7 @@ const Stake = lazy(() => import("./screens/stake")) const DApps = lazy(() => import("./screens/dapps")) const Confidential = lazy(() => import("./screens/confidential")) const Settings = lazy(() => import("./screens/settings")) +const Download = lazy(() => import("./screens/download")) function ScreenFallback(): React.JSX.Element { return ( @@ -53,10 +55,20 @@ const SCREEN_ROUTES: RouteObject[] = [ { path: "dapps/*", element: }, { path: "confidential/*", element: }, { path: "settings/*", element: }, + { path: "download/*", element: }, { path: "*", element: }, ] export const router = createBrowserRouter([ + { + // OIDC callback renders bare (no AppShell) — it runs before login. + path: "/auth/callback", + element: ( + }> + + + ), + }, { path: "/", element: ( diff --git a/apps/web/src/screens/auth/Callback.tsx b/apps/web/src/screens/auth/Callback.tsx new file mode 100644 index 00000000..3df3a80f --- /dev/null +++ b/apps/web/src/screens/auth/Callback.tsx @@ -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(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 ( +
+ {brand.logoUrl ? ( + + ) : null} + {error ? ( + <> +

Sign-in failed

+

{error}

+ + Back to {brand.walletName || "wallet"} + + + ) : ( + <> +

Signing you in…

+

Completing {brand.name || "lux.id"} authentication.

+ + )} +
+ ) +} + +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 } diff --git a/apps/web/src/screens/download/Download.tsx b/apps/web/src/screens/download/Download.tsx new file mode 100644 index 00000000..d55b25bb --- /dev/null +++ b/apps/web/src/screens/download/Download.tsx @@ -0,0 +1,168 @@ +/** + * Download screen — the per-brand native-download HOST surface served at + * `wallet./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 ( +
+
+ {brand.logoUrl ? ( + + ) : null} +
+

Download {name}

+

+ Self-custodial {brand.name || ""} wallet for every platform. Verify each + build's signature before installing. +

+
+
+ +
    + {PLATFORMS.map((p) => ( + + ))} +
+ + +
+ ) +} + +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 ( +
  • +
    + {def.label} + {dl?.version ? v{dl.version} : null} +
    + {def.format} + + {available ? ( + + {def.action} + + ) : ( + + Coming soon + + )} + + {dl?.checksumUrl ? ( + + Verify signature + + ) : null} +
  • + ) +} + +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)" } diff --git a/apps/web/src/screens/download/index.tsx b/apps/web/src/screens/download/index.tsx new file mode 100644 index 00000000..075c012e --- /dev/null +++ b/apps/web/src/screens/download/index.tsx @@ -0,0 +1,21 @@ +/** + * `/download` route — per-brand native download host. Single screen. + * + * Mounted under `` from `apps/web/src/router.tsx`: + * + * import DownloadRoutes from "./screens/download" + * … + * } /> + * + * 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 ( + + } /> + + ) +} diff --git a/apps/web/src/screens/signing/SigningModal.tsx b/apps/web/src/screens/signing/SigningModal.tsx index f20c4750..ed29e5ba 100644 --- a/apps/web/src/screens/signing/SigningModal.tsx +++ b/apps/web/src/screens/signing/SigningModal.tsx @@ -22,20 +22,24 @@ import { type DecodedCall } from "../../lib/abi" import RiskIndicator, { assessRisk, type RiskAssessment } from "./RiskIndicator" import TxSummary from "./TxSummary" import { useSigningStore } from "../../store/signing" +import { useSession } from "../../store/session" export default function SigningModal() { const pending = useSigningStore((s) => s.pending) const confirm = useSigningStore((s) => s.confirm) const reject = useSigningStore((s) => s.reject) const cancel = useSigningStore((s) => s.cancel) + const resolveWith = useSigningStore((s) => s.resolveWith) const [decoded, setDecoded] = useState(null) const [risk, setRisk] = useState(null) + const [signing, setSigning] = useState(false) - // Reset decode state on every new tx. + // Reset decode + signing state on every new tx. useEffect(() => { setDecoded(null) setRisk(null) + setSigning(false) }, [pending?.from, pending?.to, pending?.data, pending?.value]) // Re-assess whenever decode result changes. @@ -54,6 +58,33 @@ export default function SigningModal() { return () => window.removeEventListener("keydown", onKey) }, [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 return ( @@ -90,11 +121,11 @@ export default function SigningModal() {
    - -
    diff --git a/apps/web/src/store/session.ts b/apps/web/src/store/session.ts new file mode 100644 index 00000000..cba2757b --- /dev/null +++ b/apps/web/src/store/session.ts @@ -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 + /** Begin the lux.id OIDC login (navigates away). */ + login: () => Promise + /** Finish login on the /auth/callback route, then load wallets. */ + completeCallback: (search: string) => Promise + /** Clear the lux.id session and custody state. */ + logout: () => void + /** Create a new MPC custody wallet for the org and select it. */ + createWallet: () => Promise + /** Reload the org's wallets from the backend. */ + loadWallets: () => Promise + /** 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 +} + +export const useSession = create((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) +} diff --git a/apps/web/src/store/signing.ts b/apps/web/src/store/signing.ts index da03a7ce..34b56fac 100644 --- a/apps/web/src/store/signing.ts +++ b/apps/web/src/store/signing.ts @@ -52,12 +52,28 @@ export interface UnsignedTx { summary?: string /** dApp metadata, present only when origin === "dapp". */ 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 { approved: boolean - /** Set when approved=false: "rejected" | "closed" | "timeout". */ - reason?: "rejected" | "closed" | "timeout" + /** Set when approved=false: "rejected" | "closed" | "timeout" | "error". */ + 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 { @@ -72,6 +88,12 @@ export interface SigningState { reject: () => void /** Generic close — used by ESC, backdrop click, beforeunload. */ 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((set, get) => ({ @@ -111,6 +133,13 @@ export const useSigningStore = create((set, get) => ({ p.resolve({ approved: false, reason }) set({ pending: null }) }, + + resolveWith: (result) => { + const p = get().pending + if (!p) return + p.resolve(result) + set({ pending: null }) + }, })) /** diff --git a/apps/web/tools/ts-resolve.mjs b/apps/web/tools/ts-resolve.mjs new file mode 100644 index 00000000..6703858c --- /dev/null +++ b/apps/web/tools/ts-resolve.mjs @@ -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) + }, +}) diff --git a/pkgs/brand/brand.json b/pkgs/brand/brand.json index 8e5b7893..f04c4181 100644 --- a/pkgs/brand/brand.json +++ b/pkgs/brand/brand.json @@ -15,6 +15,9 @@ "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", diff --git a/pkgs/brand/src/index.ts b/pkgs/brand/src/index.ts index 34969327..11fae7eb 100644 --- a/pkgs/brand/src/index.ts +++ b/pkgs/brand/src/index.ts @@ -38,6 +38,35 @@ export interface BrandTheme { scrim?: string } +/** + * A single platform's downloadable native build. The binary `url` and the + * native-CI–published `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 { /** Product name — used in titles, headers, splash screens */ name: string @@ -75,6 +104,24 @@ export interface BrandConfig { walletConnectProjectId: string insightsHost: 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 `-` 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?: { light?: BrandTheme @@ -130,6 +177,9 @@ export const brand: BrandConfig = { walletConnectProjectId: "", insightsHost: "", insightsApiKey: "", + walletApi: "", + iamIssuer: "", + iamClientId: "", } export let runtimeConfig: RuntimeConfig | null = null @@ -194,6 +244,23 @@ export async function loadBrandConfig(overrides?: Partial): Promi 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://.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) { brand.defaultChainId = config.chains.defaultChainId ?? brand.defaultChainId brand.supportedChainIds = config.chains.supported ?? brand.supportedChainIds @@ -216,6 +283,16 @@ export async function loadBrandConfig(overrides?: Partial): Promi link.href = config.brand.faviconUrl } } + // Overwrite the static (Lux-default) meta description so a white-label + // deploy never serves a stale brand in . + 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") { const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches 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 { 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 `-` convention. Both come from `brand.json` (no derivation — + * issuers are brand-specific TLDs). + */ +export function getIamConfig(): IamConfig { + return { issuer: brand.iamIssuer, clientId: brand.iamClientId } +}