feat: bridge UI part is motly finished

This commit is contained in:
venuswhispers
2026-05-22 22:05:31 +02:00
parent f4c6a47e51
commit d4ced5d3d5
126 changed files with 11062 additions and 391 deletions
+113 -49
View File
@@ -4,10 +4,10 @@
|---|---|
| Repository | `github.com/luxfi/bridge` |
| Owner | Lux Industries, Inc. |
| Author of doc | (to be filled) |
| Author of doc | Jackson Mori |
| Date | 2026-05-22 |
| Status | Draft for review (R4 — corrected against working tree) |
| Supersedes | Prior R3 draft (contained stale tree + diagram claims) |
| Status | Draft for review (R5 — refresh after R3 merge into `whispers/bridgev2`) |
| Supersedes | R4 (pre-merge dep layout + pre-doc-cleanup; treated R3 client-side MPC as pending). R5 re-pins against `pkg/bridge` v1.0.3 + the now-merged client-side MPC / cosigner stack, and reflects the deletion of the legacy `docs/*.md` set. |
---
@@ -15,13 +15,13 @@
`bridge.lux.network` is Lux's cross-chain bridge UI. Historically it was a monolithic frontend bound to an older JavaScript API + standalone MPC service. Both have been replaced:
- The **API backend** is `app/server/` (Express + Prisma) and is live at `api.bridge.lux.network`.
- The **API backend** is `app/server/` (Express + Prisma) and is live at `api.bridge.lux.network`. It also holds the cosigner glue (`src/domain/cosigners.ts`) that talks to external custodians (Utila, Fireblocks) on behalf of tenants — secret material never leaves the server.
- The **MPC layer** is provided by `pkg/threshold/` (`@luxfi/threshold` SDK), deployed in two modes:
- **Private MPC** — Lux treasury cluster, collects fees. Config under `config/mpc/config.yaml`.
- **Public MPC** — `m-chain`, validator-powered, permissionless, deployed via `k8s/mpc-deployment.yaml`.
- The **frontend** is packaged as a publishable white-label SDK `@luxfi/bridge` (`pkg/bridge/`). The bridge UI is **inlined** under `pkg/bridge/src/app/` — there is no longer a workspace `@luxbridge/app-v3` dependency. Each tenant (Lux, Zoo, Hanzo, ) consumes the SDK from a thin host shell plus its own `@<org>/brand` package.
This document is the authoritative project requirements set. The prior `requirements.txt` working-notes file is no longer at the repo root.
This document is the authoritative project requirements set. There is no `requirements.txt` working-notes file in the repo (none has existed in git history); REQUIREMENTS.md is the canonical record.
## 2. Goals
@@ -62,11 +62,14 @@ The following are **explicitly out of scope** for this initiative and must not c
bridge/
├── app/
│ ├── bridge/ @luxbridge/lux-tenant v1.0.0 — canonical Lux tenant.
│ │ Vite + React, ~120 LOC effective (bridge.config.ts ~88
│ │ lines + main.tsx + index.html). Consumes
│ │ @luxfi/bridge + @luxfi/brand + @luxfi/logo.
│ │ Vite + React, thin shell (bridge.config.ts ~88 lines
│ │ + main.tsx + index.html). Consumes @luxfi/bridge +
│ │ @luxfi/brand + @luxfi/logo. See §5.5 for the
│ │ transitive deps it currently re-declares.
│ ├── explorer/ Bridge explorer app.
│ └── server/ Bridge API (Express + Prisma). Live at api.bridge.lux.network.
│ Holds cosigner glue (src/domain/cosigners.ts +
│ __tests__/cosigners.test.ts + cosigners-fireblocks.test.ts).
├── pkg/
│ ├── bridge/ @luxfi/bridge — PUBLIC SDK (the canonical consumer entry point).
│ │ ├── src/
@@ -78,21 +81,36 @@ bridge/
│ │ │ ├── mount.ts Imperative DOM-mount helper.
│ │ │ ├── config.ts getConfig/setConfig/applyBrandMetadata.
│ │ │ ├── types.ts BridgeConfig + BrandConfig/AuthConfig/
│ │ │ │ KMSConfig/WalletConfig/MPCConfig blocks.
│ │ │ ├── app/ Inlined bridge UI (BridgeApp.tsx + components/
│ │ │ │ hooks/ lib/ styles/).
│ │ │ └── __tests__/ Vitest specs.
│ │ ├── package.json v1.0.1, scope public, registry npmjs.org.
│ │ └── README.md Consumer-facing docs (kept in sync through R2/R3).
│ │ │ │ KMSConfig/WalletConfig/MPCConfig blocks,
│ │ │ │ plus BridgeUtilaConfig / BridgeFireblocksConfig
│ │ │ │ cosigner blocks (nested under MPCConfig).
│ │ │ ├── app/ Inlined bridge UI.
│ │ │ │ ├── BridgeApp.tsx
│ │ │ │ ├── components/ AssetInput, Card, ChainSelector, Header,
│ │ │ │ │ SwapForm, TransferStatus, WalletConnect.
│ │ │ │ ├── hooks/ useSwap, useTransfers, useWallet.
│ │ │ │ ├── lib/ assets, bridge-api, chains, format,
│ │ │ │ │ mpc-session (R3 client-side MPC),
│ │ │ │ │ wagmi-config.
│ │ │ │ └── styles/
│ │ │ └── __tests__/ Vitest specs: bridge-api, config,
│ │ │ mpc-session, useSwap, useTransfers,
│ │ │ useTransfers-cosigners, useWallet,
│ │ │ wagmi-config.
│ │ ├── package.json v1.0.3, scope public, registry npmjs.org.
│ │ └── README.md Consumer-facing docs (kept in sync through R2/R3/R5).
│ ├── core/ Shared core utilities.
│ ├── settings/ Network + asset registry (private, unpublished).
│ ├── threshold/ @luxfi/threshold — MPC SDK. Consumed by app/server.
│ ├── threshold/ @luxfi/threshold — MPC SDK. Consumed by app/server AND
│ │ now by pkg/bridge (R3 client-side MPC session helper).
│ ├── ui/ Shared UI primitives.
│ └── utila/ Utila cosigner helpers (declarative, layered).
├── docs/ Nextra site (pages/, theme.config.tsx, next.config.js,
│ package.json) — not a flat bag of md files.
│ Loose top-level *.md (BRIDGE-STATUS, LOCAL-SETUP, etc.)
│ predate the Nextra migration and are being folded in.
│ package.json + content/docs/*.mdx). The legacy loose
│ top-level *.md set (BRIDGE-STATUS, LOCAL-SETUP,
│ DEPLOYMENT, CI-CD-DOCKER-IMAGES, the MPC-* set) was
│ deleted in the merge from main (R5). Only LLM.md
│ and LUX-ID-INTEGRATION.md remain at docs/ root.
├── config/mpc/config.yaml Private MPC cluster config.
├── k8s/mpc-deployment.yaml Public MPC (m-chain) deployment manifests.
└── REQUIREMENTS.md This document.
@@ -102,8 +120,9 @@ Notable removals from prior drafts:
- `app/bridge3/` does **not** exist. The two non-tenant apps are `app/explorer/` and `app/server/`.
- `pkg/bridge/src/luxbridge-app.d.ts` does **not** exist (the workspace-dep shim was removed when the UI was inlined).
- Root `requirements.txt` is **not** present.
- Root `requirements.txt` is **not** present (and never has been in git history).
- The legacy `@luxbridge/app` v2.0.0 directory was retired; `app/bridge/` is now the new, thin `@luxbridge/lux-tenant`.
- The legacy `docs/{BRIDGE-STATUS,LOCAL-SETUP,DEPLOYMENT,CI-CD-DOCKER-IMAGES,MIGRATION-TO-GO-MPC,MPC-GO-INTEGRATION,MPC-INTEGRATION-COMPLETE,MPC-MODERNIZATION-SUMMARY}.md` were deleted in the merge from main; their content has either been folded into the Nextra site or retired outright.
### 5.2 Component status
@@ -115,8 +134,9 @@ Notable removals from prior drafts:
| Private MPC (treasury fees) | Live | same code, separate cluster, `config/mpc/config.yaml` |
| r-chain relay (optional helper) | Live (in repo) | `app/server/cmd/`, hooks |
| o-chain oracle (optional helper) | Live (in repo) | upstream `github.com/luxfi/bridge` |
| `@luxfi/bridge` SDK (`pkg/bridge/`) | v1.0.1 on `whispers/bridgev2`, UI inlined | This repo |
| Lux tenant app (`app/bridge/`) | `@luxbridge/lux-tenant` v1.0.0 — ~120 LOC | This repo |
| `@luxfi/bridge` SDK (`pkg/bridge/`) | **v1.0.3** on `whispers/bridgev2`. UI inlined; **client-side MPC session merged**; declarative cosigner blocks (`utila`, `fireblocks`) exposed via `BridgeMPCConfig`. | This repo |
| Server-side cosigner glue | **Merged** — `app/server/src/domain/cosigners.ts` + Fireblocks/native tests. End-to-end soak against a real Utila/Fireblocks tenant still pending. | `app/server/` |
| Lux tenant app (`app/bridge/`) | `@luxbridge/lux-tenant` v1.0.0 — thin shell | This repo |
| Zoo tenant app | Pending — needs new tenant repo (see §10) | TBD |
| Liquidity backend wire-up | Later | After Lux/Zoo tenants ship |
@@ -155,7 +175,8 @@ interface BridgeConfig {
auth?: BridgeAuthConfig // Hanzo IAM (OIDC) block, mirrors <Exchange auth={…}/>
kms?: BridgeKMSConfig // Tenant KMS endpoint
wallet?: BridgeWalletConfig // WalletConnect project id + chain set
mpc?: BridgeMPCConfig // publicUrl (m-chain) + optional privateUrl + protocol
mpc?: BridgeMPCConfig // optional publicUrl + privateUrl + protocol
// + optional utila / fireblocks cosigner blocks
clientId?: string // @deprecated — use auth.clientId
iamOrg?: string // @deprecated — use auth.orgSlug
}
@@ -163,25 +184,54 @@ interface BridgeConfig {
`BridgeMPCConfig.protocol` accepts classical (`cggmp21`, `frost`, `bls`, `doerner`) and post-quantum lattice-based (`pulsar`, `corona`, `magnetar`) threshold-signature identifiers. All protocols are leaderless / permissionless-safe by design.
`BridgeMPCConfig` also accepts optional `utila?: BridgeUtilaConfig` and `fireblocks?: BridgeFireblocksConfig` cosigner blocks (the types live in `pkg/bridge/src/types.ts` and are reachable through `BridgeMPCConfig`). These are **declarative only**: the SDK carries the tenant-visible identifiers (Utila org id / vault id, Fireblocks API key id / vault account id), and the bridge backend (`app/server/src/domain/cosigners.ts`) holds the matching secrets and completes the cosign. The cosigner layer sits *on top of* native Lux MPC — the backend enforces 2-of-2 (native threshold + external cosigner) before releasing settlement.
`BridgeMPCConfig.publicUrl` is **optional**: tenants running pure-external custody (utila or fireblocks set, no native threshold) may omit it. The recommended layered mode keeps it set so native MPC remains the primary signer.
`BrandConfig` keeps the original shape: `name`, optional `logoUrl`, `faviconUrl`, `primaryColor`, `secondaryColor`, `supportEmail`, `docsUrl`.
### 5.4 SDK dependency layout (verified against `pkg/bridge/package.json` v1.0.1)
### 5.4 SDK dependency layout (verified against `pkg/bridge/package.json` v1.0.3)
Direct prod `dependencies`: `@luxfi/brand`, `@hanzo/gui`, `@hanzogui/core`, `@hanzogui/helpers`, `@hanzogui/constants`, `@hanzogui/themes`, `@hanzogui/animate`, `@hanzogui/animate-presence`, `@hanzogui/button`, `@hanzogui/input`.
Direct prod `dependencies` (15 total):
`peerDependencies` (host-supplied): `react`, `react-dom`, plus the ~20 leaf `@hanzogui/*` components (popover, select, dialog, card, text, stacks, spinner, toast, tabs, label, form, list-item, separator, tooltip, avatar, image, group, progress, visually-hidden, portal, sheet) — all marked `optional` via `peerDependenciesMeta`. This lets host apps tree-shake the parts of the bridge UI they don't render.
- **UI primitives (`@hanzo` / `@hanzogui`)**: `@hanzo/gui`, `@hanzogui/core`, `@hanzogui/helpers`, `@hanzogui/constants`, `@hanzogui/themes`, `@hanzogui/config-default`, `@hanzogui/animate`, `@hanzogui/animate-presence`, `@hanzogui/button`, `@hanzogui/input`.
- **Brand**: `@luxfi/brand` (`^1.0.0`, published on npm).
- **Client-side MPC**: `@luxfi/threshold` (`workspace:*`) — drives `pkg/bridge/src/app/lib/mpc-session.ts`. This was added in the R3 merge; it's the only `workspace:*` runtime dep in the SDK.
- **Web3 / data**: `@tanstack/react-query` `5.90.20`, `viem` `2.30.5`, `wagmi` `2.15.5`. All pinned exactly.
`devDependencies`: `typescript`, `vitest`, `@types/react`, `@types/react-dom`.
`peerDependencies` (host-supplied): `react` (`>=18`), `react-dom` (`>=18`), plus 21 leaf `@hanzogui/*` components (popover, select, dialog, card, text, stacks, spinner, toast, tabs, label, form, list-item, separator, tooltip, avatar, image, group, progress, visually-hidden, portal, sheet) — all marked `optional` via `peerDependenciesMeta`. This lets host apps tree-shake the parts of the bridge UI they don't render.
The SDK **does not** workspace-depend on `@luxbridge/app-v3` (it does not exist) or on `@luxfi/threshold` directly. Today, threshold MPC is exercised from `app/server/` only; the SDK ships an `mpc?: BridgeMPCConfig` block so it can carry cluster URLs + protocol id forward, but does not import the threshold client itself.
`devDependencies`: `typescript`, `vitest`, `@testing-library/react`, `happy-dom`, `jsdom`, `@types/react`, `@types/react-dom`.
> R3 design note: a follow-on change is in flight to add a client-side `mpc-session.ts` under `pkg/bridge/src/app/lib/` that imports `ThresholdClient` directly, plus a declarative `mpc.utila` / `mpc.fireblocks` cosigner layer. That work is **not yet merged into the working tree being documented here** — when it lands, §5.4 must be re-pinned and the SDK version bumped accordingly.
The SDK **does not** workspace-depend on any `@luxbridge/app-*` package (those were retired with the inline UI). The single `workspace:*` runtime dep is `@luxfi/threshold`, which is the in-repo T-Chain SDK; it has no transitive workspace deps of its own.
### 5.5 Tenant shape (verified against `app/bridge/package.json`)
`@luxbridge/lux-tenant` v1.0.0, `private: true`. Dependencies: `@luxfi/brand ^1.0.0`, `@luxfi/bridge workspace:*`, `@luxfi/logo ^1.0.0`, plus `react` / `react-dom` (catalog) and `react-native-web` for RN-web parity. Scripts: `dev` / `build` / `preview` / `typecheck` against Vite + tsc.
`@luxbridge/lux-tenant` v1.0.0, `private: true`.
Runtime config is supplied via `window.__ENV` (templated from `BRIDGE_*` env at container boot) with an `import.meta.env.VITE_*` build-time fallback for local dev. Tenants do not pass chain configs — they pass `apiHost` + `env` + `brand` + optional auth/kms/wallet/mpc blocks.
Dependencies fall into two layers:
- **Tenant-owned**: `@luxfi/brand ^1.0.0`, `@luxfi/bridge workspace:*`, `@luxfi/logo ^1.0.0`, plus `react` / `react-dom` (catalog) and `react-native-web ^0.19.0` for RN-web parity.
- **Pinned transitively** (workaround until §9.3.1 ships a compiled `dist/`): `@hanzogui/config-default ^7.0.0`, `@luxfi/threshold workspace:*`, `@tanstack/react-query 5.90.20`, `viem 2.30.5`, `wagmi 2.15.5`. The SDK is consumed at the source level (its `main`/`types` still point at `src/*`), so Vite's dep-scanner walks these from the tenant's `node_modules/` rather than from the SDK package's nested `node_modules/`. Once the SDK builds to `dist/` (Phase 3.1) and externalizes web3 + UI primitives, tenants will no longer need to re-declare these.
Scripts: `dev` / `build` / `preview` / `typecheck` against Vite + tsc. The Vite config aliases `react-native` → `react-native-web` and dedupes `react` / `react-dom` / `react-native-web`; downstream tenants must replicate this aliasing — see `pkg/bridge/README.md` ("Consuming this SDK from a Vite app").
Runtime config is supplied via `window.__ENV` (templated from `BRIDGE_*` env at container boot, served from `/__ENV.js`) with an `import.meta.env.VITE_*` build-time fallback for local dev. Tenants do not pass chain configs — they pass `apiHost` + `env` + `brand` + optional auth/kms/wallet/mpc blocks. Brand defaults are read from `@luxfi/brand/brand.json` so a brand bump propagates without string copies.
### 5.6 R3 client-side MPC integration (merged)
The R3 work that R4 listed as "in flight" is now in the working tree. Summary of what landed:
| Artifact | Location | Purpose |
|---|---|---|
| `mpc-session.ts` | `pkg/bridge/src/app/lib/mpc-session.ts` | Wraps `@luxfi/threshold`'s `ThresholdClient` so the UI can surface real session progress (sessionId / status / signature) instead of a setTimeout placeholder. Initiates sessions only — no key material on the client. |
| `bridge-api.ts` | `pkg/bridge/src/app/lib/bridge-api.ts` | Typed bridge-server REST client. |
| `wagmi-config.ts` | `pkg/bridge/src/app/lib/wagmi-config.ts` | Per-tenant wagmi + WalletConnect config derived from `BridgeWalletConfig`. |
| `BridgeUtilaConfig` / `BridgeFireblocksConfig` | `pkg/bridge/src/types.ts` | Declarative tenant-visible identifiers for external cosigners. Reachable via `BridgeMPCConfig.utila` / `.fireblocks`. |
| `app/server/src/domain/cosigners.ts` (+ tests) | server side | Holds the actual Utila / Fireblocks secret material and performs the cosign. Browser never sees the secret. |
| Vitest specs | `pkg/bridge/src/__tests__/` | `mpc-session.test.ts`, `useTransfers-cosigners.test.tsx`, `bridge-api.test.ts`, `wagmi-config.test.ts`, `useSwap.test.tsx`, `useTransfers.test.tsx`, `useWallet.test.tsx` |
**Invariant (must not regress):** secret material for external cosigners lives on `app/server/` only. The SDK declares the tenant-public identifiers; it must never grow a code path that holds a Utila / Fireblocks secret on the client.
## 6. Target Architecture
@@ -192,14 +242,17 @@ Tenant app (~120 LOC) --> | exports: |
imports SDK + brand | Bridge, mountBridge |
e.g.: | BridgeConfig + sub-blocks|
@luxfi/bridge | UI inlined at src/app/ |
@luxfi/brand --> | no workspace lazy-load |
@luxfi/logo +-----------------------------+
@luxfi/brand --> | client-side MPC via |
@luxfi/logo | @luxfi/threshold |
+-----------------------------+
|
v
+-----------------------------+
| app/server (Express + |
| Prisma) — Bridge API |
| api.bridge.lux.network |
| + cosigners.ts (utila / |
| fireblocks secrets) |
+-----------------------------+
|
+--------------------+--------------------+
@@ -231,8 +284,8 @@ Status of original Phase 1 line items:
| 1.1 | Bridge UI compiles and runs against `apiHost: api.bridge.lux.network` in `env: mainnet`. | ✅ — UI is inlined under `pkg/bridge/src/app/` and consumed by `app/bridge/`. |
| 1.2 | Commit the in-flight SDK edits that resolved the `@luxbridge/app` workspace cycle. | ✅ — see Phase 1.5 history note in `pkg/bridge/src/Bridge.tsx`. |
| 1.3 | Run the SDK end-to-end against Lux testnet. | 🟡 — verify on current branch before mainnet cutover. |
| 1.4 | Deploy the Lux tenant app to `bridge.lux.network`. | 🟡 — `app/bridge/` shape is final; deploy pipeline TBC. |
| 1.5 | Refresh `docs/BRIDGE-STATUS.md` and `docs/LOCAL-SETUP.md` to point at `app/bridge/`. | 🟡 — these files predate Nextra migration; fold them into `docs/pages/` and remove stale legacy mentions. |
| 1.4 | Deploy the Lux tenant app to `bridge.lux.network`. | 🟡 — `app/bridge/` shape is final (`Dockerfile`, `docker-entrypoint.sh`, `__ENV.js` template all merged); deploy pipeline TBC. |
| 1.5 | Refresh / fold the legacy `docs/*.md` (`BRIDGE-STATUS`, `LOCAL-SETUP`, etc.). | ✅ — the loose top-level set was deleted in the merge from main; only `docs/LLM.md` and `docs/LUX-ID-INTEGRATION.md` remain at docs root, and active docs now live under `docs/content/docs/`. |
| 1.6 | Keep `pkg/bridge/README.md` accurate (it was updated through R2/R3). | ✅ — consumer-facing README documents `Bridge`, `mountBridge`, `applyBrandMetadata`, `getConfig`, `setConfig`. |
**Acceptance criteria** (recheck before sign-off):
@@ -240,7 +293,7 @@ Status of original Phase 1 line items:
- A user can reach `bridge.lux.network`, connect a wallet, get a quote on Lux testnet, and complete a bridge transaction signed by an m-chain MPC quorum.
- Internal devs can run `pnpm dev` from `app/bridge/` against local `app/server/` and complete the same flow.
- Treasury fee collection through the private MPC cluster fires on a real transfer.
- `pkg/bridge/README.md` matches the current API surface in §5.3.
- `pkg/bridge/README.md` matches the current API surface in §5.3 (including the cosigner block additions from R3).
## 8. Phase 2 — Multi-Tenant Embed
@@ -254,7 +307,7 @@ Status of original Phase 1 line items:
| 2.2 | Publish `@zooai/brand` and `@zooai/logo` if not yet published; confirm `@hanzoai/brand` if needed. | Out-of-tree, coordinate with brand owners. |
| 2.3 | Add an `@luxfi/bridge` mount inside `luxfi/exchange/apps/web/src/main.tsx` (mirroring the existing `@luxfi/exchange` SDK pattern). | Cross-repo. |
| 2.4 | Same for `zooai/exchange/apps/web/src/main.tsx`. | Cross-repo. |
| 2.5 | Confirm `BrandConfig` + the auth/kms/wallet/mpc blocks cover everything Zoo + Hanzo need without code changes in `pkg/bridge/`. If anything must be hardcoded, surface it as a new optional config field with semver-minor bump. | Drives any v1.1.x of the SDK. |
| 2.5 | Confirm `BrandConfig` + the auth/kms/wallet/mpc blocks (including the cosigner sub-blocks) cover everything Zoo + Hanzo need without code changes in `pkg/bridge/`. If anything must be hardcoded, surface it as a new optional config field with semver-minor bump. | Drives any v1.1.x of the SDK. |
**Acceptance criteria**
@@ -270,12 +323,12 @@ Status of original Phase 1 line items:
| # | Task | Notes |
|---|---|---|
| 3.1 | Switch `pkg/bridge/package.json` `main`/`types` from `src/*.ts` to compiled `dist/*.js` + `.d.ts` outputs. Add a real `build` step that emits both. | Today `main`/`types` point at `src/*` and `build` is `tsc --noEmit`. |
| 3.2 | Externalize React + react-dom + `@luxfi/brand` from the published bundle. | Already peer-deps; verify after build step lands. |
| 3.3 | Confirm there are no `workspace:*` deps left in the publishable surface. | UI is inlined; `@hanzogui/*` are already on npm; `@luxfi/brand` is already published `^1.0.0`. |
| 3.1 | Switch `pkg/bridge/package.json` `main`/`types` from `src/*.ts` to compiled `dist/*.js` + `.d.ts` outputs. Add a real `build` step that emits both. | Today `main`/`types` point at `src/*` and `build` is `tsc --noEmit`. This is what forces tenants to re-declare web3 / `@hanzogui/config-default` / `@luxfi/threshold` in their own `package.json` (§5.5). |
| 3.2 | Externalize React + react-dom + `@luxfi/brand` (and ideally `wagmi` / `viem` / `@tanstack/react-query`) from the published bundle. | React / react-dom are already peer-deps; verify the new web3 trio after the build step lands. |
| 3.3 | Confirm there are no `workspace:*` deps left in the publishable surface. | `@luxfi/threshold` is currently a `workspace:*` runtime dep — it must be published to npm (or vendored / re-exported through `dist/`) before SDK publication. |
| 3.4 | Publish `@luxfi/bridge` v1.x → npm under the `@luxfi` scope (public access). | `pnpm pub` from `pkg/bridge/`. |
| 3.5 | Land the R3 client-side MPC integration (`mpc-session.ts` + `mpc.utila` / `mpc.fireblocks` declarative cosigner layer) and bump SDK accordingly. | This is the work referenced in §5.4 R3 design note. Secret material stays on `app/server/`. |
| 3.6 | Document standalone-backend mode for : how to point `apiHost` at a non-Lux endpoint, what server API contract `app/server/` exposes, what's pluggable. | New doc under `docs/pages/`. |
| 3.5 | ~~Land the R3 client-side MPC integration~~ — **done** (R5). `mpc-session.ts`, `bridge-api.ts`, `wagmi-config.ts`, `BridgeUtilaConfig` / `BridgeFireblocksConfig`, server-side `cosigners.ts` + tests are all merged; SDK bumped to v1.0.3. Remaining sub-task: end-to-end soak against a real Utila and a real Fireblocks tenant on testnet. | Secret material stays on `app/server/` — invariant must not regress. |
| 3.6 | Document standalone-backend mode for : how to point `apiHost` at a non-Lux endpoint, what server API contract `app/server/` exposes, what's pluggable. | New doc under `docs/content/docs/`. |
| 3.7 | Confirm can render their own securities (AAPL, etc.) via the existing tokens/assets registry (`@luxbridge/settings`) without forking. If not, add an injection point. | May surface a `tokens?` field on `BridgeConfig`. |
**Acceptance criteria**
@@ -283,6 +336,7 @@ Status of original Phase 1 line items:
- `npm install @luxfi/bridge` from a fresh project succeeds; the Quick Start in `pkg/bridge/README.md` produces a running bridge in <5 minutes.
- A third party can stand up a bridge with their own `apiHost` and `BrandConfig` without modifying SDK source.
- A new standalone-backend doc clearly describes the path.
- The cosigner layer has been exercised end-to-end against at least one real external custodian on testnet.
## 10. Outstanding Tenant Work
@@ -292,24 +346,34 @@ The earlier `zoo/bridge` proxy-Go code and `lux-shim`/`zoo/bridge/app` overlay d
### 10.2 SDK working state
`pkg/bridge/` is checked in at v1.0.1 on `whispers/bridgev2`. Recent merges (see `git log`): `phase3-r2 stack (inline UI + cleanup + Tamagui swap)`, `phase1 SDK config blocks (auth/kms/wallet/mpc + PQ protocol union)`, `wire @luxfi/logo as canonical Lux logo source`. The R3 client-side MPC + cosigner work referenced in §5.4 / §9.3.5 is the next merge in flight.
`pkg/bridge/` is at **v1.0.3** on `whispers/bridgev2`. Recent merges (see `git log`):
- `phase3-r2 stack (inline UI + cleanup + Tamagui swap)` — inlined the bridge UI.
- `phase1 SDK config blocks (auth/kms/wallet/mpc + PQ protocol union)` — added the optional config blocks.
- `wire @luxfi/logo as canonical Lux logo source` — brand wiring.
- `merge: swarm/integration → main` (phase1-3 + `@luxfi/logo`) — bundled the R3 work into main.
- `merge: main → whispers/bridgev2` (R5) — pulled the **R3 client-side MPC + cosigner stack** into this working tree: `mpc-session.ts`, `bridge-api.ts`, `wagmi-config.ts`, `BridgeUtilaConfig` / `BridgeFireblocksConfig`, `app/server/src/domain/cosigners.ts` + tests, plus tenant deploy artefacts (`Dockerfile`, `docker-entrypoint.sh`, `__ENV.js` template).
- `chore: env has been set up successfully` — environment bring-up after the R3 merge: added the transitively-required deps to `app/bridge/package.json` (see §5.5) and a `prepare` script to `pkg/threshold/` so `dist/` builds on `pnpm install`.
The next outstanding SDK work is **Phase 3.1** (compile to `dist/`) so external consumers can install the package without source-level workarounds, and **Phase 3.5 remainder** (e2e soak of the cosigner layer against a real external custodian).
## 11. Risks & Open Questions
| # | Item | Risk / impact | Owner |
|---|---|---|---|
| R1 | Build step still emits no `dist/`. Publishing today would ship `src/*.ts` directly — works for workspace consumers, breaks downstream type resolution for non-pnpm users. | Blocks Phase 3.1/3.4. | SDK |
| R2 | Top-level `docs/*.md` files (`BRIDGE-STATUS.md`, `LOCAL-SETUP.md`, the MPC-* set) predate the Nextra migration and still mention legacy paths. New contributors will follow stale instructions. | Wasted onboarding cycles. | Docs / Phase 1.5 |
| R3 | The R3 cosigner layer (`mpc.utila` / `mpc.fireblocks`) is purely declarative in the SDK type. Secrets must stay on `app/server/`; a future implementer must not move them client-side by accident. | Security regression. | SDK + server, Phase 3.5 |
| R1 | Build step still emits no `dist/`. Publishing today would ship `src/*.ts` directly — works for workspace consumers (since pnpm hoists transitive deps locally), breaks downstream type resolution for non-pnpm users and forces tenants to re-declare web3 deps. | Blocks Phase 3.1 / 3.4. | SDK |
| R2 | `@luxfi/threshold` is a `workspace:*` runtime dep of `@luxfi/bridge`. Publishing `@luxfi/bridge` without first publishing (or vendoring) threshold will produce an unresolvable install for external consumers. | Blocks Phase 3.3 / 3.4. | SDK |
| R3 | The cosigner layer (`mpc.utila` / `mpc.fireblocks`) is declarative on the SDK side and secret-holding on the server side. A future implementer must not move secret material client-side. The SDK type surface deliberately omits any field that would carry a secret. | Security regression. | SDK + server, Phase 3.5 e2e. |
| R4 | 's securities (AAPL, etc.) may require asset-registry injection points the current `@luxbridge/settings` does not expose. | Could force a SDK breaking change. | Investigate during Phase 3.7. |
| R5 | New `auth` / `kms` / `wallet` blocks are not yet exercised end-to-end by a non-Lux tenant; behavior under Zoo/Hanzo branding is unverified. | Auth break for Zoo/Hanzo. | Phase 2 e2e. |
| R5 | New `auth` / `kms` / `wallet` / cosigner blocks are not yet exercised end-to-end by a non-Lux tenant; behavior under Zoo/Hanzo branding is unverified. | Auth + cosigner break for Zoo/Hanzo. | Phase 2 e2e. |
| R6 | `app/bridge/package.json` currently re-declares deps that morally belong to `@luxfi/bridge` (web3 trio + `@hanzogui/config-default` + `@luxfi/threshold`). If a tenant lags an SDK upgrade, version drift between the tenant's declared pins and what the SDK actually uses can cause subtle runtime breaks. Phase 3.1 retires this risk. | Drift between tenant and SDK pins. | SDK, Phase 3.1. |
**Open questions for sponsor:**
1. Is Hanzo a tenant in this round, or only Lux + Zoo + (later) ?
2. Target date for the R3 client-side MPC + cosigner merge (drives Phase 3 schedule).
3. Should the loose top-level `docs/*.md` files be folded into the Nextra site under `docs/pages/`, or deleted outright?
4. Should `pkg/utila/` (and any equivalent `pkg/fireblocks/` if added) ship as published `@luxfi/*` packages, or stay private workspace helpers consumed only by `app/server/`?
2. Target date / acceptance bar for the Phase 3.5 cosigner e2e soak (Utila + Fireblocks on testnet).
3. Should `pkg/utila/` (and any equivalent `pkg/fireblocks/` if added) ship as published `@luxfi/*` packages, or stay private workspace helpers consumed only by `app/server/`?
4. Publication timing for `@luxfi/threshold` (gates SDK publication — see R2).
## 12. Out-of-Tree References
+5
View File
@@ -21,5 +21,10 @@ window.__ENV = {
BRIDGE_IAM_ORG: '${BRIDGE_IAM_ORG:-}',
BRIDGE_LOGO_URL: '${BRIDGE_LOGO_URL:-}',
WC_PROJECT_ID: '${WC_PROJECT_ID:-}',
BRIDGE_WALLET_DEFAULT_CHAIN: '${BRIDGE_WALLET_DEFAULT_CHAIN:-}',
BRIDGE_WALLET_SUPPORTED_CHAINS: '${BRIDGE_WALLET_SUPPORTED_CHAINS:-}',
BRIDGE_MPC_PUBLIC_URL: '${BRIDGE_MPC_PUBLIC_URL:-}',
BRIDGE_MPC_PRIVATE_URL: '${BRIDGE_MPC_PRIVATE_URL:-}',
BRIDGE_MPC_PROTOCOL: '${BRIDGE_MPC_PROTOCOL:-}',
}
EOF
+5
View File
@@ -11,4 +11,9 @@ window.__ENV = window.__ENV || {
BRIDGE_IAM_ORG: '',
BRIDGE_LOGO_URL: '',
WC_PROJECT_ID: '',
BRIDGE_WALLET_DEFAULT_CHAIN: '',
BRIDGE_WALLET_SUPPORTED_CHAINS: '',
BRIDGE_MPC_PUBLIC_URL: '',
BRIDGE_MPC_PRIVATE_URL: '',
BRIDGE_MPC_PROTOCOL: '',
}
+96 -5
View File
@@ -26,7 +26,11 @@
// 3. @luxfi/brand defaults — final fallback to the canonical Lux brand
// package's brand.json values.
import type { BridgeConfig } from '@luxfi/bridge'
import type {
BridgeConfig,
BridgeMPCConfig,
BridgeWalletConfig,
} from '@luxfi/bridge'
import luxBrandJson from '@luxfi/brand/brand.json'
import { getColorSVG } from '@luxfi/logo'
@@ -61,13 +65,98 @@ const env = (key: string, fallback?: string): string | undefined => {
const luxLogoDataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(getColorSVG())}`
// Bridge endpoints belong to the bridge subdomain — independent of
// @luxfi/brand's appDomain (which is "lux.exchange" for the exchange).
const fallbackApiHost = 'https://api.bridge.lux.network'
// Live bridge REST endpoint. Note the hostname is `bridge-api.lux.network`
// (hyphenated) — this is the same Express backend the production
// bridge.lux.network frontend uses today. The previous `api.bridge.lux.network`
// fallback never resolved in DNS, which is why the UI used to surface
// "Failed to fetch" on first load. When a public B-Chain (BridgeVM) JSON-RPC
// endpoint is exposed, the SDK will try that first via `BridgeConfig.rpc`
// and only fall back to this REST host on RPC failure.
const fallbackApiHost = 'https://bridge-api.lux.network'
const fallbackDocsUrl = 'https://docs.lux.network/bridge'
// MPC endpoints. publicUrl is the m-chain (public threshold network, used
// for user wallets); privateUrl is the treasury cluster (used for fee
// collection only and not always reachable from the tenant). Protocol
// defaults to cggmp21 — the canonical classical ECDSA-CMP variant called
// out as leaderless / permissionless-safe in REQUIREMENTS.md §5.3.
const fallbackMpcPublicUrl = 'https://mpc.lux.network'
const fallbackMpcProtocol: NonNullable<BridgeMPCConfig['protocol']> = 'cggmp21'
// Curated EVM allow-list for the Lux tenant. The SDK's full supported set
// (mainnet, arbitrum, base, polygon, optimism) is intentionally narrowed
// here so the user wallet only sees chains the bridge backend actually
// settles to. Non-EVM destinations (Lux native, Solana) are handled by the
// MPC threshold layer and don't appear in the wagmi connector.
const fallbackSupportedChainIds = [1, 42161, 8453, 137, 10]
const fallbackDefaultChainId = 1
/**
* Parse a comma-separated list of numeric chain IDs from env, dropping
* non-numeric entries. Empty / unset → `undefined` so the caller can fall
* back to the static allow-list.
*/
const parseChainIds = (raw: string | undefined): number[] | undefined => {
if (!raw) return undefined
const ids = raw
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n))
return ids.length > 0 ? ids : undefined
}
const parseChainId = (raw: string | undefined): number | undefined => {
if (!raw) return undefined
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : undefined
}
const wallet: BridgeWalletConfig = {
walletConnectProjectId: env('WC_PROJECT_ID'),
defaultChainId: parseChainId(env('BRIDGE_WALLET_DEFAULT_CHAIN')) ?? fallbackDefaultChainId,
supportedChainIds:
parseChainIds(env('BRIDGE_WALLET_SUPPORTED_CHAINS')) ?? fallbackSupportedChainIds,
}
// MPC protocol must be one of the union values in BridgeMPCConfig.protocol.
// Anything else from env → fall back to cggmp21 rather than producing a
// type-cast lie.
const ALLOWED_MPC_PROTOCOLS: readonly NonNullable<BridgeMPCConfig['protocol']>[] = [
'cggmp21',
'frost',
'bls',
'doerner',
'pulsar',
'corona',
'magnetar',
]
const parseMpcProtocol = (
raw: string | undefined,
): NonNullable<BridgeMPCConfig['protocol']> => {
if (!raw) return fallbackMpcProtocol
const match = ALLOWED_MPC_PROTOCOLS.find((p) => p === raw)
return match ?? fallbackMpcProtocol
}
const mpc: BridgeMPCConfig = {
publicUrl: env('BRIDGE_MPC_PUBLIC_URL', fallbackMpcPublicUrl),
privateUrl: env('BRIDGE_MPC_PRIVATE_URL'),
protocol: parseMpcProtocol(env('BRIDGE_MPC_PROTOCOL')),
}
// In Vite dev mode we want apiHost to be same-origin so /api/* routes through
// the Vite dev proxy (which injects the production Origin header — see
// vite.config.ts). In production builds we use the explicit env value or the
// canonical bridge-api.lux.network fallback (where the prod origin is on
// the server's CORS allow-list).
const devApiHost =
import.meta.env.DEV && typeof window !== 'undefined'
? window.location.origin
: undefined
export const bridgeConfig: BridgeConfig = {
apiHost: env('BRIDGE_API_HOST', fallbackApiHost)!,
apiHost: devApiHost ?? env('BRIDGE_API_HOST', fallbackApiHost)!,
env: env('BRIDGE_ENV', 'mainnet')!,
clientId: env('BRIDGE_CLIENT_ID'),
iamOrg: env('BRIDGE_IAM_ORG', 'lux'),
@@ -84,4 +173,6 @@ export const bridgeConfig: BridgeConfig = {
supportEmail: supportEmail ?? '[email protected]',
docsUrl: fallbackDocsUrl,
},
wallet,
mpc,
}
+5
View File
@@ -12,6 +12,11 @@ interface ImportMetaEnv {
readonly VITE_BRIDGE_IAM_ORG?: string
readonly VITE_BRIDGE_LOGO_URL?: string
readonly VITE_WC_PROJECT_ID?: string
readonly VITE_BRIDGE_WALLET_DEFAULT_CHAIN?: string
readonly VITE_BRIDGE_WALLET_SUPPORTED_CHAINS?: string
readonly VITE_BRIDGE_MPC_PUBLIC_URL?: string
readonly VITE_BRIDGE_MPC_PRIVATE_URL?: string
readonly VITE_BRIDGE_MPC_PROTOCOL?: string
}
interface ImportMeta {
+27 -1
View File
@@ -22,7 +22,33 @@ export default defineConfig({
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
__DEV__: 'false',
},
server: { port: 3001, host: true },
server: {
port: 3001,
host: true,
// Dev-only proxy that fronts the production bridge REST API. Browsers
// would otherwise be blocked: bridge-api.lux.network returns
// `Access-Control-Allow-Origin: https://bridge.lux.network` and a 500
// for any other origin. With this proxy in place the browser sees a
// same-origin request (`/api/*` → localhost) and Vite forwards it to
// bridge-api.lux.network with `Origin: https://bridge.lux.network`
// injected. Production builds talk directly to the API (the prod
// origin is whitelisted server-side) so this block has no effect on
// the deployed image.
proxy: {
'/api': {
target: 'https://bridge-api.lux.network',
changeOrigin: true,
secure: true,
headers: {
// Tell bridge-api.lux.network this request came from the prod
// origin so its CORS allow-list passes. changeOrigin already
// rewrites the Host header; this overrides the Origin header
// that node-http-proxy would otherwise forward verbatim.
Origin: 'https://bridge.lux.network',
},
},
},
},
preview: { port: 3001, host: true },
build: { outDir: 'dist', sourcemap: true },
})
+2
View File
@@ -0,0 +1,2 @@
VITE_SERVER_URI=https://api-bridge.lux.network
VITE_NET_VERSION=testnet
+41
View File
@@ -0,0 +1,41 @@
export interface Brand {
name: string
title: string
description: string
shortName: string
legalEntity: string
appDomain: string
twitter: string
github: string
discord: string
logoUrl: string
faviconUrl: string
}
const defaults: Brand = {
name: 'Lux Bridge',
title: 'Lux Bridge',
description: 'Cross-chain bridge powered by Lux Network',
shortName: 'LUX',
legalEntity: 'Lux Industries Inc.',
appDomain: 'bridge.lux.network',
twitter: 'https://x.com/luxfi',
github: 'https://github.com/luxfi',
discord: 'https://discord.gg/lux',
logoUrl: '/favicon.ico',
faviconUrl: '/favicon.ico',
}
export let brand: Brand = { ...defaults }
export async function loadBrand(): Promise<Brand> {
try {
const res = await fetch('/brand.json')
if (!res.ok) return brand
const config = await res.json()
if (config.brand) Object.assign(brand, config.brand)
const baseName = brand.name.replace(/\s*bridge\s*/i, '').trim()
if (!brand.shortName && baseName) brand.shortName = baseName.length <= 3 ? baseName.toUpperCase() : baseName
} catch {}
return brand
}
@@ -0,0 +1,62 @@
import { type PropsWithChildren } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@hanzo/ui/primitives-common'
const TooltipHelper: React.FC<{
text: string
tooltipClx?: string
} & PropsWithChildren> = ({
children,
text,
tooltipClx=''
}) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent className={tooltipClx}>
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
const formatAvailable = (v: number | null): string => {
if (!v) return ''
let str = v.toFixed(5)
if (v - parseFloat(str) !== 0) {
str = '~' + str
}
return str
}
const AmountAvailable: React.FC<{
amount?: number
fromAssetName: string
}> = ({
amount,
fromAssetName
}) => {
const formattedAmount = formatAvailable(amount ?? null)
const availableWasRounded = formattedAmount?.startsWith('~')
return (
<span className='block shrink-0 cursor-default'>
{availableWasRounded ? (
<TooltipHelper text={`${amount} ${fromAssetName}`} tooltipClx='text-foreground'>
<span>{formattedAmount}&nbsp;{fromAssetName}&nbsp;avail</span>
</TooltipHelper>
) : (<>
<span>{formattedAmount}&nbsp;{fromAssetName}&nbsp;avail</span>
</>)}
</span>
)
}
export default AmountAvailable
@@ -0,0 +1,32 @@
import { type PropsWithChildren } from 'react'
import { TooltipWrapper } from '@hanzo/ui/primitives-common'
import { formatToMaxChar } from '@hanzo/ui/util'
const AssetQuantityAvailable: React.FC<{
quantity: number
maxChars: number
fromAssetName: string
}> = ({
quantity,
maxChars,
fromAssetName
}) => {
const { result: formatted, change } = formatToMaxChar(quantity, maxChars)
return (
<span className='block shrink-0 cursor-default'>
{change === 'rounded' ? (
<TooltipWrapper text={`${quantity} ${fromAssetName}`} tooltipClx='text-foreground'>
<span>~{formatted}&nbsp;{fromAssetName}&nbsp;avail</span>
</TooltipWrapper>
) : (
<span>{quantity}&nbsp;{fromAssetName}&nbsp;avail</span>
)}
</span>
)
}
export default AssetQuantityAvailable
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
import { observable, reaction, type IObservableValue } from 'mobx'
import { observer } from 'mobx-react-lite'
import { TooltipWrapper } from '@hanzo/ui/primitives-common'
import { cn, type FormatThreshold, formatAndAbbreviateAsCurrency } from '@hanzo/ui/util'
import { useSwapState } from '@/contexts/swap-state'
const THRESHOLDS = [
{
from: 1000000000,
use: 'M'
},
{
from: 1000000000000,
use: 'B'
},
] satisfies FormatThreshold[]
const DollarValue: React.FC<{
className?: string
}> = observer(({
className='',
}) => {
const swapState = useSwapState()
const valueRef = useRef<IObservableValue<string | null>>(observable.box(''))
const tooltipRef = useRef<IObservableValue<string | null>>(observable.box(''))
useEffect(() => (
reaction(
() => ({
quantity: swapState.fromAssetQuantity,
price: swapState.fromAssetPriceUSD
}),
({quantity, price}) => {
const value: number | null = (quantity > 0 && price) ? quantity * price : null
const {
result,
change,
full
} = formatAndAbbreviateAsCurrency(value, THRESHOLDS)
//console.log(`RES: ${result}, CH: ${change}, FULL: ${full}`)
valueRef.current.set(((change === 'rounded') ? '~' : '') + result)
tooltipRef.current.set((change === 'rounded') ? full : null)
},
{ fireImmediately: true }
)
), [])
return (
<span className={cn('block cursor-default', className)}>
{tooltipRef.current.get() ? (
<TooltipWrapper text={tooltipRef.current.get()!} tooltipClx='text-foreground'>
<span>{valueRef.current.get()}</span>
</TooltipWrapper>
) : (
<span>{valueRef.current.get()}</span>
)}
</span>
)
})
export default DollarValue
@@ -0,0 +1,116 @@
import { useRef } from 'react'
import { observable, runInAction, type IObservableValue } from 'mobx'
import { observer } from 'mobx-react-lite'
import { useDebouncedCallback } from 'use-debounce'
import { cn } from '@hanzo/ui/util'
import { Button, Input } from '@hanzo/ui/primitives-common'
import { useSwapState } from '@/contexts/swap-state'
import AssetCombobox from '../asset-combobox'
import AssetQuantityAvailable from './asset-quantity-available'
import DollarValue from './dollar-value'
const AssetCard: React.FC<{
assetQuantityAvailable?: number
onQuantityChanged?: (am: number) => void
debounceQuantityChangesBy?: number
className?: string
}> = observer(({
assetQuantityAvailable,
onQuantityChanged,
debounceQuantityChangesBy=600,
className=''
}) => {
const swapState = useSwapState()
// Use this extra state string to better handle initial decimal point
// and other subtleties.
const quantityDisplayRef = useRef<IObservableValue<string>>(observable.box(''))
const debouncedOnQuantityChanged = useDebouncedCallback(
(v: number) => { onQuantityChanged && onQuantityChanged(v) },
debounceQuantityChangesBy
)
const onChange: React.ChangeEventHandler<HTMLInputElement> = (
event
) => {
let str = event.target.value
if (str === '.') {
str = '0.'
}
else if (str === '0' && quantityDisplayRef.current.get() === '0.') {
str = ''
}
const newValue = Number(str)
if (!isNaN(newValue)) {
runInAction(() => {
quantityDisplayRef.current.set(str)
})
}
swapState.setFromAssetQuantity(newValue)
debouncedOnQuantityChanged(newValue)
}
const onMaxClicked = () => {
swapState.setFromAssetQuantity(assetQuantityAvailable!)
debouncedOnQuantityChanged(assetQuantityAvailable!)
runInAction(() => {
quantityDisplayRef.current.set(assetQuantityAvailable!.toString())
})
}
return (
<div className={cn('border border-muted-4 py-2 pl-3 pr-1.5 has-[:focus]:border-muted', className)}>
<div className='flex justify-between items-center gap-1'>
<Input
className={
'min-w-0 text-foreground focus:text-accent bg-level-0 p-1 ' +
'!border-none focus:!outline-none focus-visible:!ring-0 focus:!ring-0 text-xl !placeholder-muted-3'
}
lang="en"
placeholder='0'
min='0'
type='text'
value={quantityDisplayRef.current.get()}
onChange={onChange}
/>
<AssetCombobox
assets={swapState.fromAssets}
asset={swapState.fromAsset}
setAsset={swapState.setFromAsset}
buttonClx='shrink-0 border-none pr-0 -mr-1'
popoverAlign='end'
/>
</div>
<div className='flex justify-between items-center text-muted text-sm gap-1.5'>
<DollarValue />
<div className='flex items-center shrink-0'>
{ (assetQuantityAvailable && swapState.fromAsset) ? (<>
<AssetQuantityAvailable
quantity={assetQuantityAvailable}
maxChars={8}
fromAssetName={swapState.fromAsset?.name ?? ''}
/>
<Button
variant='outline'
onClick={onMaxClicked}
size='xs'
disabled={assetQuantityAvailable === swapState.fromAssetQuantity}
className='font-sans text-muted bg-level-1 ml-2 h-6'
>
max
</Button>
</>) : (
<div />
)}
</div>
</div>
</div>
)
})
export default AssetCard
@@ -0,0 +1,69 @@
import React from 'react'
import { Combobox, type ComboboxTriggerProps, type ListAdaptor } from '@hanzo/ui/primitives-common'
import { cn } from '@hanzo/ui/util'
import type { Asset } from '@luxfi/core'
const adaptor = {
getValue: (el: Asset): string => (el.asset),
equals: (el1: Asset, el2: Asset): boolean => (
el1.asset.toUpperCase() === el2.asset.toUpperCase()
),
valueEquals: (el: Asset, v: string): boolean => (
el.asset.toUpperCase() === v.toUpperCase()
),
getLabel: (el: Asset): string => (el.name),
getImageUrl: (el: Asset): string => (el.logo),
isDisabled: (el: Asset): boolean => (el.status !== 'active')
} satisfies ListAdaptor<Asset>
const AssetCombobox: React.FC<{
assets: Asset[]
asset: Asset | null
setAsset: (Asset: Asset | null) => void
placeholder?: string
searchHint?: string
disabled?: boolean
buttonClx?: string
popoverClx?: string
popoverAlign? : "center" | "end" | "start"
}> = ({
assets,
asset,
setAsset,
placeholder,
searchHint,
disabled=false,
buttonClx='',
popoverClx='',
popoverAlign = 'center',
}) => (
<Combobox<Asset, ComboboxTriggerProps<Asset>>
elements={assets}
current={asset}
setCurrent={setAsset}
adaptor={adaptor}
noCheckmark
searchPlaceholder={searchHint}
popoverClx={cn('font-sans font-medium w-full', popoverClx)}
popoverAlign={popoverAlign}
listItemClx='bg-background hover:!bg-level-3'
listItemSelectedClx='!bg-level-2' // fw default is level-3
listItemDisabledClx='!opacity-30' // fw default is 35
triggerProps={{
open: false,
current: (asset ?? null),
currentLabel: null,
imageUrl: null,
placeholder,
imageClx: 'rounded',
buttonClx: cn('font-sans font-medium w-auto pl-1.5 pr-2', buttonClx),
disabled
}}
/>
)
export default AssetCombobox
@@ -0,0 +1,201 @@
import React from 'react'
import { cn } from '@hanzo/ui/util'
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle
} from '@hanzo/ui/primitives-common'
import type ChildMenu from '@/hanzo-ui-types/child-menu'
import type LinkDefExtended from '@/hanzo-ui-types/link-def-extended'
const DesktopNav: React.FC<{
links: LinkDefExtended[],
isMenuOpened: boolean,
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>
}> = ({ links, isMenuOpened, setIsMenuOpen }) => {
React.useEffect(() => {
const preventScroll = (e: WheelEvent | TouchEvent) => {
e.preventDefault()
}
if (isMenuOpened) {
window.addEventListener('wheel', preventScroll, { passive: false })
window.addEventListener('touchmove', preventScroll, { passive: false })
window.addEventListener('keydown', preventScrollKeys, { passive: false })
} else {
window.removeEventListener('wheel', preventScroll)
window.removeEventListener('touchmove', preventScroll)
window.removeEventListener('keydown', preventScrollKeys)
}
return () => {
window.removeEventListener('wheel', preventScroll)
window.removeEventListener('touchmove', preventScroll)
window.removeEventListener('keydown', preventScrollKeys)
}
}, [isMenuOpened])
const preventScrollKeys = (e: KeyboardEvent) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
e.preventDefault()
}
}
const handleMouseEnter = React.useCallback(() => {
setIsMenuOpen(true)
}, [setIsMenuOpen])
const handleMouseLeave = React.useCallback(() => {
setIsMenuOpen(false)
}, [setIsMenuOpen])
const handleContentMouseMove = () => {
setIsMenuOpen(false)
}
const menuHiddenClass = !isMenuOpened ? 'invisible' : ''
return links.length > 0 ? (
<NavigationMenu>
<NavigationMenuList>
{links.map((el, index) => (
<NavigationMenuItem key={index} className='!m-0'>
{el.isAIMenu ? (
<NavigationMenuLink href={el.href} className={cn(navigationMenuTriggerStyle(), ' text-muted-1 bg-transparent')}>
{el.title}
</NavigationMenuLink>
) : el.title === 'Cards' ? (
<>
<NavigationMenuTrigger
className='text-muted-1 bg-transparent'
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleMouseLeave}
>
<a href={el.href} >
{el.title}
</a>
</NavigationMenuTrigger>
<NavigationMenuContent
className={cn('fixed left-0 top-14 pt-6 w-screen h-full border-0 !backdrop-blur-3xl bg-transparent mt-0', menuHiddenClass)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='flex justify-center items-start'>
<div
className='grid xl:grid-cols-3 w-full justify-center max-w-[750px]'
onMouseLeave={handleContentMouseMove}
>
{GroupChildMenu({ childs: el.childMenu, isCards: true })}
</div>
</div>
</NavigationMenuContent>
</>
) : (
<>
<NavigationMenuTrigger
className='text-muted-1 bg-transparent'
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleMouseLeave}
>
{
el.href && el.href !== '' ?
<a href={el.href} >
{el.title}
</a> : <>{el.title}</>
}
</NavigationMenuTrigger>
<NavigationMenuContent
className={cn('fixed left-0 top-14 pt-6 w-screen h-full border-0 !backdrop-blur-3xl bg-transparent mt-0', menuHiddenClass)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className='flex flex-row w-full justify-center'
onMouseLeave={handleContentMouseMove}
>
{GroupChildMenu({ childs: el.childMenu })}
</div>
</NavigationMenuContent>
</>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
) : null
}
export default DesktopNav
const ListItem = React.forwardRef<
React.ElementRef<'a'>,
React.ComponentPropsWithoutRef<'a'>
>(({ className, title, children, ...props }, ref) => (
<li key={title}>
<NavigationMenuLink asChild>
<a
ref={ref}
className={cn(
'block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:text-accent-foreground focus:bg-level-1 focus:text-accent-foreground text-muted-1 hover:text-primary hover:bg-transparent duration-1000 ease-in-out',
className
)}
{...props}
>
<div className='text-sm font-medium leading-none'>{title}</div>
<p className='line-clamp-3 text-sm leading-snug text-muted-1'>
{children}
</p>
</a>
</NavigationMenuLink>
</li>
))
ListItem.displayName = 'ListItem'
const GroupChildMenu = (params: { childs: ChildMenu[] | undefined, isCards?: boolean }) => {
const { childs, isCards = false } = params
if (!childs) {
return null
}
const groupedChildMenus = childs.reduce((grouped: Record<string, ChildMenu[]>, childLink) => {
if (childLink.groupName) {
grouped[childLink.groupName] = grouped[childLink.groupName] || []
grouped[childLink.groupName].push(childLink)
}
return grouped
}, {})
const getChildExtraClass = (index: number) => {
if (isCards && (index === 3 || index === 4)) {
return 'xl:-mt-32'
}
return ''
}
return Object.entries(groupedChildMenus).map(([groupName, childLinks], index) => (
<div key={groupName} className={cn('py-4 px-4 ', getChildExtraClass(index))}>
<h2 className='text-muted-1'>{groupName}</h2>
<ul className='w-[200px] gap-3 md:w-[250px] lg:w-[250px]'>
{childLinks.map((link) => (
<div className='flex justify-start items-center' key={link.title}>
{link.icon}
<ListItem key={link.title} title={link.title} href={link.href} className='ml-[14px]'>
{link.contents}
</ListItem>
</div>
))}
</ul>
</div>
))
}
@@ -0,0 +1,52 @@
import React, { useState, type FC, type PropsWithChildren } from 'react'
import type { LinkDef } from '@hanzo/ui/types'
import { cn } from '@hanzo/ui/util'
import Logo, { type LogoVariant } from '../logo'
import DesktopNav from './desktop-nav-menu'
const DesktopHeader: React.FC<{
currentAs: string | undefined
links: LinkDef[]
className?: string
logoVariant?: LogoVariant
} & PropsWithChildren> = ({
links,
className = '',
children,
logoVariant='text-only'
}) => {
const [isMenuOpened, setIsMenuOpen] = useState(false);
// TODO move 13px into a size class and configure twMerge to recognize say, 'text-size-nav'
// (vs be beat out by 'text-color-nav')
return (
<header id='DESKTOP_HEADER' className={cn(
'bg-[rgba(0, 0, 0, 0.5)] !backdrop-blur-3xl fixed z-header top-0 left-0 right-0',
className,
isMenuOpened ? ' h-full' : ''
)} >
{/* md or larger */}
<div className={
'flex flex-row h-[80px] items-center justify-between ' +
'mx-[24px] w-full max-w-screen'
}>
<Logo size={logoVariant === 'logo-only' ? 'lg' : 'md'} href='/' outerClx='hidden lg:flex' key='two' variant={logoVariant} />
<Logo size='sm' href='/' outerClx='hidden md:flex lg:hidden' key='one' variant={logoVariant} />
{/* md or larger */}
<div className='flex w-full gap-4 items-center justify-center'>
<DesktopNav links={links} isMenuOpened={isMenuOpened} setIsMenuOpen={setIsMenuOpen} />
</div>
<div className='flex items-center'>
{children}
</div>
</div>
</header>
)
}
export default DesktopHeader
@@ -0,0 +1,39 @@
import React, { useState, type PropsWithChildren } from 'react'
import { cn } from '@hanzo/ui/util'
import type SiteDef from '@/hanzo-ui-types/site-def'
import DesktopHeader from './desktop'
import { type LogoVariant } from '../logo'
/** children will render furthest right */
const Header: React.FC<{
siteDef: SiteDef
className?: string
logoVariant?: LogoVariant
} & PropsWithChildren> = ({
siteDef,
className = '',
children,
logoVariant='text-only'
}) => {
// TODO
const [open, setOpen] = useState<boolean>(false);
const { nav: { common, featured }, currentAs } = siteDef
const links = (featured) ? [...common, ...featured] : common
return (
<DesktopHeader
className={cn(className, 'hidden md:flex')}
links={links}
currentAs={currentAs}
logoVariant={logoVariant}
>{children}</DesktopHeader>
)
}
export default Header
@@ -0,0 +1,11 @@
import React from 'react'
import { type LucideProps } from 'lucide-react'
const Avatar: React.FC<LucideProps> = (props: LucideProps) => (
<svg width="17" height="21" viewBox="0 0 17 21" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.66112 10.3544C6.14668 10.3544 4.32664 6.91073 4.32664 4.59206C4.32664 2.12213 6.27238 0.115723 8.66112 0.115723C11.0499 0.115723 12.9956 2.12514 12.9956 4.59206C12.9956 6.91398 11.1756 10.3544 8.66112 10.3544ZM8.66112 1.1542C6.82539 1.1542 5.33242 2.69703 5.33242 4.59185C5.33242 6.65402 6.94715 9.31553 8.66112 9.31553C10.3761 9.31553 11.9898 6.65387 11.9898 4.5925C11.9898 2.69668 10.4969 1.15398 8.66112 1.15398V1.1542Z" fill="white" />
<path d="M6.79776 11.3345H10.5242C13.7802 11.3345 16.4302 14.0712 16.4302 17.4338C16.4302 19.0831 15.1297 20.423 13.5356 20.423H3.78923C2.19216 20.423 0.894689 19.0831 0.894689 17.4338C0.893706 14.0712 3.54156 11.3345 6.79776 11.3345ZM3.78819 19.3844H13.5337C14.5749 19.3844 15.4225 18.509 15.4225 17.4338C15.4225 14.6453 13.2253 12.3731 10.5223 12.3731H6.79862C4.0956 12.3731 1.89839 14.6423 1.89839 17.4338C1.89937 18.509 2.74701 19.3844 3.78816 19.3844H3.78819Z" fill="white" />
</svg>
)
export default Avatar
@@ -0,0 +1,10 @@
import React from 'react'
import { type LucideProps } from 'lucide-react'
const BagIcon: React.FC<LucideProps> = (props: LucideProps) => (
<svg fill="none" viewBox="0 0 1019 1281" {...props}>
<path d="m977.243 365.553h-175.407v-90.629c0-151.923-131.968-274.924-292.334-274.924-160.367 0-292.337 123.002-292.337 274.924v90.629h-175.4023c-23.3886 0-41.7627 17.051-41.7627 38.756v775.121c0 55.8 48.4456 100.76 108.582 100.76h801.84c60.138 0 108.578-44.96 108.578-100.76v-775.121c0-21.705-18.37-38.756-41.757-38.756zm-676.553-90.629c0-108.517 93.546-196.9408 208.812-196.9408 115.265 0 208.813 88.4218 208.813 196.9408v89.08h-417.625z" fill="currentColor"/>
</svg>
)
export default BagIcon
@@ -0,0 +1,6 @@
export { default as Avatar } from './avatar'
export { default as Bag } from './bag-icon'
export { default as LeftArrow } from './left-arrow'
export { default as LuxLogo } from './lux-logo'
export { default as RightArrow } from './right-arrow'
export { default as SocialIcon, type SocialIconProps } from './social-icon'
@@ -0,0 +1,11 @@
import React from 'react'
import { type LucideProps } from 'lucide-react'
const LeftArrow: React.FC<LucideProps> = (props: LucideProps) => (
<svg width="10" height="16" viewBox="0 0 10 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M1.06975 7.44328C0.679224 7.83381 0.679224 8.46697 1.06975 8.8575L7.43371 15.2215C7.82423 15.612 8.4574 15.612 8.84792 15.2215C9.23845 14.8309 9.23845 14.1978 8.84792 13.8072L3.19107 8.15039L8.84792 2.49354C9.23845 2.10301 9.23845 1.46985 8.84792 1.07932C8.4574 0.688798 7.82423 0.688798 7.43371 1.07932L1.06975 7.44328ZM1.77686 9.15039H2.49037V7.15039H1.77686V9.15039Z" fill="white"/>
</svg>
)
export default LeftArrow
@@ -0,0 +1,10 @@
import React from 'react'
import { type LucideProps } from 'lucide-react'
const LuxLogo: React.FC<LucideProps> = (props: LucideProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" {...props}>
<polygon points="25,46.65 50,3.35 0,3.35" fill="white" stroke='black' strokeWidth={props.strokeWidth}/>
</svg>
)
export default LuxLogo
@@ -0,0 +1,10 @@
import React from 'react'
import { type LucideProps } from 'lucide-react'
const RightArrow: React.FC<LucideProps> = (props: LucideProps) => (
<svg width="9" height="16" viewBox="0 0 9 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.33797 7.58293C8.72849 7.97346 8.72849 8.60662 8.33797 8.99715L1.97401 15.3611C1.58348 15.7516 0.950316 15.7516 0.559792 15.3611C0.169267 14.9706 0.169267 14.3374 0.559792 13.9469L6.21665 8.29004L0.559792 2.63318C0.169267 2.24266 0.169267 1.6095 0.559792 1.21897C0.950316 0.828447 1.58348 0.828447 1.97401 1.21897L8.33797 7.58293ZM7.63086 9.29004H6.91734V7.29004H7.63086V9.29004Z" fill="white"/>
</svg>
)
export default RightArrow
@@ -0,0 +1,35 @@
import React from 'react'
import { SocialIcon as BaseSocialIcon } from 'react-social-icons'
import { cn } from '@hanzo/ui/util'
import './social-svg.css'
interface SocialIconProps {
// one of these: https://github.com/couetilc/react-social-icons/tree/main/db
network: string
size: number
className?: string
}
const SocialIcon: React.FC<SocialIconProps> = ({
network,
size,
className = ''
}) => (
<BaseSocialIcon
network={network}
as='div'
// This is set up so the enclosing element sets the color.
// For example, something like 'color-foreground hover:color-muted-1'
className={cn('color-inherit', className)}
bgColor='transparent'
fgColor='currentColor'
style={{height: size, width: size, flex: 'none' }}
/>
)
export {
type SocialIconProps,
SocialIcon as default
}
@@ -0,0 +1,3 @@
.social-icon svg {
transform: scale(1.25);
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import { Link } from "@remix-run/react"
import { type TShirtSize } from '@hanzo/ui/types'
import { LuxLogo} from './icons'
const TEXT = 'LUX'
type LogoVariant = 'text-only' | 'logo-only' | 'full'
const Logo: React.FC<{
size?: TShirtSize
variant?: LogoVariant
onClick?: () => void
href?: string
outerClx?: string
textClx?: string
}> = ({
size,
href, // no default please!
outerClx='',
textClx='',
variant='full',
onClick,
}) => {
let classes: any = {}
const toAdd = (variant === 'logo-only') ?
{
span: 'hidden',
icon: ''
}
:
(variant === 'text-only') ?
{
span: '',
icon: 'hidden'
}
:
{
span: '',
icon: ''
}
if (size === 'lg' || size === 'xl' ) { // for safety
classes.icon = 'h-10 w-10 mr-4 color-inherit '
classes.span = 'text-3xl '
}
// match lux.network
else if (size === 'md') {
classes.icon = 'h-[32px] w-[32px] mr-[12px] color-inherit '
classes.span = 'text-[1.8rem]/[1.8rem] tracking-tighter '
}
else if (size === 'sm' ) {
classes.icon = 'h-6 w-6 mr-2 color-inherit '
classes.span = 'text-lg '
}
// xs
else {
classes.icon = 'h-4 w-4 mr-1 color-inherit '
classes.span = 'text-base '
}
classes.icon += toAdd.icon
classes.span += toAdd.span
const outerClasses = 'flex flex-row items-center ' + outerClx
const spanClasses = 'inline-block font-bold font-heading '
+ textClx
+ (href ? ' hover:text-accent cursor-pointer ' : ' cursor-default ')
+ classes.span
return (
href ? (
<Link to={href} className={outerClasses} onClick={onClick} >
<LuxLogo className={classes.icon} />
<span className={spanClasses}>{TEXT}</span>
</Link>
) : (
<span className={outerClasses} onClick={onClick}>
<LuxLogo className={classes.icon} />
<span className={spanClasses}>{TEXT}</span>
</span>
)
)
}
export {
Logo as default,
type LogoVariant
}
+27
View File
@@ -0,0 +1,27 @@
import React, { type PropsWithChildren } from 'react'
import { cn } from '@hanzo/ui/util'
const Main: React.FC<PropsWithChildren & {
id?: string
className?: string
headerSpace?: boolean
standardWidth?: boolean
}> = ({
children,
className='',
headerSpace=true,
standardWidth=true,
id
}) => (
<main id={id ?? 'BRIDGE_MAIN'} className={cn(
'flex flex-col justify-start items-center',
'px-4 md:px-6 xl:px-0',
standardWidth ? 'max-w-screen-xl w-full lg:mx-auto' : '',
headerSpace ? 'pt-[44px] md:pt-[80px]' : 'py-4',
className
)}>
{children}
</main>
)
export default Main
@@ -0,0 +1,16 @@
import { type ListAdaptor } from '@hanzo/ui/primitives-common'
import type { Network } from '@luxfi/core'
export default {
getValue: (el: Network): string => (el.internal_name),
equals: (el1: Network, el2: Network): boolean => (
el1.internal_name.toUpperCase() === el2.internal_name.toUpperCase()
),
valueEquals: (el: Network, v: string): boolean => (
el.internal_name.toUpperCase() === v.toUpperCase()
),
getLabel: (el: Network): string => (el.display_name),
getImageUrl: (el: Network): string | null => ((el.logo ?? el.img_url) ?? null),
} satisfies ListAdaptor<Network>
@@ -0,0 +1,61 @@
import React from 'react'
import { Combobox } from '@hanzo/ui/primitives-common'
import { cn } from '@hanzo/ui/util'
import type { Network } from '@luxfi/core'
import adaptor from './adaptor'
import NetworkComboboxTrigger, { type NetworkTriggerProps } from './trigger'
const ICON_SIZE = 40
const NetworkCombobox: React.FC<{
networks: Network[]
network: Network | null
setNetwork: (network: Network | null) => void
searchHint?: string
popoverClx?: string
buttonClx?: string
popoverAlign? : "center" | "end" | "start"
label: string
rightJustified?: boolean
}> = ({
networks,
network,
setNetwork,
searchHint,
popoverClx='',
popoverAlign = 'center',
buttonClx='',
label,
rightJustified=false
}) => (
<Combobox<Network, NetworkTriggerProps>
elements={networks}
current={network}
setCurrent={setNetwork}
adaptor={adaptor}
searchPlaceholder={searchHint}
noCheckmark
popoverClx={cn('font-sans font-medium w-full', popoverClx)}
popoverAlign={popoverAlign}
listItemClx='bg-background hover:!bg-level-3'
listItemSelectedClx='!bg-level-2'
Trigger={NetworkComboboxTrigger}
triggerProps={{
open: false,
current: (network ?? null),
currentLabel: null,
imageUrl: null,
buttonClx: cn('font-sans font-medium w-full px-2', buttonClx),
disabled: false,
imageSize: ICON_SIZE,
label,
rightJustified
}}
/>
)
export default NetworkCombobox
@@ -0,0 +1,69 @@
import React from 'react'
import { Button, Skeleton, LoadingSpinner, type ComboboxTriggerProps } from '@hanzo/ui/primitives-common'
import { cn } from '@hanzo/ui/util'
import type { Network } from '@luxfi/core'
interface NetworkTriggerProps extends ComboboxTriggerProps<Network>{
label: string
rightJustified?: boolean
}
const NetworkComboboxTriggerInner = ({
current,
currentLabel,
imageUrl,
buttonClx,
imageClx,
imageSize,
rightJustified=false,
label,
...rest
}: NetworkTriggerProps,
ref: React.ForwardedRef<HTMLButtonElement>
) => (
<Button
ref={ref}
{...rest}
variant='outline'
role='combobox'
className={cn(
'flex gap-1.5 rounded-lg h-auto py-1',
rightJustified ? 'justify-start flex-row-reverse' : 'justify-start',
current ? '' : 'pointer-events-none !p-0', // so skeleton will look right
buttonClx
)}
>
{current ? (<>
<img
src={imageUrl!}
alt={currentLabel + ' image'}
height={imageSize}
width={imageSize}
loading="eager"
className={cn('block rounded-md ', imageClx)}
/>
<div className={cn(
'flex flex-col gap-0 ',
rightJustified ? 'justify-end items-end' : 'justify-start items-start'
)}>
<span className='text-sm block text-muted-2'>{label}</span>
<span className={cn('block font-semibold', currentLabel ? 'text-foreground' : 'text-muted')}>
{ currentLabel ?? '(select)' }
</span>
</div>
</>) : ( // height from observation
<Skeleton className='w-full h-[64px] flex justify-center items-center' reverse={rightJustified}>
<LoadingSpinner size={30}/>
</Skeleton>
)}
</Button>
)
const NetworkComboboxTrigger = React.forwardRef(NetworkComboboxTriggerInner) as <Network, NetworkTriggerProps>(props: NetworkTriggerProps & { ref?: React.ForwardedRef<HTMLButtonElement> }) => React.ReactNode
export {
NetworkComboboxTrigger as default,
type NetworkTriggerProps
}
@@ -0,0 +1,33 @@
import { observer } from 'mobx-react-lite'
import { cn } from '@hanzo/ui/util'
import { useSwapState } from '@/contexts/swap-state'
const BridgeLabel: React.FC<{
imageSize?: number
imageClx?: string
className?: string
}> = observer(({
imageSize=24,
imageClx='',
className=''
}) => {
const swapState = useSwapState()
const imageUrl = swapState.toNetwork?.logo ?? swapState.toNetwork?.img_url ?? null
return imageUrl ? (
<div className={cn('flex justify-start items-center gap-2', className)}>
<img
src={imageUrl}
alt={swapState.toNetwork?.display_name + ' image'}
height={imageSize}
width={imageSize}
loading="eager"
className={cn('block', imageClx)}
/>
<span className='block'>{swapState.bridge}</span>
</div>
) : null
})
export default BridgeLabel
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { AnimatePresence } from 'motion/react'
import * as motion from 'motion/react-client'
import { cn, formatToMaxChar } from '@hanzo/ui/util'
import { useSwapState } from '@/contexts/swap-state'
import BridgeLabel from './bridge-label'
const ReceiveCard: React.FC<{
quantity: number | null // debounced
usdFee: number
assetGas: number
txnTime: string // eg, '~5min'
className?: string
}> = observer(({
quantity,
usdFee,
assetGas,
txnTime, // eg, '~5min'
className=''
}) => {
const swapState = useSwapState()
const visible = !!quantity // Note: both and null or === 0
const [loading, setLoading] = useState<boolean>(false)
const [quantityDisplay, setQuantityDisplay] = useState<string>('')
useEffect(() => {
setLoading(true)
const {
result,
change
} = formatToMaxChar(quantity, 15)
setQuantityDisplay(change === 'rounded' ? '~' + result : result)
// mimic remote fetch
setTimeout(() => {
setLoading(false)
}, 400)
}, [quantity])
return (
<AnimatePresence initial={visible} mode='wait'>
{ loading && visible ? (
<motion.div
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
className={className}
>
loading...
</motion.div>
) : ( visible ? (
<motion.div
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleY: 0 }}
className={cn(
'flex flex-col gap-3',
'border border-muted-4 py-2 px-2',
className
)}
>
<div className='flex justify-between items-center text-sm'>
{swapState.toNetwork ? (
<span className='block font-bold'>Get on {swapState.toNetwork!.display_name }</span>
) : (
<span className='block'/>
)}
<BridgeLabel/>
</div>
<div className='flex justify-start items-end text-xl font-bold'>
{quantityDisplay && (<span className='block leading-none'>{quantityDisplay}&nbsp;</span>)}
{swapState.toAsset && (<span className='block text-base leading-none pb-[1px]'>{swapState.toAsset?.asset ?? ''}</span>) }
</div>
</motion.div>
) : null)}
</AnimatePresence>
)
})
export default ReceiveCard
@@ -0,0 +1,64 @@
import { observer } from 'mobx-react-lite'
import NetworkCombobox from '../network-combobox'
import ReverseButton from './reverse-button'
import { useSwapState } from '@/contexts/swap-state'
const ABS_CENTERED = {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%,-50%)'
} satisfies React.CSSProperties
const FromToCard: React.FC<{
className?: string
}> = observer(({
className=''
}) => {
const swapState = useSwapState()
/* TODO
const swapFromAndTo = () => {
const tmp = swapState.from
swapState.setFrom(swapState.to)
swapState.setTo(tmp)
const tmpNetworks = swapState.fromNetworks
swapState.setFromNetworks(swapState.toNetworks)
swapState.setToNetworks(tmpNetworks)
}
*/
// 'flex w-full gap-2 relative'
return (
<div className={className}>
<NetworkCombobox
networks={swapState.fromNetworks}
setNetwork={swapState.setFromNetwork}
network={swapState.fromNetwork}
buttonClx='grow pr-4'
popoverClx='w-[350px]'
popoverAlign='start'
label='from'
/>
<NetworkCombobox
networks={swapState.toNetworks}
setNetwork={swapState.setToNetwork}
network={swapState.toNetwork}
buttonClx='grow pl-4'
popoverClx='w-[350px]'
popoverAlign='end'
label='to'
rightJustified
/>
<ReverseButton
className='p-1 h-auto text-muted active:!bg-level-3'
onClick={() => {}}
style={ABS_CENTERED}
/>
</div>
)
})
export default FromToCard
@@ -0,0 +1,50 @@
import React, { useState } from 'react'
import { cn } from '@hanzo/ui/util'
import AssetCard from '../asset-card'
import FromToCard from './from-to-card'
import ReceiveCard from '../receive-card'
import { useSwapState } from '@/contexts/swap-state'
const FIXTURE = {
usdFee: 2.4,
assetGas: .045,
txnTime: '~5min',
assetsAvailable: 134.56676767676,
}
const SwapCard: React.FC<{
className?: string
}> = ({
className='',
}) => {
const swapState = useSwapState()
// AssetCard debounces this change so use a sep state var
const [quantity, setQuantity] = useState<number | null>(swapState.fromAssetQuantity ?? null)
return (
<div className={cn(
'flex flex-col justify-start items-center',
'px-6 py-4 gap-4 rounded border border-muted-4',
className
)}>
<FromToCard className='flex w-full gap-2 relative' />
<AssetCard
assetQuantityAvailable={FIXTURE.assetsAvailable}
onQuantityChanged={setQuantity}
className='w-full rounded-lg mt-2'
/>
<ReceiveCard
quantity={quantity}
usdFee={FIXTURE.usdFee}
assetGas={FIXTURE.assetGas}
txnTime={FIXTURE.txnTime}
className='w-full rounded-lg'
/>
</div>
)
}
export default SwapCard
@@ -0,0 +1,27 @@
import { ChevronsLeftRight } from 'lucide-react'
import { Button } from '@hanzo/ui/primitives-common'
import { cn } from '@hanzo/ui/util'
const ReverseButton: React.FC<{
onClick: () => void
className?: string
style?: React.CSSProperties
}> = ({
onClick,
className='',
style={}
}) => (
<Button
variant='outline'
size='sm'
className={cn(className)}
onClick={onClick}
style={style}
>
<ChevronsLeftRight className='w-4 h-4'/>
</Button>
)
export default ReverseButton
+36
View File
@@ -0,0 +1,36 @@
import type { PropsWithChildren } from 'react'
import { TooltipProvider } from '@hanzo/ui/primitives-common'
import type { Network } from '@luxfi/core'
import SettingsProvider from '@/contexts/settings'
import SwapStateProvider from '@/contexts/swap-state'
import type { AppSettings } from '@/domain/types'
const Contexts: React.FC<{
appSettings: AppSettings,
defaultFromNetwork?: Network
defaultToNetwork?: Network
} & PropsWithChildren> = ({
children,
appSettings,
defaultFromNetwork,
defaultToNetwork,
}) => {
return (
<SettingsProvider appSettings={appSettings}>
<SwapStateProvider
appSettings={appSettings}
defaultFromNetwork={defaultFromNetwork}
defaultToNetwork={defaultToNetwork}
>
<TooltipProvider>
{children}
</TooltipProvider>
</SwapStateProvider>
</SettingsProvider>
)
}
export default Contexts
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, useRef, type PropsWithChildren } from 'react'
import type AppSettings from '@/domain/types/app-settings'
const SettingsContext = createContext<React.MutableRefObject<AppSettings> | null>(null)
const SettingsProvider: React.FC<{
appSettings: AppSettings
} & PropsWithChildren> = ({
children,
appSettings
}) => {
const settingsRef = useRef<AppSettings>(appSettings)
return (
<SettingsContext.Provider value={settingsRef}>
{children}
</SettingsContext.Provider>
)
}
const useSettings = (): AppSettings => {
const ref = useContext(SettingsContext)
if (!ref || !ref.current) {
throw new Error('SettingsProvider not ititialized!')
}
return ref.current
}
export {
SettingsProvider as default,
SettingsContext,
useSettings,
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useEffect, useRef, type PropsWithChildren } from 'react'
import type { Network } from '@luxfi/core'
import type { SwapState, AppSettings } from '@/domain/types'
import getInstance from '@/domain/swap/get-instance'
import SwapStore from '@/domain/swap/store'
import { useSettings } from './settings'
const SwapStateContext = createContext<React.MutableRefObject<SwapState> | null>(null)
const SwapStateProvider: React.FC<{
appSettings: AppSettings,
defaultFromNetwork?: Network,
defaultToNetwork?: Network,
} & PropsWithChildren> = ({
children,
appSettings,
defaultFromNetwork,
defaultToNetwork
}) => {
const swapStateRef = useRef<SwapState>(getInstance(
appSettings,
defaultFromNetwork,
defaultToNetwork
))
useEffect(() => {
if (swapStateRef.current) {
(swapStateRef.current as SwapStore).initialize()
}
return () => {
if (swapStateRef.current) {
(swapStateRef.current as SwapStore).dispose()
}
}
}, [])
return (
<SwapStateContext.Provider value={swapStateRef}>
{children}
</SwapStateContext.Provider>
)
}
const useSwapState = (): SwapState => {
const ref = useContext(SwapStateContext)
if (!ref || !ref.current) {
throw new Error('SwapStateProvider or SwapState not ititialized!')
}
return ref.current
};
export {
SwapStateProvider as default,
SwapStateContext,
useSwapState,
}
@@ -0,0 +1,2 @@
This basically the same function as BridgeApiClient in the bridge2 code.
Individual utilities that call the backend API.
@@ -0,0 +1,13 @@
import axios from 'axios'
import type { Asset } from '@luxfi/core'
const getAssetPrice = async (a: Asset): Promise<number | undefined> => {
const { data } = await axios.get(
import.meta.env.VITE_SERVER_URI + '/api/tokens/price/' + a.asset
)
return data?.data?.price ? Number(data?.data?.price) : undefined
}
export default getAssetPrice
@@ -0,0 +1,17 @@
import axios from 'axios'
import type AppSettings from '@/domain/types/app-settings'
import swapPairs from './swap-pairs'
const getSettings = async (): Promise<AppSettings | undefined> => {
const { data } = await axios.get(
import.meta.env.VITE_SERVER_URI + '/api/settings?version=' + import.meta.env.VITE_NET_VERSION
)
data.swapPairs = swapPairs
return data as AppSettings ?? undefined
}
export default getSettings
+8
View File
@@ -0,0 +1,8 @@
import { type BackendService } from '../types'
import { default as getSettings } from './get-settings'
import { default as getAssetPrice } from './get-asset-price'
export default {
getSettings,
getAssetPrice
} satisfies BackendService
@@ -0,0 +1,101 @@
export default {
// Lux tokens
LUX: ['ZLUX'],
LBTC: ['WBTC', 'ZBTC', 'cbBTC'],
LETH: ['ETH', 'WETH', 'ZETH'],
LUSD: ['USDT', 'USDC', 'DAI', 'ZUSD'],
LZOO: ['ZOO'],
LBNB: ['BNB', 'ZBNB'],
LPOL: ['POL', 'ZPOL'],
LCELO: ['CELO', 'ZCELO'],
LFTM: ['FTM', 'ZFTM'],
LXDAI: ['XDAI', 'ZXDAI'],
LSOL: ['ZSOL'],
LTON: ['ZTON'],
LADA: ['ZADA'],
LAVAX: ['AVAX', 'ZAVAX'],
LBLAST: ['ZBLAST', 'BLAST'],
LBONK: ['ZBONK'],
LWIF: ['ZWIF'],
LPOPCAT: ['ZPOPCAT'],
LPNUT: ['ZPNUT'],
LMEW: ['ZMEW'],
LBOME: ['ZBOME'],
LGIGA: ['ZGIGA'],
LAI16Z: ['ZAI16Z'],
LFWOG: ['ZFWOG'],
LMOODENG: ['ZMOODENG'],
LPONKE: ['ZPONKE'],
LNOT: ['ZNOT'],
LDOGS: ['ZDOGS'],
LMRB: ['ZMRB'],
LREDO: ['ZREDO'],
// Zoo tokens
ZOO: ['LZOO'],
ZBTC: ['WBTC', 'LBTC'],
ZETH: ['ETH', 'WETH', 'LETH'],
ZUSD: ['USDT', 'USDC', 'DAI', 'LUSD'],
ZLUX: ['LUX'],
ZBNB: ['BNB', 'LBNB'],
ZPOL: ['POL', 'LPOL'],
ZCELO: ['CELO', 'LCELO'],
ZFTM: ['FTM', 'LFTM'],
ZXDAI: ['XDAI', 'LXDAI'],
ZSOL: ['LSOL'],
ZTON: ['LTON'],
ZADA: ['LADA'],
ZAVAX: ['AVAX', 'LAVAX'],
ZBLAST: ['LBLAST', 'BLAST'],
ZBONK: ['LBONK'],
ZWIF: ['LWIF'],
ZPOPCAT: ['LPOPCAT'],
ZPNUT: ['LPNUT'],
ZMEW: ['LMEW'],
ZBOME: ['LBOME'],
ZGIGA: ['LGIGA'],
ZAI16Z: ['LAI16Z'],
ZFWOG: ['LFWOG'],
ZMOODENG: ['LMOODENG'],
ZPONKE: ['LPONKE'],
ZNOT: ['LNOT'],
ZDOGS: ['LDOGS'],
ZMRB: ['LMRB'],
ZREDO: ['LREDO'],
// Evm tokens
ETH: ['LETH', 'ZETH'],
WETH: ['LETH', 'ZETH'],
cbBTC: ['LBTC', 'ZBTC'],
WBTC: ['LBTC', 'ZBTC'],
POL: ['LPOL', 'ZPOL'],
BNB: ['LBNB', 'ZBNB'],
FTM: ['LFTM', 'ZFTM'],
DAI: ['LUSD', 'ZUSD'],
USDT: ['LUSD', 'ZUSD'],
USDC: ['LUSD', 'ZUSD'],
CELO: ['LCELO', 'ZCELO'],
XDAI: ['LXDAI', 'ZXDAI'],
AVAX: ['LAVAX', 'ZAVAX'],
BLAST: ['LBLAST', 'ZBLAST'],
//// Utila tokens
BTC: ['LBTC', 'ZBTC'],
TON: ['LTON', 'ZTON'],
// Solana tokens
SOL: ['LSOL', 'ZSOL'],
BONK: ['LBONK', 'ZBONK'],
SIXR: ['LSIXR', 'ZSIXR'],
WIF: ['LWIF', 'ZWIF'],
POPCAT: ['LPOPCAT', 'ZPOPCAT'],
PNUT: ['LPNUT', 'ZPNUT'],
MEW: ['LMEW', 'ZMEW'],
BOME: ['LBOME', 'ZBOME'],
GIGA: ['LGIGA', 'ZGIGA'],
AI16Z: ['LAI16Z', 'ZAI16Z'],
FWOG: ['LFWOG', 'ZFWOG'],
MOODENG: ['LMOODENG', 'ZMOODENG'],
PONKE: ['LPONKE', 'ZPONKE'],
// Ton tokens
NOT: ['LNOT', 'ZNOT'],
DOGS: ['LDOGS', 'ZDOGS'],
MRB: ['LMRB', 'ZMRB'],
REDO: ['LREDO', 'ZREDO']
} satisfies Record<string, string[]>
@@ -0,0 +1,43 @@
import { enableStaticRendering } from 'mobx-react-lite'
import type { Network } from '@luxfi/core'
import type { AppSettings, SwapState } from '@/domain/types'
import SwapStore from './store'
// https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18
// (This also applies to remix)
enableStaticRendering(typeof window === "undefined")
let instance: SwapStore | undefined = undefined
const getInstance = (
settings: AppSettings,
initialFrom?: Network,
initialTo?: Network
): SwapState => {
// Server side: create a new copy of the store each time
if (typeof window === "undefined") {
const inst = new SwapStore(
settings,
initialTo,
initialFrom
)
// TODO how to call dispose()?
return inst
}
// Client side: create the store only once
if (!instance) {
instance = new SwapStore(
settings,
initialTo,
initialFrom
)
}
return instance
}
export default getInstance
@@ -0,0 +1,15 @@
# Swap Logic
These functions keep the **Swap form elements** logically
in sync with each other.
They are mobx reactions that consolidate and encapsulate
various logic scattered across the ui code in bridge2. In order
to **separate concerns** and collect related business logic
in one please, they are all
They are designed to be triggered by both **user actions** and
**effect functions** of other reactions. In the Swap store's
`initialize()` method, they are create in **reverse order** so that
a desired effect is always initialized before its trigger.
@@ -0,0 +1,40 @@
import { reaction } from 'mobx'
import type { Network, Asset } from '@luxfi/core'
import type { SwapState } from '@/domain/types'
import backend from '@/domain/backend'
const swapExists = (
swapPairs: Record<string, string[]>,
src: Asset,
swapAsset: Asset
): boolean => (
swapPairs[src.asset].includes(swapAsset.asset)
)
export default (store: SwapState) => (reaction(
() => ({
fromAsset: store.fromAsset,
}),
async ({
fromAsset,
}) => {
// Networks for which at least one swap pair exists (swap is possible)
store.setToNetworks(
fromAsset ? (
store.allNetworks
.map((n: Network) => ({
...n,
currencies: n.currencies.filter((c: Asset) => (swapExists(store.swapPairs, fromAsset!, c))),
}))
.filter((n: Network) => n.currencies.length > 0)
) : []
)
if (fromAsset) {
const price = await backend.getAssetPrice(fromAsset)
store.setFromAssetPriceUSD(price ?? null)
}
}
))
@@ -0,0 +1,16 @@
import { reaction } from 'mobx'
import type { SwapState } from '@/domain/types'
export default (store: SwapState) => (reaction(
() => ({
network: store.fromNetwork,
}),
({
network,
}) => {
// Do not filter by status==='active' as the UI now shows those as disabled
store.setFromAssets(network?.currencies ?? [])
store.setFromAsset(store.fromAssets.length ? store.fromAssets[0] : null)
}
))
@@ -0,0 +1,32 @@
import { reaction } from 'mobx'
import type { Network, NetworkType } from '@luxfi/core'
import type { SwapState, Bridge } from '@/domain/types'
const matchesRoute = (bridge: Bridge, t: NetworkType): boolean => (
(bridge === 'teleport' ? t === 'evm' : t !== 'evm' )
)
export default (store: SwapState) => (reaction(
() => ({
networks: store.allNetworks,
bridge: store.bridge,
}),
({
networks,
bridge
}) => {
store.setFromNetworks(networks.filter(
(n: Network) => (n.status === 'active' && matchesRoute(bridge, n.type))
))
store.setFromNetwork(store.fromNetworks.length ? store.fromNetworks[0] : null)
store.setFromAssets(store.fromNetwork?.currencies ?? [])
store.setFromAsset(store.fromAssets.length ? store.fromAssets[0] : null)
},
// fire now so that all reactions will cascade based on
// what was passed into the constructor
{ fireImmediately: true}
))
@@ -0,0 +1,14 @@
import { reaction } from 'mobx'
import type { SwapState } from '@/domain/types'
export default (store: SwapState) => (reaction(
() => ({
toNetwork: store.toNetwork,
}),
({
toNetwork,
}) => {
store.setToAsset(toNetwork?.currencies.length ? toNetwork?.currencies[0] : null)
}
))
@@ -0,0 +1,14 @@
import { reaction } from 'mobx'
import type { SwapState } from '@/domain/types'
export default (store: SwapState) => (reaction(
() => ({
toNetworks: store.toNetworks,
}),
({
toNetworks,
}) => {
store.setToNetwork(toNetworks.length ? toNetworks[0] : null)
}
))
+114
View File
@@ -0,0 +1,114 @@
import {
computed,
makeObservable,
observable,
action,
type IReactionDisposer,
} from 'mobx'
import type { Asset, Network } from '@luxfi/core'
import type { AppSettings, SwapState, Bridge } from '@/domain/types'
import onNetworksOrRouteChange from './logic/on-networks-or-route-change'
import onFromNetworkChange from './logic/on-from-network-change'
import onFromAssetChange from './logic/on-from-asset-change'
import onToNetworksChange from './logic/on-to-networks-change'
import onToNetworkChange from './logic/on-to-network-change'
class SwapStore implements SwapState {
fromNetworks: Network[] = []
toNetworks: Network[] = []
fromNetwork: Network | null = null
toNetwork: Network | null = null
fromAssets: Asset[] = []
fromAsset: Asset | null = null
fromAssetPriceUSD: number | null = null
toAsset: Asset | null = null
fromAssetQuantity: number = 0
bridge: Bridge = 'teleport'
allNetworks: Network[] = []
swapPairs: Record<string, string[]> = {}
private _disposers: IReactionDisposer[] = []
constructor(
settings: AppSettings,
initialTo?: Network,
initialFrom?: Network
) {
makeObservable(this, {
fromNetworks: observable.shallow,
toNetworks: observable.shallow,
fromNetwork: observable.shallow,
toNetwork: observable.shallow,
fromAssets:observable.shallow,
fromAsset: observable.shallow,
fromAssetPriceUSD: observable,
toAsset: observable.shallow,
fromAssetQuantity: observable,
bridge: observable,
allNetworks: observable.shallow,
setFromNetwork: action.bound,
setToNetwork: action.bound,
setFromAssets: action,
setFromAsset: action.bound,
setFromAssetPriceUSD: action.bound,
setToAsset: action.bound,
setFromAssetQuantity: action.bound,
setBridge: action.bound,
setSettings: action.bound,
})
this.allNetworks = settings.networks
this.swapPairs = settings.swapPairs
this.fromNetwork = initialFrom ?? null
this.toNetwork = initialTo ?? null
}
/**
This should be called clients-side as early as possible
(eg, in useEffect()).
Mobx reaction()s return a disposer function.
Provider calls our dispose() when it unmounts
via useEffect()'s return value.
These should be called in REVERSE order of causality
for the effects to cascade properly the first time.
*/
initialize = () => {
this._disposers.push(onToNetworkChange(this))
this._disposers.push(onToNetworksChange(this))
this._disposers.push(onFromAssetChange(this))
this._disposers.push(onFromNetworkChange(this))
this._disposers.push(onNetworksOrRouteChange(this)) // fires immediately
}
dispose = () => { this._disposers.forEach((d) => {d()}) }
setFromNetworks = (n: Network[]): void => { this.fromNetworks = n }
setToNetworks = (n: Network[]): void => { this.toNetworks = n }
setFromNetwork = (n: Network | null): void => { this.fromNetwork = n }
setToNetwork = (n: Network | null): void => { this.toNetwork = n }
setFromAssets = (a: Asset[]): void => { this.fromAssets = a }
setFromAsset = (a: Asset | null): void => { this.fromAsset = a }
setFromAssetPriceUSD = (n: number | null): void => {this.fromAssetPriceUSD = n}
setToAsset = (a: Asset | null): void => { this.toAsset = a }
setFromAssetQuantity = (a: number): void => { this.fromAssetQuantity = a }
setBridge = (b: Bridge): void => { this.bridge = b }
setSettings = (
settings: AppSettings,
initialTo?: Network,
initialFrom?: Network
) => {
this.allNetworks = settings.networks
this.swapPairs = settings.swapPairs
this.fromNetwork = initialFrom ?? null
this.toNetwork = initialTo ?? null
}
}
export default SwapStore
@@ -0,0 +1,11 @@
import { type Network } from '@luxfi/core'
interface AppSettings {
networks: Network[]
swapPairs: Record<string, string[]>
}
export {
type AppSettings as default
}
@@ -0,0 +1,12 @@
import type { Asset } from '@luxfi/core'
import type AppSettings from './app-settings'
interface BackendService {
getSettings: () => Promise<AppSettings | undefined>
getAssetPrice: (a: Asset) => Promise<number | undefined>
}
export {
type BackendService as default
}
+3
View File
@@ -0,0 +1,3 @@
export type { default as AppSettings } from './app-settings'
export type { default as BackendService } from './backend-service'
export type { default as SwapState, Bridge } from './swap-state'
@@ -0,0 +1,44 @@
import type { Network, Asset } from '@luxfi/core'
import type AppSettings from './app-settings'
type Bridge = 'teleport' | 'utila'
interface SwapState {
allNetworks : Network[]
fromNetworks: Network[]
toNetworks: Network[]
toNetwork : Network | null
fromNetwork : Network | null
fromAssets : Asset[]
fromAsset : Asset | null
fromAssetPriceUSD: number | null
toAsset : Asset | null
bridge : Bridge
fromAssetQuantity : number
setFromNetwork(n: Network | null) : void
setToNetwork(n: Network | null): void
setFromAsset(a: Asset | null): void
setFromAssetPriceUSD(n: number | null): void
setToAsset(a: Asset | null): void
setFromAssetQuantity(a: number): void
setBridge(b: Bridge): void
// internal use
swapPairs: Record<string, string[]>
setFromNetworks(n: Network[]): void
setToNetworks(n: Network[]): void
setFromAssets(a: Asset[]): void
setSettings (
settings: AppSettings,
initialTo?: Network,
initialFrom?: Network
): void
}
export {
type SwapState as default,
type Bridge
}
+82
View File
@@ -0,0 +1,82 @@
// 'type' must be outside the curlies!
// https://github.com/remix-run/remix/issues/9916#issuecomment-2436405265
import type { LinkDescriptor } from '@vercel/remix'
type CrossOrigin = "anonymous" | "use-credentials"
// https://github.com/remix-run/remix/discussions/4963#discussioncomment-4556820
export default [
{
rel: "preload",
as: "font",
href: "/fonts/Druk-Wide-Medium.ttf",
type: "font/ttf",
crossOrigin: "anonymous" as CrossOrigin
},
{
rel: "preload",
as: "font",
href: "/fonts/Druk-Wide-Bold.ttf",
type: "font/ttf",
crossOrigin: "anonymous" as CrossOrigin,
},
{
rel: "preload",
as: "font",
href: "/fonts/InterVariable.woff2",
type: "font/woff2",
crossOrigin: "anonymous" as CrossOrigin,
},
{
rel: "preload",
as: "font",
href: "/fonts/InterVariable.ttf",
type: "font/ttf",
crossOrigin: "anonymous" as CrossOrigin,
},
{
rel: "preload",
as: "font",
href: "/fonts/InterVariable-Italic.woff2",
type: "font/woff2",
crossOrigin: "anonymous" as CrossOrigin,
},
{
rel: "preload",
as: "font",
href: "/fonts/InterVariable-Italic.ttf",
type: "font/ttf",
crossOrigin: "anonymous" as CrossOrigin,
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/lux-site-icons/favicon-16x16.png'
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/lux-site-icons/favicon-32x32.png'
},
{
rel: 'icon',
type: 'image/png',
sizes: '192x192',
href: '/lux-site-icons/android-chrome-192x192.png'
},
{
rel: 'icon',
type: 'image/png',
sizes: '512x512',
href: '/lux-site-icons/android-chrome-512x512.png'
},
{
rel: 'apple-touch-icon',
type: 'image/png',
sizes: "180x180",
href: '/lux-site-icons/apple-touch-icon.png'
},
] satisfies LinkDescriptor[]
+2
View File
@@ -0,0 +1,2 @@
These are "remix-safe" versions of the types used by @luxfi/ui
TODO: remove them make luxfi/ui usable by next and remix
@@ -0,0 +1,10 @@
import type { LinkDef } from '@hanzo/ui/types'
interface ChildMenu extends LinkDef {
groupName?: string
icon_act?: any
}
export {
type ChildMenu as default
}
@@ -0,0 +1,14 @@
import type { LinkDef } from '@hanzo/ui/types'
import type ChildMenu from './child-menu'
interface LinkDefExtended extends LinkDef {
isAIMenu?: boolean
icon?: any
details?: string
childMenu?: ChildMenu[]
}
export {
type LinkDefExtended as default,
}
@@ -0,0 +1,43 @@
import type { LinkDef } from '@hanzo/ui/types'
interface SiteDef {
/** url of this site. All nav links in the system will show it in 'current' state */
currentAs?: string
nav: {
/** common elements (will auto-select currentAs if it's provide) */
/** optional feature element. right-most after 'elements' (any min-w is ignored) */
common: LinkDef[]
featured?: LinkDef[]
}
/**
* Array of columns, each of which itself is an array of links
* (or "title defs" where href='' and variant='linkFG')
* Common use case: To render a site-specific first column:
* {
* footer[myColumn, ...commonColumnsFromUi]
* }
* see: @hanzo/ui/siteDef/footer (default export)
* or @hanzo/ui/siteDef/footer/common
*/
footer: LinkDef[][]
/**
* optional override of default 'above copyright' horizantal links
default (undefined / absent): links in side-def/footer/legal are rendered
[] renders nothing above the copyright
*/
aboveCopyright?: LinkDef[]
//commerce?: CommerceConfig
//chatbot?: ChatbotConfig
// Do not render login / avatar widget
noAuth?: boolean
}
export {
type SiteDef as default,
}
+27
View File
@@ -0,0 +1,27 @@
// see https://remix.run/docs/ru/main/route/meta
// Brand defaults — overridden at runtime via brand.json (see brand.ts)
import { brand } from './brand'
export default [
{
title: brand.title,
},
{
name: 'description',
content: brand.description,
},
{
name: 'application-name',
content: brand.name,
},
{
name: 'author',
content: brand.legalEntity,
},
{
name: 'keywords',
content:
'Blockchain Bridge, Multi-Chain, EVM, Solana, Bitcoin, Cross-Chain, Interoperability, Cryptocurrency, Blockchain Technology',
},
]
+131
View File
@@ -0,0 +1,131 @@
import React, { type PropsWithChildren } from 'react'
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
type MetaFunction,
} from '@remix-run/react'
import { useLoaderData, useRouteLoaderData, useRouteError } from '@remix-run/react'
// 'type' must be outside the curlies!
// https://github.com/remix-run/remix/issues/9916#issuecomment-2436405265
import type { LinksFunction } from '@vercel/remix'
import { Analytics } from '@vercel/analytics/react'
import { BreakpointIndicator } from '@hanzo/ui/primitives-common'
import type { Network } from '@luxfi/core'
import luxGlobalCss from '@/style/lux-global-non-next.css?url'
import cartAnimationCss from '@/style/cart-animation.css?url'
import checkoutAnimationCss from '@/style/checkout-animation.css?url'
import type { AppSettings } from '@/domain/types'
import Contexts from '@/contexts'
import Main from '@/components/main'
import Header from '@/components/header'
import backend from '@/domain/backend'
import fontAndIconLinks from './font-and-icon-links';
import metadata from './metadata'
import siteDef from './site-def'
// export const config = { runtime: 'edge' } // not supported by vercel
const bodyClasses = 'bg-background text-foreground flex flex-col min-h-full '
export const links: LinksFunction = () => ([
...fontAndIconLinks,
{ rel: "stylesheet", href: luxGlobalCss },
{ rel: "stylesheet", href: cartAnimationCss },
{ rel: "stylesheet", href: checkoutAnimationCss }
])
export const meta: MetaFunction = () => (metadata)
interface LoaderReturnType {
appSettings: AppSettings,
defaultFromNetwork?: Network,
defaultToNetwork?: Network,
}
export const loader = async (): Promise<LoaderReturnType> => {
const appSettings = await backend.getSettings()
return {
appSettings: appSettings!, // otherwise an error is thrown
defaultFromNetwork: undefined,
defaultToNetwork: undefined
}
}
// cf: https://remix.run/docs/en/main/route/should-revalidate#never-reloading-the-root
export const shouldRevalidate = () => (false)
const Layout: React.FC<PropsWithChildren> = ({
children
}) => {
// https://github.com/remix-run/remix/pull/8958
// https://github.com/remix-run/remix/issues/8951
const data = useRouteLoaderData("root") as LoaderReturnType
const error = useRouteError()
const {
appSettings,
defaultFromNetwork,
defaultToNetwork
} = data
return (
<html
lang='en'
suppressHydrationWarning
className='hanzo-ui-dark-theme'
style={{backgroundColor: '#000'}}
>
<head>
<meta charSet='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<Meta />
<Links />
</head>
<body className={bodyClasses} >
{error ? (
<h1 className='mx-auto text-2xl mt-10'>error</h1>
) : (<>
<Contexts
appSettings={appSettings}
defaultFromNetwork={defaultFromNetwork}
defaultToNetwork={defaultToNetwork}
>
<Header siteDef={siteDef}/>
<Main className='gap-4 '>
{children}
</Main>
</Contexts>
<ScrollRestoration />
<Scripts />
{process.env.NODE_ENV === 'development' && <BreakpointIndicator />}
<Analytics />
</>)}
</body>
</html>
)
}
const Root: React.FC = () => (
<Outlet />
)
export {
Layout,
Root as default
}
+7
View File
@@ -0,0 +1,7 @@
import SwapCard from '@/components/swap-card'
const Index: React.FC = () => (
<SwapCard className='w-[500px] h-auto pb-10 mt-8' />
)
export default Index
@@ -0,0 +1,60 @@
import type { LinkDef } from '@hanzo/ui/types'
import { Warpcast } from '@luxfi/menu-icons'
import { SocialIcon } from '@/components/icons'
const SOC_ICON_SIZE = 18
export default [
{
title: 'Community',
href: '',
variant: 'linkFG',
},
{
title: 'Lux Channel',
href: 'https://warpcast.com/~/channel/lux',
icon: <Warpcast width={SOC_ICON_SIZE} height={SOC_ICON_SIZE} />
},
{
title: 'Lux Discussions',
href: 'https://github.com/orgs/luxfi/discussions',
icon: <SocialIcon network='github' size={SOC_ICON_SIZE} />
},
{
title: 'Discord',
href: 'https://discord.gg/sxaS7FFHwh',
icon: <SocialIcon network='discord' size={SOC_ICON_SIZE} />
},
{
title: 'Telegram',
href: 'https://t.me/luxdefi',
icon: <SocialIcon network='telegram' size={SOC_ICON_SIZE} />
},
{
title: '@luxdefi',
href: 'https://twitter.com/luxdefi',
icon: <SocialIcon network='x' size={SOC_ICON_SIZE} />
},
{
title: '@luxdefi',
href: 'https://facebook.com/luxdefi',
icon: <SocialIcon network='facebook' size={SOC_ICON_SIZE + 2} />
},
{
title: '@luxdefi',
href: 'https://www.instagram.com/luxdefi',
icon: <SocialIcon network='instagram' size={SOC_ICON_SIZE + 2} />
},
{
title: '@luxdefi',
href: 'https://linkedin.com/company/luxdefi',
icon: <SocialIcon network='linkedin' size={SOC_ICON_SIZE + 2} />
},
{
title: '@luxdefi',
href: 'https://www.youtube.com/@luxdefi',
icon: <SocialIcon network='youtube' size={SOC_ICON_SIZE + 2} />
},
] satisfies LinkDef[]
@@ -0,0 +1,37 @@
import type { LinkDef } from '@hanzo/ui/types'
export default [
{
title: 'Company',
href: "https://lux.partners/",
variant: 'linkFG',
newTab: false,
},
{
title: 'About',
href: 'https://lux.partners',
newTab: false,
},
{
title: 'Brand',
href: 'https://drive.google.com/drive/folders/14OJtKLVakGY6883XO9yGbiHtlFxQUUm5?usp=share_link',
},
{
title: 'Careers',
href: 'https://docs.google.com/document/d/1SCt0Hg7EIs06TootKCA1am1xo4mcXoKF/edit#heading=h.30j0zll',
newTab: true,
},
{
title: 'Partnerships',
href: 'https://apply.lux.partners/',
newTab: false,
},
{
title: 'Press',
href: 'mailto:[email protected]?subject=%E2%96%BC%20Press',
},
{
title: 'Help',
href: 'mailto:[email protected]?subject=%E2%96%BC%20Help',
},
] satisfies LinkDef[]
@@ -0,0 +1,37 @@
import type { LinkDef } from '@hanzo/ui/types'
export default [
{
title: 'Ecosystem',
href: 'https://lux.link',
variant: 'linkFG'
},
{
title: 'Lux AI',
href: 'https://lux.chat',
},
{
title: 'Lux Credit',
href: 'https://lux.credit',
},
{
title: 'Lux Exchange',
href: 'https://lux.exchange',
},
{
title: 'Lux Finance',
href: 'https://lux.finance',
},
{
title: 'Lux Market',
href: 'https://lux.market',
},
{
title: 'Lux Shop',
href: 'https://lux.shop',
},
{
title: 'Lux Quest',
href: 'https://lux.quest',
},
] satisfies LinkDef[]
+26
View File
@@ -0,0 +1,26 @@
import type { LinkDef } from '@hanzo/ui/types'
import ecosystem from './ecosystem'
import network from './network'
import company from './company'
import community from './community'
import { legal, legalColumn } from './legal'
const standard = [
ecosystem,
network,
company,
community,
] satisfies LinkDef[][]
export {
ecosystem,
network,
company,
community,
legal,
legalColumn,
standard
}
+41
View File
@@ -0,0 +1,41 @@
import type { LinkDef } from '@hanzo/ui/types'
/**
* Legal links — use env vars for white-label deployments.
* Default to lps.lux.network for regulatory (shared across all brands).
*/
const termsUrl = process.env.NEXT_PUBLIC_TERMS_URL ?? '/terms'
const privacyUrl = process.env.NEXT_PUBLIC_PRIVACY_URL ?? '/privacy'
const regulatoryUrl = process.env.NEXT_PUBLIC_REGULATORY_URL ?? 'https://lps.lux.network/legal/regulatory-status'
const legal: LinkDef[] = [
{
title: 'Terms of Service',
href: termsUrl,
newTab: termsUrl.startsWith('http'),
},
{
title: 'Privacy Policy',
href: privacyUrl,
newTab: privacyUrl.startsWith('http'),
},
{
title: 'Regulatory Status',
href: regulatoryUrl,
newTab: true,
},
]
const title: LinkDef =
{
title: 'Legal',
href: '',
variant: 'linkFG',
}
const legalColumn: LinkDef[] = [title, ...legal]
export {
legal,
legalColumn
}
@@ -0,0 +1,45 @@
import type { LinkDef } from '@hanzo/ui/types'
export default [
{
title: 'Network',
href: "https://lux.network/",
variant: 'linkFG'
},
{
title: 'Lux Bridge',
href: "https://bridge.lux.network/",
},
{
title: 'Lux Explorer',
href: "https://explore.lux.network/",
},
{
title: 'Lux Wallet',
href: "https://wallet.lux.network/",
},
{
title: 'Lux Safe',
href: "https://safe.lux.network/",
},
{
title: 'Lux Validator',
href: "https://lux.network/validator",
},
{
title: 'Lux Coin',
href: "https://lux.network/coin",
},
{
title: 'Governance',
href: "https://lux.vote",
},
{
title: 'Open Source',
href: 'https://github.com/luxfi',
},
{
title: 'Launch Subnet',
href: 'https://docs.lux.network/build/subnet/hello-subnet',
},
] satisfies LinkDef[]
+11
View File
@@ -0,0 +1,11 @@
import type SiteDef from '@/hanzo-ui-types/site-def'
import { standard } from './footer'
import mainNav from './main-nav'
export default {
//currentAs: 'https://lux.bridge',
nav: {
common: mainNav,
},
footer: standard,
} satisfies SiteDef
+447
View File
@@ -0,0 +1,447 @@
import type LinkDefExtended from '@/hanzo-ui-types/link-def-extended'
import {
AIChatAct,
AIChat,
Anodized,
BlogAct,
Blog,
BridgeAct,
Bridge,
ChangeLogAct,
ChangeLog,
Chrome,
CoinsAct,
Coins,
CompareCardsAct,
CompareCards,
CreditAct,
Credit,
CustomersAct,
Customers,
CustomerSupportAct,
CustomerSupport,
DeveloperDocsAct,
DeveloperDocs,
ExchangeAct,
Exchange,
ExplorerAct,
Explorer,
FAQsAct,
FAQs,
GoldCard,
GuidesAct,
Guides,
GunMetal,
IntegrationAct,
Integration,
Irradescent,
LaunchSubnetAct,
LaunchSubnet,
LuxFinanceAct,
LuxFinance,
LuxPassAct,
LuxPass,
LuxQuestsAct,
LuxQuests,
LuxWalletAct,
MarketAct,
Market,
MirroredTitanium,
MoreBenefitsAct,
MoreBenefits,
OpenSourceAct,
OpenSource,
SafeAct,
Safe,
ShopAct,
Shop,
Sterling,
TemplatesAct,
Templates,
ValidatorsAct,
Validators,
ViewAllCardsAct,
ViewAllCards,
Wallet,
} from '@luxfi/menu-icons'
export default [
{
title: "AI",
icon: "",
href: "https://lux.chat",
newTab: false,
isAIMenu: true,
},
{
title: "Cards",
icon: "",
details: "Lux Credit is a part of the Lux Network ecosystem that offers a crypto credit card solution. This service allows users to easily conduct transactions using their cryptocurrency holdings. The Lux Credit card aims to bridge the gap between traditional financial systems and the growing world of digital assets, providing a seamless way to spend cryptocurrencies in everyday transactions.",
href: "https://lux.credit",
newTab: false,
childMenu: [
{
groupName:'Black Card',
title: "Anodized Black Titanium",
icon: <Anodized />,
icon_act: '',
href: "https://lux.credit/cards/black?sku=LXM-CR-B-ABT",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Black Card',
title: "Black Gunmetal",
icon: <GunMetal />,
icon_act: '',
href: "https://lux.credit/cards/black?sku=LXM-CR-B-GM",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Founder Card',
title: "Cool Chrome",
icon: <Chrome />,
icon_act: '',
href: "https://lux.credit/cards/founder?sku=LXM-CR-F-CC",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Founder Card',
title: "Iridescent Chrome",
icon: <Irradescent />,
icon_act: '',
href: "https://lux.credit/cards/founder?sku=LXM-CR-F-IC",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Explore',
title: "Compare cards",
icon: <CompareCards width={25} height={25} />,
icon_act: <CompareCardsAct width={27} height={27} />,
href: "https://lux.credit/compare",
newTab: false,
contents:"Evaluate Lux cards"
},
{
groupName:'Explore',
title: "View all cards",
icon: <ViewAllCards width={25} height={25} />,
icon_act: <ViewAllCardsAct width={25} height={25}/>,
href: "https://lux.credit/cards",
newTab: false,
contents:"General overview"
},
{
groupName:'Explore',
title: "More benefits",
icon: <MoreBenefits width={25} height={25} />,
icon_act: <MoreBenefitsAct width={27} height={27} />,
href: "#",
newTab: false,
contents:"See what's truly unique"
},
{
groupName:'Elite Card',
title: "24k Gold",
icon: <GoldCard />,
icon_act: "",
href: "https://lux.credit/cards/elite?sku=LXM-CR-E-24G",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Elite Card',
title: "Sterling Silver",
icon: <Sterling />,
icon_act: "",
href: "https://lux.credit/cards/elite?sku=LXM-CR-E-SS",
newTab: false,
contents:"" //insert string to here
},
{
groupName:'Sovereign Card',
title: "Reflective Titanium",
icon: <MirroredTitanium/>,
icon_act: "",
href: "https://lux.credit/cards/sovereign?sku=LXM-CR-S-RT",
newTab: false,
contents:"" //insert string to here
},
]
},
{
title: "Ecosystem",
icon: "",
details: "The Lux Ecosystem is a comprehensive suite of blockchain-based services and platforms designed to offer a wide range of functionalities, from decentralized finance (DeFi) to asset management and secure transactions.",
href: "https://lux.link",
newTab: false,
childMenu: [
{
groupName:'Blockchain',
title: "AI Chat",
icon: <AIChat width={25} height={25} />,
icon_act: <AIChatAct width={27} height={27}/>,
href: "https://lux.chat",
newTab: false,
contents:"Ask anything, literally"
},
{
groupName:'Blockchain',
title: "Exchange",
icon: <Exchange width={25} height={25} />,
icon_act: <ExchangeAct width={27} height={27} />,
href: "https://lux.exchange",
newTab: false,
contents:"Buy, sell, trade, swap..."
},
{
groupName:'Blockchain',
title: "Market",
icon: <Market width={25} height={25} />,
icon_act: <MarketAct width={27} height={27}/>,
href: "https://lux.market",
newTab: false,
contents:"Digital collectibles & more"
},
{
groupName:'Blockchain',
title: "Shop",
icon: <Shop width={25} height={25} />,
icon_act: <ShopAct width={27} height={27}/>,
href: "https://lux.market",
newTab: false,
contents:"Find any Lux product for sale"
},
{
groupName:'Real World',
title: "Credit",
icon: <Credit width={25} height={25} />,
icon_act: <CreditAct width={27} height={27}/>,
href: "https://lux.credit",
newTab: false,
contents:"Risk free loans"
},
{
groupName:'Real World',
title: "Lux Finance",
icon: <LuxFinance width={25} height={25} />,
icon_act: <LuxFinanceAct width={27} height={27}/>,
href: "https://lux.finance",
newTab: false,
contents:"Financial arbitrage"
},
{
groupName:'More',
title: "Lux Quest",
icon: <LuxQuests width={25} height={25} />,
icon_act: <LuxQuestsAct width={27} height={27}/>,
href: "https://lux.quest",
newTab: false,
contents:"Go above and beyond"
}
]
},
{
title: "Network",
icon: "",
details: "Lux Network is a scalable, quantum-safe blockchain designed to offer high throughput and low latency. It is part of a broader ecosystem that includes various platforms and services aimed at integrating traditional finance (TradFi) with decentralized finance (DeFi), providing secure and efficient solutions for asset management, trading, and more.",
href: "https://lux.network/",
newTab: false,
childMenu: [
{
groupName:'Defi Tools',
title: "Bridge",
icon: <Bridge width={25} height={25} />,
icon_act: <BridgeAct width={27} height={27}/>,
href: "https://bridge.lux.network/",
newTab: false,
contents:"Teleport assets"
},
{
groupName:'Defi Tools',
title: "Explorer",
icon: <Explorer width={25} height={25} />,
icon_act: <ExplorerAct width={27} height={27}/>,
href: "https://explorer.lux.network/",
newTab: false,
contents:"All transactions"
},
{
groupName:'Defi Tools',
title: "Launch Subnet",
icon: <LaunchSubnet width={25} height={25} />,
icon_act: <LaunchSubnetAct width={27} height={27}/>,
href: "https://docs.lux.network/build/subnet/hello-subnet",
newTab: false,
contents:"Easy, free, and superior"
},
{
groupName:'Earn',
title: "Coins",
icon: <Coins width={25} height={25} />,
icon_act: <CoinsAct width={27} height={27}/>,
href: "https://lux.network/coin",
newTab: false,
contents:"Be part of the ecosystem"
},
{
groupName:'Earn',
title: "Safe",
icon: <Safe width={25} height={25} />,
icon_act: <SafeAct width={27} height={27} />,
href: "https://safe.lux.network",
newTab: false,
contents:"Multi-signature wallet"
},
{
groupName:'Earn',
title: "Lux Wallet",
icon: <Wallet width={25} height={25} />,
icon_act: <LuxWalletAct width={27} height={27}/>,
href: "https://wallet.lux.network",
newTab: false,
contents:"Safe and quantum secure"
},
{
groupName:'Earn',
title: "Validators",
icon: <Validators width={25} height={25} />,
icon_act: <ValidatorsAct width={27} height={27}/>,
href: "https://lux.market/validator",
newTab: false,
contents:"Own a part of the network"
},
{
groupName:'Get Access',
title: "Developer docs",
icon: <DeveloperDocs width={25} height={25} />,
icon_act: <DeveloperDocsAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Software explained"
},
{
groupName:'Get Access',
title: "Open Source",
icon: <OpenSource width={25} height={25} />,
icon_act: <OpenSourceAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Accessible for everyone"
},
{
groupName:'Get Access',
title: "Lux Pass",
icon: <LuxPass width={25} height={25} />,
icon_act: <LuxPassAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"All access pass to network"
},
]
},
{
title: "Resources",
icon: '',
href: "",
newTab: false,
details: "",
childMenu: [
{
groupName:'Tools',
title: "Resource Center",
icon: <AIChat width={25} height={25} />,
icon_act: <AIChatAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Dynamic solutions"
},
{
groupName:'Tools',
title: "Integrations",
icon: <Integration width={25} height={25} />,
icon_act: <IntegrationAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Simplified onboarding"
},
{
groupName:'Tools',
title: "Templates",
icon: <Templates width={25} height={25} />,
icon_act: <TemplatesAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Speedy app development"
},
{
groupName:'Tools',
title: "Guides",
icon: <Guides width={25} height={25} />,
icon_act: <GuidesAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Find help quickly"
},
{
groupName:'Company',
title: "Customers",
icon: <Customers width={25} height={25} />,
icon_act: <CustomersAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Trusted by the best teams"
},
{
groupName:'Company',
title: "Blog",
icon: <Blog width={25} height={25} />,
icon_act: <BlogAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Latest posts and changes"
},
{
groupName:'Company',
title: "Changelog",
icon: <ChangeLog width={25} height={25} />,
icon_act: <ChangeLogAct width={27} height={27} />,
href: "#",
newTab: false,
contents:"Manage deployments"
},
{
groupName:'More',
title: "Developer docs",
icon: <DeveloperDocs width={25} height={25} />,
icon_act: <DeveloperDocsAct width={25} height={25}/>,
href: "#",
newTab: false,
contents:"Software explained"
},
{
groupName:'More',
title: "Customer Support",
icon: <CustomerSupport width={25} height={25} />,
icon_act: <CustomerSupportAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Dedicated help, 24/7"
},
{
groupName:'More',
title: "FAQs",
icon: <FAQs width={25} height={25} />,
icon_act: <FAQsAct width={27} height={27}/>,
href: "#",
newTab: false,
contents:"Common queries"
}
]
},
] satisfies LinkDefExtended[]
+29
View File
@@ -0,0 +1,29 @@
@keyframes wiggle-larger {
0% { transform: rotate(0deg); }
18% { transform: rotate(10deg) scale(0.9); }
32% { transform: rotate(0deg) scale(1.05);; }
50% { transform: rotate(-10deg) scale(1.05); }
68% { transform: rotate(0deg) scale(1.1); }
84% { transform: rotate(10deg) scale(1); }
100% { transform: rotate(0deg) scale(1);}
}
@keyframes wiggle-smaller {
0% { transform: rotate(0deg); }
18% { transform: rotate(10deg) scale(1.05); }
32% { transform: rotate(0deg) scale(0.9);; }
50% { transform: rotate(-10deg) scale(0.9); }
68% { transform: rotate(0deg) scale(0.85); }
84% { transform: rotate(10deg) scale(1); }
100% { transform: rotate(0deg) scale(1);}
}
.wiggle-larger-animation,
.item-added-to-cart-animation {
animation: wiggle-larger 350ms;
}
.wiggle-smaller-animation,
.item-removed-from-cart-animation {
animation: wiggle-smaller 300ms;
}
@@ -0,0 +1,23 @@
@keyframes checkout-widget-fade-in {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1;}
}
.checkout-widget-appears {
animation-name: checkout-widget-fade-in;
animation-duration: 200ms;
}
.checkout-widget-disappears {
animation-name: checkout-widget-fade-in;
animation-duration: 200ms;
animation-direction: reverse;
animation-fill-mode: forwards;
}
.checkout-widget-appears-after-buy-drawer-closes {
animation-fill-mode: backwards;
/* Drawer is hardcoded to open in 0.5s. We can start slightly sooner. */
animation-delay: 400ms;
}
+85
View File
@@ -0,0 +1,85 @@
@tailwind base;
/* see https://stackoverflow.com/questions/69746121/using-nextjs-how-can-you-import-in-css-using-tailwind-css */
@layer base {
:root, .hanzo-ui-light-theme {
--hz-ui-fg-0: hsl(0 0% 0%);
--hz-ui-fg-body: hsl(0 0% 10%);
--hz-ui-fg-1: hsl(0 0% 20%);
--hz-ui-fg-2: hsl(0 0% 35%);
--hz-ui-fg-3: hsl(0 0% 50%);
--hz-ui-fg-4: hsl(0 0% 70%);
--hz-ui-fg-5: hsl(0 0% 90%);
--hz-ui-bg-0: hsl(0 0% 100%);
--hz-ui-bg-1: hsl(0 0% 90%);
--hz-ui-bg-2: hsl(0 0% 75%);
--hz-ui-bg-3: hsl(0 0% 55%);
--hz-ui-bg-overlay: hsla(0 0% 100% 0.60);
--hz-ui-bg-inverted: var(--hz-ui-fg-0);
--hz-ui-bg-inverted-hover: hsla(0, 0%, 0%, 0.85);
--hz-ui-fg-inverted: var(--hz-ui-bg-0);
--hz-ui-secondary-0: hsl(262, 7%, 82%);
--hz-ui-secondary-1: hsl(258, 7%, 72%);
--hz-ui-secondary-2: hsl(254, 7%, 62%);
--hz-ui-secondary-3: hsl(250, 5%, 53%);
--hz-ui-primary: var(--hz-ui-bg-inverted);
--hz-ui-primary-hover: var(--hz-ui-bg-inverted-hover);
--hz-ui-primary-fg: var(--hz-ui-fg-inverted);
--hz-ui-secondary: var(--hz-ui-secondary-0);
--hz-ui-secondary-hover: var(--hz-ui-secondary-2);
--hz-ui-secondary-fg: var(--hz-ui-bg-0);
--hz-ui-nav: var(--hz-ui-fg-4);
--hz-ui-nav-current: var(--hz-ui-fg-0);
--hz-ui-nav-hover: var(--hz-ui-fg-0);
--hz-ui-destructive: hsl(0 62.8% 45%);
--hz-ui-destructive-hover: hsl(0 62.8% 25%);
--hz-ui-destructive-fg: hsl(0 0% 100%);
--hz-ui-ring: var(--hz-ui-fg-1);
--hz-ui-radius: 0.5rem;
}
.hanzo-ui-dark-theme {
--hz-ui-fg-0: hsl(0 0% 100%);
--hz-ui-fg-body: hsl(0, 0%, 97%);
--hz-ui-fg-1: hsl(0 0% 85%);
--hz-ui-fg-2: hsl(0 0% 70%);
--hz-ui-fg-3: hsl(0 0% 55%);
--hz-ui-fg-4: hsl(0 0% 40%);
--hz-ui-fg-5: hsl(0 0% 25%);
--hz-ui-bg-inverted: var(--hz-ui-fg-0);
--hz-ui-bg-inverted-hover: hsla(0, 0%, 100%, 0.85);
--hz-ui-bg-3: hsl(0 0% 25%);
--hz-ui-bg-2: hsl(0 0% 18%);
--hz-ui-bg-1: hsl(0 0% 12%);
--hz-ui-bg-0: hsl(0 0% 0%);
--hz-ui-bg-overlay: hsla(0 0% 0% 0.60);
--hz-ui-fg-inverted: var(--hz-ui-bg-0);
--hz-ui-secondary-0: hsl(250, 5%, 53%);
--hz-ui-secondary-1: hsl(254, 7%, 62%);
--hz-ui-secondary-2: hsl(258, 7%, 72%);
--hz-ui-secondary-3: hsl(262, 7%, 82%);
--hz-ui-secondary-fg: var(--hz-ui-fg-0);
}
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@layer base {
@font-face {
font-family: "DrukWide";
src: url("/fonts/Druk-Wide-Medium.ttf");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "DrukWide";
src: url("/fonts/Druk-Wide-Bold.ttf");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "inter";
src: url("/fonts/InterVariable.woff2") format("woff2"),
url("/fonts/InterVariable.ttf") format("ttf");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "inter";
src: url("/fonts/InterVariable-Italic.woff2") format("woff2"),
url("/fonts/InterVariable-Italic.ttf") format("ttf");
font-weight: 500;
font-style: italic;
}
}
@@ -0,0 +1,82 @@
/* Note, preflight is applied first:
https://unpkg.com/[email protected]/src/css/preflight.css
*/
@import "tailwindcss/base";
/* see https://stackoverflow.com/questions/69746121/using-nextjs-how-can-you-import-in-css-using-tailwind-css */
/* @import "@hanzo/ui/style/hanzo-common"; */
@import "lux-colors";
@import "lux-fonts";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@layer base {
* {
box-sizing: inherit;
}
/*
using this approach:
https://stackoverflow.com/a/76066443/11645689
see src/app/(home)/scroll-snap
*/
html {
scroll-snap-type: y mandatory;
height: 100vh;
overflow-y: scroll;
box-sizing: border-box;
scrollbar-width: none;
}
body {
font-feature-settings: "rlig" 1, "calt" 1;
font-size: 16px;
letter-spacing: 0.15px;
}
}
div.nextjs-toast-errors-parent[data-nextjs-toast="true"] {
display: none;
}
.font-heading {
@apply uppercase;
}
/* Specific style fixes for react-square-web-payments-sdk -
* reduce gap between card input and pay button
*/
#rswps-card-container {
min-height: unset !important;
}
.sq-card-wrapper .sq-card-message {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.element-container:hover .icon-container-nor {
display: none;
}
.icon-container-hov {
display: none;
}
.element-container:hover .icon-container-hov {
display: block !important;
}
.element-container:hover .text-container {
background: transparent;
}
.overflow-scroll::-webkit-scrollbar{
display:none;
}
@@ -0,0 +1,18 @@
import type { TwFontDesc } from '@hanzo/ui/tailwind'
import luxTwFonts from './lux-tw-fonts'
export const fontFamily = (ignoreTheme: any): {
[key: string]: string[]
} => {
let result: any = {}
luxTwFonts.forEach((font: TwFontDesc) => {
result[font.twName] = font.fontFamily
// eg: heading: ['var(--font-druk-text-wide)']
})
return result as {
[key: string]: string[]
}
}
+2
View File
@@ -0,0 +1,2 @@
export { default as preset } from './tailwind.config.lux-preset'
+40
View File
@@ -0,0 +1,40 @@
import type { TwFontDesc } from '@hanzo/ui/tailwind'
/* NOTE: /next/load-and-return....ts depends on this file! */
export default [
{
fontFamily: ['var(--font-inter)'], // do not provide fall-backs due to next bug
cssVar: '--font-inter',
twName: 'sans'
},
{
fontFamily: ['var(--font-druk-text-wide)'], // do not provide fall-backs due to next bug
cssVar: '--font-druk-text-wide',
twName: 'nav'
},
{
fontFamily: ['var(--font-druk-wide)'], // do not provide fall-backs due to next bug
cssVar: '--font-druk-wide',
twName: 'heading'
},
{
twName: 'serif',
fontFamily: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times']
},
{
twName: 'mono',
fontFamily: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
]
}
] as TwFontDesc[]
@@ -0,0 +1,10 @@
import type { Config } from 'tailwindcss'
import { preset } from '@hanzo/ui/tailwind'
import { fontFamily } from './fontFamily.tailwind.lux'
export default {
presets: [preset],
content: [],
theme: { fontFamily }
} satisfies Config
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@luxbridge/app-v3",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"start": "remix-serve ./build/server/index.js",
"tc": "tsc",
"typecheck": "tsc",
"clean": "rm -rf ./node_modules && rm -rf ./vercel",
"clean:all": "pnpm -w clean:all && rm -rf ./vercel"
},
"sideEffects": false,
"type": "module",
"dependencies": {
"@hanzo/ui": "catalog:",
"@luxfi/core": "catalog:",
"@luxfi/menu-icons": "1.0.2",
"@remix-run/node": "^2.15.1",
"@remix-run/react": "^2.15.1",
"@remix-run/route-config": "^2.15.2",
"@remix-run/serve": "^2.15.1",
"@vercel/analytics": "^1.4.1",
"@vercel/remix": "^2.15.1",
"axios": "^1.7.9",
"isbot": "^4",
"lucide-react": "catalog:",
"mobx": "^6.12.3",
"mobx-react-lite": "^4.0.7",
"mobx-utils": "^6.1.0",
"motion": "^11.15.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-social-icons": "^6.18.0",
"use-debounce": "^10.0.4"
},
"devDependencies": {
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@remix-run/dev": "^2.15.1",
"@remix-run/server-runtime": "^2.15.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "5.4.11",
"vite-tsconfig-paths": "5.1.2"
},
"engines": {
"node": "20.x"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+77
View File
@@ -0,0 +1,77 @@
{
"brand": {
"name": "Lux Exchange",
"title": "Lux Exchange | Trade",
"description": "Multi-chain decentralized exchange powered by Lux Network",
"shortName": "LUX",
"labsName": "Lux Labs",
"legalEntity": "Lux Industries Inc.",
"walletName": "LUX Wallet",
"protocolName": "LUX Protocol",
"copyrightHolder": "Lux Industries Inc.",
"appDomain": "lux.exchange",
"docsDomain": "docs.lux.exchange",
"infoDomain": "info.lux.exchange",
"gatewayDomain": "gw.lux.exchange",
"wsDomain": "ws.lux.exchange",
"helpUrl": "https://docs.lux.exchange/help",
"termsUrl": "https://lux.exchange/terms",
"privacyUrl": "https://lux.exchange/privacy",
"downloadUrl": "https://lux.exchange/wallet",
"complianceEmail": "[email protected]",
"supportEmail": "[email protected]",
"twitter": "https://x.com/luxfi",
"farcaster": "",
"linkedin": "",
"tiktok": "",
"github": "https://github.com/luxfi",
"discord": "https://discord.gg/lux",
"logoUrl": "https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/assets/logo/logo.svg",
"faviconUrl": "https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/assets/logo/favicon.png",
"primaryColor": "#000000",
"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, 96367, 200200, 1, 42161, 8453, 137, 43114]
},
"rpc": {
"96369": "https://api.lux.network/ext/bc/C/rpc",
"96368": "https://api.lux-test.network/ext/bc/C/rpc",
"96367": "https://api.lux-dev.network/ext/bc/C/rpc",
"200200": "https://api.zoo.network/ext/bc/C/rpc",
"200201": "https://api.zoo-test.network/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",
"43114": "https://api.avax.network/ext/bc/C/rpc"
},
"api": {
"graphql": "https://api.lux.exchange/v1/graphql",
"gateway": "https://gw.lux.exchange",
"insights": "https://insights.hanzo.ai"
},
"iam": {
"provider": "https://lux.id",
"clientId": "lux-exchange"
},
"walletConnect": {
"projectId": ""
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 B

+39
View File
@@ -0,0 +1,39 @@
import type { Config } from 'tailwindcss'
import { preset } from './app/tailwind'
const fontFamily = {
sans: 'inter',
heading: 'DrukWide',
nav: 'DrukWide',
serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times'],
mono: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
]
}
export default {
presets: [preset],
content: {
files: [
'app/**/*.tsx',
'./node_modules/@hanzo/**/*.{ts,tsx}'
]
},
theme: {
fontFamily,
extend: {
colors: {
white: "#ffffff",
black: "#000000",
gray: "#e5e7eb",
}
},
}
} satisfies Config
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
// All settings are as per remix rec's
// https://remix.run/docs/hi/main/guides/typescript
"target": "ES2022",
"jsx": "react-jsx",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [ "./app/*" ]
},
"types": [
"@vercel/remix",
"vite/client",
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
]
}
+49
View File
@@ -0,0 +1,49 @@
import { vitePlugin as remix } from '@remix-run/dev'
import { vercelPreset } from '@vercel/remix/vite'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import { viteCommonjs } from '@originjs/vite-plugin-commonjs'
export default defineConfig({
define: {
'process.env': {}
},
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true, // cf: https://github.com/remix-run/react-router/pull/12441
v3_lazyRouteDiscovery: false // silences warning
},
presets: [vercelPreset()],
}),
tsconfigPaths(),
viteCommonjs(),
],
optimizeDeps: {
include: ['react-dom'],
// Not excluding these seem to:
// a) always force a refresh after initial load: https://github.com/vitejs/vite/discussions/14801)
// b) optimize an old version!
exclude: [
'@hanzo/ui/primitives-common',
'@hanzo/ui/util',
]
},
// https://github.com/remix-run/remix/issues/10156#issuecomment-2440234744
server: {
warmup: {
clientFiles: ['app/**/*.tsx'],
},
},
build: {
sourcemap: true, // Enable source maps in production build
rollupOptions: {
output: {
sourcemapExcludeSources: false, // Include source content in the map
},
},
},
})
+32 -2
View File
@@ -5,7 +5,34 @@
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
"scope": "@luxfi"
"scope": "@luxfi",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./Bridge": {
"types": "./dist/Bridge.d.ts",
"import": "./dist/Bridge.js",
"default": "./dist/Bridge.js"
},
"./config": {
"types": "./dist/config.d.ts",
"import": "./dist/config.js",
"default": "./dist/config.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js",
"default": "./dist/types.js"
},
"./styles.css": "./dist/app/styles/theme.css",
"./package.json": "./package.json"
}
},
"author": "Lux Industries, Inc.",
"license": "MIT",
@@ -33,17 +60,20 @@
"./package.json": "./package.json"
},
"files": [
"dist",
"src",
"public",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc --noEmit",
"clean": "rm -rf dist",
"build": "pnpm run clean && tsc -p tsconfig.build.json && cp -r src/app/styles dist/app/styles",
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lat": "npm show @luxfi/bridge version",
"prepublishOnly": "pnpm run build",
"pub": "npm publish"
},
"dependencies": {
+36
View File
@@ -30,6 +30,8 @@ import * as gui from '@hanzo/gui'
import { applyBrandMetadata, setConfig } from './config'
import type { BridgeConfig } from './types'
import { BridgeApp } from './app/BridgeApp'
import { setRpcClient } from './app/lib/bridge-api'
import { BridgeRPCClient } from './app/lib/bridge-rpc'
const GuiProvider: FC<{
children: ReactNode
@@ -54,6 +56,40 @@ export const Bridge: FC<BridgeProps> = ({ config }) => {
applyBrandMetadata(config.brand)
}, [config.brand])
// Install (or tear down) the direct-RPC client based on cfg.rpc. When
// bchainUrl is set, the bridge-api dispatcher routes quote/submit/status
// through BridgeVM JSON-RPC and falls back to REST on failure (default
// policy). When bchainUrl is absent the SDK runs in pure-REST mode.
useEffect(() => {
const bchain = config.rpc?.bchainUrl
if (!bchain) {
setRpcClient(null)
return
}
const init = {
bridgeRpcUrl: bchain,
...(config.rpc?.tchainUrl
? { thresholdRpcUrl: config.rpc.tchainUrl }
: config.mpc?.publicUrl
? { thresholdRpcUrl: config.mpc.publicUrl }
: {}),
...(config.rpc?.timeoutMs !== undefined
? { timeoutMs: config.rpc.timeoutMs }
: {}),
}
const client = new BridgeRPCClient(init)
setRpcClient(client, config.rpc?.fallback ?? 'rest')
return () => {
setRpcClient(null)
}
}, [
config.rpc?.bchainUrl,
config.rpc?.tchainUrl,
config.rpc?.fallback,
config.rpc?.timeoutMs,
config.mpc?.publicUrl,
])
// `GuiProvider` is required for @hanzo/gui's `Button` / `Input` primitives
// (used in SwapForm, WalletConnect, AssetInput) to find their theme. The
// SDK owns this wrapper so consumers don't have to know it exists — the
@@ -0,0 +1,36 @@
// Shared React-Query / Wagmi wrapper for renderHook tests.
//
// useNetworks (consumed transitively by useSwap and useTransfers) calls
// useQuery, which requires a QueryClientProvider in the render tree.
// Production wiring lives in BridgeApp.tsx; tests need their own.
//
// retry: false + gcTime: 0 so failed fetches don't spend the test budget
// retrying, and the cache is dropped between tests for isolation.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { FC, ReactNode } from 'react'
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
},
})
}
/**
* Build a fresh wrapper per test. Important: the returned component is a
* new closure every call, so React-Query won't share cache across tests
* unless the caller deliberately reuses one instance.
*/
export function makeTestWrapper(): FC<{ children: ReactNode }> {
const queryClient = createTestQueryClient()
const Wrapper: FC<{ children: ReactNode }> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return Wrapper
}

Some files were not shown because too many files have changed in this diff Show More