feat: testnet configuration set, updated deposit workflow with mpc workflow, not teleport

This commit is contained in:
venuswhispers
2026-05-25 18:20:04 +02:00
parent 0ff373526e
commit 202785aff8
12 changed files with 687 additions and 47 deletions
+175
View File
@@ -0,0 +1,175 @@
# Phase 1.3 sign-off — end-to-end testnet bridge
Goal: flip `REQUIREMENTS.md` §7, row 1.3 from 🟡 to ✅. The acceptance bar
is one signed bridge transaction from Sepolia → Lux Testnet that the m-chain
MPC quorum completes.
This is a one-shot human-in-the-loop verification. It's not part of CI —
running this requires a real wallet + faucet funds, so it lives outside the
automated test suite.
## Code state (verified before this doc was written)
These are pre-conditions the SDK + tenant satisfy as of the current branch:
- `useNetworks.ts` sends `?version=${cfg.env}` to `/api/networks`, so the
server returns the testnet registry instead of mainnet defaults.
- `wagmi-config.ts` ships testnet viem chains
(`sepolia`, `arbitrumSepolia`, `baseSepolia`, `optimismSepolia`,
`polygonAmoy`, `holesky`, `bscTestnet`) when `cfg.env === 'testnet' || 'devnet'`,
so the wagmi connector can `switchChain(11155111)` without rejecting.
- `app/bridge/src/bridge.config.ts` picks `[11155111, 84532, 17000]` as the
fallback wallet allow-list and `defaultChainId = 11155111` when
`BRIDGE_ENV=testnet`.
- Visual smoke: `app/server/result-img/testnet-{landing,chains}.png` show the
TESTNET header chip and 10 testnet chains in the picker.
- Backend deps verified live: `bridge-api.lux.network/api/networks?version=testnet`
returns 10 active testnet networks; `/api/quote` accepts
`ETHEREUM_SEPOLIA → LUX_TESTNET`; `mpc.lux.network` returns 200; Lux
testnet C-Chain RPC at `api.lux-test.network/ext/bc/C/rpc` returns
chainId `0x17870` (96368); Sepolia public RPC returns chainId `0xaa36a7`
(11155111).
## Prerequisites
| Need | How to get it |
|---|---|
| EIP-6963 wallet (MetaMask, Rabby, Brave) | Browser extension |
| Sepolia ETH (≥ 0.05 recommended) | https://www.alchemy.com/faucets/ethereum-sepolia or https://sepoliafaucet.com (any modern faucet — usually requires GitHub or Alchemy login) |
| Recipient address on Lux Testnet | Lux Testnet is EVM chain 96368; reuse your MetaMask address — same hex string works as the destination |
| (Optional) Lux Testnet RPC added to MetaMask | RPC `https://api.lux-test.network/ext/bc/C/rpc`, chainId `96368`, symbol `LUX`, explorer `https://explore.lux-test.network/` — lets you confirm the destination balance after settlement |
## Run the app
From the repo root:
```bash
VITE_BRIDGE_ENV=testnet pnpm -C app/bridge dev
```
Vite listens on `http://localhost:3001` (falls back to `:3002` if busy).
The dev proxy forwards `/api/*` to `bridge-api.lux.network` with the
production `Origin` header injected — CORS allow-list passes without
deploying anything.
If you want a `BRIDGE_*` runtime path instead of the build-time
`VITE_BRIDGE_*` path, build the docker image and run it locally with the
operator runbook in `DEPLOY.md` §3 "Smoke-test the image locally", but pass
`-e BRIDGE_ENV=testnet`.
## Walkthrough
Each step has a checkbox that must be true to proceed. Stop at the first
failure and capture the symptom — silent fall-through to mainnet defaults
is the failure mode this doc is designed to catch.
### 1. Render check
Open `http://localhost:3001/` in a fresh incognito window.
- [ ] Header reads `LUX Bridge` + a `TESTNET` chip + `MPC · CGGMP21` chip.
If the chip says `MAINNET`, the env var didn't reach `import.meta.env`
— restart Vite with the prefix.
- [ ] Devtools → Network → first request to `/api/networks` carries
`?version=testnet`. If the query string is absent the SDK regressed —
see `pkg/bridge/src/app/hooks/useNetworks.ts`.
- [ ] Footer says `Network: testnet`.
### 2. Chain registry
Click the **FROM** chain selector.
- [ ] Dropdown shows at least: `Ethereum Sepolia`, `Base Sepolia`,
`Holesky Testnet`, `Lux Testnet`, `Zoo Testnet`, `BSC Testnet`,
`Bitcoin Testnet`, `Solana Devnet`, `Ton Testnet`, `XRP Testnet`.
- [ ] Dropdown does **not** show `Ethereum`, `Base`, `Polygon`, `Arbitrum
One`, `Optimism`, or any other mainnet entry. If any mainnet row
appears, the `is_testnet` boundary in `network-mapper.ts` regressed.
Pick **FROM = Ethereum Sepolia, ETH**. Pick **TO = Lux Testnet, LUX**.
### 3. Quote
Enter `0.01` in the **You send** amount field.
- [ ] A receive amount renders within ~1 s in the **You receive** row.
- [ ] Network tab shows `GET /api/quote?source_network=ETHEREUM_SEPOLIA&source_token=ETH&destination_network=LUX_TESTNET&destination_token=LUX&amount=0.01&refuel=0`.
- [ ] No `Failed to fetch` errors in the console. (If you see one, check
that the dev proxy is still up and the upstream backend is healthy
at https://bridge-api.lux.network/api/networks?version=testnet.)
### 4. Wallet
Click **Connect Wallet**. The picker (`WalletConnect.tsx` portal modal)
should open.
- [ ] An "Installed" group is shown if you have a browser-extension wallet —
MetaMask (or whichever) is listed with a "Detected" badge.
- [ ] A "Popular" group lists Coinbase Wallet (and WalletConnect if
`VITE_WC_PROJECT_ID` is set).
Click MetaMask (or your extension).
- [ ] Wallet popup asks to connect.
- [ ] After approval, the picker closes; the header **Connect Wallet** button
is replaced by your truncated address.
- [ ] If MetaMask is on a different chain it should prompt to switch to
**Sepolia (chainId 11155111)**. Approve.
### 5. Submit + sign + settle
Type your own Sepolia address into the **Destination address** field (or
toggle whichever "use connected wallet" affordance the UI exposes).
Click **Bridge** (or whatever the active CTA reads — it should not say
"Source and destination must differ" any longer).
- [ ] MetaMask opens a transaction popup for the Sepolia deposit. The
`to:` is the bridge deposit address surfaced by the server; the
value is `0.01 ETH`.
- [ ] Sign the transaction. A tx hash appears in the **Transfers** card
and is clickable to https://sepolia.etherscan.io/tx/<hash>.
- [ ] The transfer status advances through:
`user_transfer_pending → bridge_transfer_pending_signing → bridge_transfer_pending_broadcasting → completed`.
(Approximate phase names — the UI may render them as
`Depositing → Signing → Broadcasting → Settled` or similar.)
- [ ] The transfer card shows MPC session info:
`sessionId`, a non-empty `status`, and eventually a `signature` hex.
This is wired through `pkg/bridge/src/app/lib/mpc-session.ts`
against `https://mpc.lux.network` (`BRIDGE_MPC_PUBLIC_URL`).
- [ ] When the phase reaches `completed`, the destination address holds
the corresponding LUX on Lux Testnet — confirm with
`curl -s -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0xYOUR_ADDR","latest"]}' \
https://api.lux-test.network/ext/bc/C/rpc`.
### 6. Treasury / fee path (optional)
If `BRIDGE_MPC_PRIVATE_URL` is configured in your env, also verify the
private-cluster fee-collection signature fires on the same transfer.
For most external sign-offs leave this off — Phase 1.3 cares about the
public m-chain quorum only.
## Sign-off
If every box in §1–§5 is ticked:
1. Update `REQUIREMENTS.md` §7 row 1.3 status from `🟡` to `✅`. Add a
short evidence line under the row: testnet date, Sepolia tx hash,
Lux Testnet recipient address, MPC session id.
2. Commit with the screenshots from `app/server/result-img/testnet-*.png`
attached if you captured fresh ones during the run.
3. Phase 1.3 is now closed; Phase 1.4 (`bridge.lux.network` deploy) can
proceed without this gate.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Header chip says `MAINNET` | `VITE_BRIDGE_ENV` not present at Vite startup, or `window.__ENV.BRIDGE_ENV` got templated wrong | Restart `pnpm dev` with the prefix; check `/__ENV.js` in container mode |
| 22 mainnet chains in the picker | `?version` query param missing from `/api/networks` request | `useNetworks.ts` regressed — check the URL in devtools |
| `chain not configured` from wagmi on connect | `wagmi-config.ts` regressed to mainnet-only viem chain set | Re-add testnet imports + branch on `cfg.env` |
| Quote returns but submit fails with `unknown destination network` | Server expects `LUX_TESTNET`, not `LUX_MAINNET` — possibly a chain-id-to-internal-name mapping issue | Inspect the POST body; the `internalName` comes from the API row, not the bridge-api.ts dead-code map (see `chainIdToInternalName` — currently unused but mis-encoded) |
| MPC session stays at `pending` forever | `mpc.lux.network` unreachable, or `BRIDGE_MPC_PUBLIC_URL` set to something else | `curl -I https://mpc.lux.network/` — should be HTTP 200 |
| Sepolia deposit tx never confirms | Sepolia is congested / your gas was too low | Speed up via MetaMask; bridge picks up on confirmation, not on broadcast |
| Final balance never arrives on Lux Testnet | Bridge backend stuck mid-pipeline | `curl https://bridge-api.lux.network/api/swaps/<id>` for the server's view of the swap state |
+16 -7
View File
@@ -84,12 +84,21 @@ const fallbackMpcPublicUrl = 'https://mpc.lux.network'
const fallbackMpcProtocol: NonNullable<BridgeMPCConfig['protocol']> = 'cggmp21' const fallbackMpcProtocol: NonNullable<BridgeMPCConfig['protocol']> = 'cggmp21'
// Curated EVM allow-list for the Lux tenant. The SDK's full supported set // Curated EVM allow-list for the Lux tenant. The SDK's full supported set
// (mainnet, arbitrum, base, polygon, optimism) is intentionally narrowed // (mainnet, arbitrum, base, polygon, optimism, bsc for mainnet; sepolia,
// here so the user wallet only sees chains the bridge backend actually // arbitrumSepolia, baseSepolia, optimismSepolia, polygonAmoy, holesky,
// settles to. Non-EVM destinations (Lux native, Solana) are handled by the // bscTestnet for testnet) is intentionally narrowed here so the user wallet
// MPC threshold layer and don't appear in the wagmi connector. // only sees chains the bridge backend actually settles to. Non-EVM
const fallbackSupportedChainIds = [1, 42161, 8453, 137, 10] // destinations (Lux native, Solana, BTC, TON, XRP) are handled by the MPC
const fallbackDefaultChainId = 1 // threshold layer and don't appear in the wagmi connector.
//
// Env-aware: BRIDGE_ENV=testnet → Sepolia / Base Sepolia / Holesky;
// BRIDGE_ENV=mainnet (default) → the production EVM allow-list.
const envSlug = env('BRIDGE_ENV', 'mainnet') ?? 'mainnet'
const isTestnetEnv = envSlug === 'testnet' || envSlug === 'devnet'
const fallbackSupportedChainIds = isTestnetEnv
? [11155111, 84532, 17000] // Sepolia, Base Sepolia, Holesky — the testnet pairs the bridge backend currently quotes.
: [1, 42161, 8453, 137, 10]
const fallbackDefaultChainId = isTestnetEnv ? 11155111 : 1
/** /**
* Parse a comma-separated list of numeric chain IDs from env, dropping * Parse a comma-separated list of numeric chain IDs from env, dropping
@@ -157,7 +166,7 @@ const devApiHost =
export const bridgeConfig: BridgeConfig = { export const bridgeConfig: BridgeConfig = {
apiHost: devApiHost ?? env('BRIDGE_API_HOST', fallbackApiHost)!, apiHost: devApiHost ?? env('BRIDGE_API_HOST', fallbackApiHost)!,
env: env('BRIDGE_ENV', 'mainnet')!, env: envSlug,
clientId: env('BRIDGE_CLIENT_ID'), clientId: env('BRIDGE_CLIENT_ID'),
iamOrg: env('BRIDGE_IAM_ORG', 'lux'), iamOrg: env('BRIDGE_IAM_ORG', 'lux'),
brand: { brand: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+170
View File
@@ -0,0 +1,170 @@
{
"_meta": {
"description": "Canonical bridge deposit addresses for every supported chain. EVM chains use static teleporter + vault contracts; non-EVM chains use per-swap MPC-derived addresses returned by POST /api/swaps and have no static address to publish here.",
"generated_at": "2026-05-25",
"sources": {
"evm_contracts": "app/server/src/domain/contracts/teleport.ts",
"non_evm_address_types": "app/server/src/domain/mpc-wallet.ts",
"wallet_factory": "app/server/src/domain/mpc-wallet.ts::createMPCWalletForDeposit"
},
"notes": [
"For EVM chains, the `teleporter` is the inbound contract users deposit to and the `vault` is the funds-holding contract.",
"Empty-string entries mean the chain is configured server-side but no deposit contract is currently deployed (Gnosis, Fantom, Aurora).",
"For non-EVM chains, a fresh address is derived from the MPC keys at swap-creation time. The static_address field is therefore null; address_type names the signature scheme."
]
},
"evm": {
"mainnet": [
{
"chain": "LUX_MAINNET",
"chain_id": 96369,
"teleporter": "0x5B562e80A56b600d729371eB14fE3B83298D0642",
"vault": "0x08c0f48517C6d94Dd18aB5b132CA4A84FB77108e"
},
{
"chain": "ZOO_MAINNET",
"chain_id": 200200,
"teleporter": "0x5B562e80A56b600d729371eB14fE3B83298D0642",
"vault": "0x08c0f48517C6d94Dd18aB5b132CA4A84FB77108e"
},
{
"chain": "ETHEREUM_MAINNET",
"chain_id": 1,
"teleporter": "0xebD1Ee9BCAaeE50085077651c1a2dD452fc6b72e",
"vault": "0xcf963Fe4E4cE126047147661e6e06e171f366506"
},
{
"chain": "POLYGON_MAINNET",
"chain_id": 137,
"teleporter": "0xE09C9b6Ed2BADAa97AB00652dF75da05adc6dAeF",
"vault": "0x217feE2a1a6A31Dda68433270531F56C91EC8D2B"
},
{
"chain": "OPTIMISM_MAINNET",
"chain_id": 10,
"teleporter": "0xbdCE894aEd7d30BA0C0D0B51604ee9d225fc8b95",
"vault": "0x37d9fB96722ebDDbC8000386564945864675099B"
},
{
"chain": "ARBITRUM_MAINNET",
"chain_id": 42161,
"teleporter": "0xA60429080752484044e529012aA46e1D691f50Ab",
"vault": "0xE6e3E18F86d5C35ec1E24c0be8672c8AA9989258"
},
{
"chain": "CELO_MAINNET",
"chain_id": 42220,
"teleporter": "0xbdCE894aEd7d30BA0C0D0B51604ee9d225fc8b95",
"vault": "0x37d9fB96722ebDDbC8000386564945864675099B"
},
{
"chain": "BASE_MAINNET",
"chain_id": 8453,
"teleporter": "0x37d9fB96722ebDDbC8000386564945864675099B",
"vault": "0x3226bb1d3055685EFC1b0E49718B909a1c6Ce18d"
},
{
"chain": "BSC_MAINNET",
"chain_id": 56,
"teleporter": "0xebD1Ee9BCAaeE50085077651c1a2dD452fc6b72e",
"vault": "0xcf963Fe4E4cE126047147661e6e06e171f366506"
},
{
"chain": "AVAX_MAINNET",
"chain_id": 43114,
"teleporter": "0x3226bb1d3055685EFC1b0E49718B909a1c6Ce18d",
"vault": "0x6149D8C44Ec9a4a683d14E494a73B1b11ac331A8"
},
{
"chain": "ZORA_MAINNET",
"chain_id": 7777777,
"teleporter": "0xbdCE894aEd7d30BA0C0D0B51604ee9d225fc8b95",
"vault": "0x37d9fB96722ebDDbC8000386564945864675099B"
},
{
"chain": "BLAST_MAINNET",
"chain_id": 81457,
"teleporter": "0xbdCE894aEd7d30BA0C0D0B51604ee9d225fc8b95",
"vault": "0x37d9fB96722ebDDbC8000386564945864675099B"
},
{
"chain": "LINEA_MAINNET",
"chain_id": 59144,
"teleporter": "0x1e48d32a4f5e9f08db9ae4959163300faf8a6c8e",
"vault": "0x3226bb1d3055685EFC1b0E49718B909a1c6Ce18d"
},
{
"chain": "GNOSIS_MAINNET",
"chain_id": 100,
"teleporter": "",
"vault": "",
"note": "Configured server-side but no deposit contract deployed yet."
},
{
"chain": "FANTOM_MAINNET",
"chain_id": 250,
"teleporter": "",
"vault": "",
"note": "Configured server-side but no deposit contract deployed yet."
},
{
"chain": "AURORA_MAINNET",
"chain_id": 1313161554,
"teleporter": "",
"vault": "",
"note": "Configured server-side but no deposit contract deployed yet."
}
],
"testnet": [
{
"chain": "ETHEREUM_SEPOLIA",
"chain_id": 11155111,
"teleporter": "0xb34e8a93A56De724c432fD41052E715657Fb0B1D",
"vault": "0x63139CFe43cEe881332a2264851940108333e8D0"
},
{
"chain": "HOLESKY_TESTNET",
"chain_id": 17000,
"teleporter": "0x2951A9386df11a4EA8ae5A823B94DC257dEb35Cb",
"vault": "0xebD1Ee9BCAaeE50085077651c1a2dD452fc6b72e"
},
{
"chain": "BASE_SEPOLIA",
"chain_id": 84532,
"teleporter": "0xE139056397091c15F860E94b20cCc169520b086A",
"vault": "0x2c4908f1B607A4448272ea29A054bf9AA08A1D76"
},
{
"chain": "BSC_TESTNET",
"chain_id": 97,
"teleporter": "0xD293337c79A951C0e93aEb6a5E255097d65b671f",
"vault": "0xe0feC703840364714b97272973B8945FD5eB5600"
},
{
"chain": "LUX_TESTNET",
"chain_id": 96368,
"teleporter": "0x5B562e80A56b600d729371eB14fE3B83298D0642",
"vault": "0x08c0f48517C6d94Dd18aB5b132CA4A84FB77108e"
},
{
"chain": "ZOO_TESTNET",
"chain_id": 200201,
"teleporter": "0x5B562e80A56b600d729371eB14fE3B83298D0642",
"vault": "0x08c0f48517C6d94Dd18aB5b132CA4A84FB77108e"
}
]
},
"non_evm": [
{ "chain": "BITCOIN_MAINNET", "address_type": "btc", "static_address": null },
{ "chain": "BITCOIN_TESTNET", "address_type": "btc", "static_address": null },
{ "chain": "SOLANA_MAINNET", "address_type": "sol", "static_address": null },
{ "chain": "SOLANA_DEVNET", "address_type": "sol", "static_address": null },
{ "chain": "SOLANA_TESTNET", "address_type": "sol", "static_address": null },
{ "chain": "TON_MAINNET", "address_type": "ton", "static_address": null },
{ "chain": "TON_TESTNET", "address_type": "ton", "static_address": null },
{ "chain": "XRP_MAINNET", "address_type": "xrp", "static_address": null },
{ "chain": "XRP_TESTNET", "address_type": "xrp", "static_address": null },
{ "chain": "POLKADOT_MAINNET", "address_type": "dot", "static_address": null },
{ "chain": "CARDANO_MAINNET", "address_type": "sol", "static_address": null, "note": "placeholder — needs Ed25519 finalisation in mpc-wallet.ts" }
]
}
@@ -0,0 +1,208 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//
// useNetworks fetches `${apiHost}/api/networks?version=…` and transforms the
// response into the SDK's flat chains/assets shape. The version query param
// is load-bearing — without it the bridge backend defaults to mainnetSettings
// even when the SDK is configured for env=testnet, which masks the entire
// testnet network registry. These tests pin that behaviour.
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setConfig } from '../config'
import { useNetworks } from '../app/hooks/useNetworks'
import { makeTestWrapper } from './test-providers'
const originalFetch = global.fetch
interface ApiNetworkJson {
display_name: string
internal_name: string
logo: string | null
native_currency: string
is_testnet: boolean
is_featured: boolean
average_completion_time: string
chain_id: string | null
type: string
status: string
transaction_explorer_template: string
account_explorer_template: string
currencies: Array<{
name: string
asset: string
logo: string | null
contract_address: string | null
decimals: number
status: string
is_deposit_enabled: boolean
is_withdrawal_enabled: boolean
is_refuel_enabled: boolean
}>
}
const sepolia: ApiNetworkJson = {
display_name: 'Ethereum Sepolia',
internal_name: 'ETHEREUM_SEPOLIA',
logo: null,
native_currency: 'ETH',
is_testnet: true,
is_featured: true,
average_completion_time: '00:03:00',
chain_id: '11155111',
type: 'evm',
status: 'active',
transaction_explorer_template: 'https://sepolia.etherscan.io/tx/{0}',
account_explorer_template: 'https://sepolia.etherscan.io/address/{0}',
currencies: [
{
name: 'ETH',
asset: 'ETH',
logo: null,
contract_address: null,
decimals: 18,
status: 'active',
is_deposit_enabled: true,
is_withdrawal_enabled: true,
is_refuel_enabled: false,
},
],
}
const luxTestnet: ApiNetworkJson = {
display_name: 'Lux Testnet',
internal_name: 'LUX_TESTNET',
logo: null,
native_currency: 'LUX',
is_testnet: true,
is_featured: true,
average_completion_time: '00:03:00',
chain_id: '96368',
type: 'evm',
status: 'active',
transaction_explorer_template: 'https://explore.lux-test.network/tx/{0}',
account_explorer_template: 'https://explore.lux-test.network/address/{0}',
currencies: [
{
name: 'LUX',
asset: 'LUX',
logo: null,
contract_address: null,
decimals: 18,
status: 'active',
is_deposit_enabled: true,
is_withdrawal_enabled: true,
is_refuel_enabled: false,
},
],
}
const ethereumMainnet: ApiNetworkJson = {
display_name: 'Ethereum',
internal_name: 'ETHEREUM_MAINNET',
logo: null,
native_currency: 'ETH',
is_testnet: false,
is_featured: true,
average_completion_time: '00:03:00',
chain_id: '1',
type: 'evm',
status: 'active',
transaction_explorer_template: 'https://etherscan.io/tx/{0}',
account_explorer_template: 'https://etherscan.io/address/{0}',
currencies: [
{
name: 'ETH',
asset: 'ETH',
logo: null,
contract_address: null,
decimals: 18,
status: 'active',
is_deposit_enabled: true,
is_withdrawal_enabled: true,
is_refuel_enabled: false,
},
],
}
function mockNetworksResponse(rows: ApiNetworkJson[]): ReturnType<typeof vi.fn> {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ data: rows }),
text: () => Promise.resolve(JSON.stringify({ data: rows })),
})
global.fetch = fetchSpy as unknown as typeof fetch
return fetchSpy
}
afterEach(() => {
global.fetch = originalFetch
vi.useRealTimers()
})
describe('useNetworks', () => {
beforeEach(() => {
setConfig({ apiHost: 'https://api.example.test', env: 'mainnet' })
})
it('requests version=mainnet for env=mainnet', async () => {
const fetchSpy = mockNetworksResponse([ethereumMainnet])
const { result } = renderHook(() => useNetworks(), {
wrapper: makeTestWrapper(),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(fetchSpy).toHaveBeenCalledTimes(1)
const [calledUrl] = fetchSpy.mock.calls[0] as [string]
expect(calledUrl).toBe(
'https://api.example.test/api/networks?version=mainnet',
)
expect(result.current.chains.map((c) => c.internalName)).toContain(
'ETHEREUM_MAINNET',
)
})
it('requests version=testnet for env=testnet and surfaces testnet chains', async () => {
setConfig({ apiHost: 'https://api.example.test', env: 'testnet' })
const fetchSpy = mockNetworksResponse([sepolia, luxTestnet])
const { result } = renderHook(() => useNetworks(), {
wrapper: makeTestWrapper(),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
const [calledUrl] = fetchSpy.mock.calls[0] as [string]
expect(calledUrl).toBe(
'https://api.example.test/api/networks?version=testnet',
)
const internalNames = result.current.chains.map((c) => c.internalName)
expect(internalNames).toEqual(
expect.arrayContaining(['ETHEREUM_SEPOLIA', 'LUX_TESTNET']),
)
// Mainnet rows must not bleed through the env filter.
expect(internalNames).not.toContain('ETHEREUM_MAINNET')
})
it('falls back to bundled DEFAULT_CHAINS when the API yields zero rows', async () => {
setConfig({ apiHost: 'https://api.example.test', env: 'testnet' })
// Server hands us only mainnet rows — they should be filtered out, and
// we should fall back to DEFAULT_CHAINS rather than render an empty picker.
mockNetworksResponse([ethereumMainnet])
const { result } = renderHook(() => useNetworks(), {
wrapper: makeTestWrapper(),
})
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.chains.length).toBeGreaterThan(0)
})
it('requests version=devnet for env=devnet', async () => {
setConfig({ apiHost: 'https://api.example.test', env: 'devnet' })
const fetchSpy = mockNetworksResponse([])
renderHook(() => useNetworks(), { wrapper: makeTestWrapper() })
await waitFor(() => expect(fetchSpy).toHaveBeenCalled())
const [calledUrl] = fetchSpy.mock.calls[0] as [string]
expect(calledUrl).toBe(
'https://api.example.test/api/networks?version=devnet',
)
})
})
@@ -120,7 +120,10 @@ describe('useTransfers', () => {
source_network: 'LUX_MAINNET', source_network: 'LUX_MAINNET',
destination_network: 'ETHEREUM_MAINNET', destination_network: 'ETHEREUM_MAINNET',
destination_address: '0xabc1234567890abcdef1234567890abcdef12345', destination_address: '0xabc1234567890abcdef1234567890abcdef12345',
use_teleporter: true, // LUX leg → teleporter // Pure MPC flow: every source gets an MPC-derived deposit address;
// the teleporter-contract dispatch is off the happy path.
use_deposit_address: true,
use_teleporter: false,
app_name: 'TestBridge', app_name: 'TestBridge',
}) })
@@ -44,6 +44,22 @@ describe('buildWagmiConfig', () => {
expect(ids).toContain(8453) expect(ids).toContain(8453)
}) })
it('exposes the testnet EVM set when env=testnet', () => {
const config = buildWagmiConfig({ ...base, env: 'testnet' })
const ids = config.chains.map((c) => c.id)
// Sepolia, Base Sepolia, Holesky must all be present; mainnet chains must not.
expect(ids).toEqual(expect.arrayContaining([11155111, 84532, 17000]))
expect(ids).not.toContain(1)
expect(ids).not.toContain(8453)
})
it('treats env=devnet the same as env=testnet for chain set selection', () => {
const config = buildWagmiConfig({ ...base, env: 'devnet' })
const ids = config.chains.map((c) => c.id)
expect(ids).toContain(11155111)
expect(ids).not.toContain(1)
})
it('narrows chains to supportedChainIds when provided', () => { it('narrows chains to supportedChainIds when provided', () => {
const config = buildWagmiConfig({ const config = buildWagmiConfig({
...base, ...base,
+13 -16
View File
@@ -252,14 +252,15 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
if (cfg.mpc?.utila) cosigners.push('Utila') if (cfg.mpc?.utila) cosigners.push('Utila')
if (cfg.mpc?.fireblocks) cosigners.push('Fireblocks') if (cfg.mpc?.fireblocks) cosigners.push('Fireblocks')
// Source chain family decides the flow: // Every source goes through the MPC deposit-address flow: the server
// - EVM / Lux: wagmi connector signs the deposit tx; wallet required. // mints a fresh MPC-derived address via createMPCWalletForDeposit() and
// - Anything else (BTC, Solana, TON, XRP, Cardano, Polkadot): the // the user pays it from any wallet they own (MetaMask for EVM/Lux,
// server issues a deposit address and the user pays from any wallet // native BTC wallet for Bitcoin, Solana wallet for SOL, etc.). There is
// they own. No wagmi integration needed for those chains. // no wagmi writeContract path on the source side — the SDK never holds
// a private key and never calls a teleporter contract directly.
const sourceIsEvm = const sourceIsEvm =
swap.fromChain.family === 'evm' || swap.fromChain.family === 'lux' swap.fromChain.family === 'evm' || swap.fromChain.family === 'lux'
const needsDepositAddressFlow = !sourceIsEvm const needsDepositAddressFlow = true
// Destination address resolution. We always need an address to deliver // Destination address resolution. We always need an address to deliver
// bridged funds to. EVM destinations default to the connected wallet's // bridged funds to. EVM destinations default to the connected wallet's
@@ -285,9 +286,6 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
if (!swap.quote) return 'Enter an amount' if (!swap.quote) return 'Enter an amount'
if (sourceIsEvm && !wallet.address) return 'Connect wallet to bridge' if (sourceIsEvm && !wallet.address) return 'Connect wallet to bridge'
if (!destOk) return `Enter ${swap.toChain.name} destination address` if (!destOk) return `Enter ${swap.toChain.name} destination address`
if (needsDepositAddressFlow) {
return `Generate ${swap.fromAsset.symbol} deposit address`
}
return `Bridge ${swap.fromAsset.symbol} → ${swap.toAsset.symbol}` return `Bridge ${swap.fromAsset.symbol} → ${swap.toAsset.symbol}`
})() })()
@@ -398,13 +396,12 @@ export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
autoCapitalize="off" autoCapitalize="off"
aria-label="Destination address" aria-label="Destination address"
/> />
{needsDepositAddressFlow ? ( <span style={destHint}>
<span style={destHint}> The bridge will issue an MPC-secured deposit address — send your{' '}
{swap.fromChain.name} is non-EVM — the bridge will issue a {swap.fromAsset.symbol} from any {swap.fromChain.name} wallet to
deposit address for you to send {swap.fromAsset.symbol} from any that address and the {swap.toAsset.symbol} will arrive at your
wallet you own. destination after the threshold quorum signs.
</span> </span>
) : null}
</div> </div>
<div style={refuelRow}> <div style={refuelRow}>
+7 -1
View File
@@ -46,9 +46,15 @@ export function useNetworks(): NetworksState {
const cfg = getConfig() const cfg = getConfig()
const query = useQuery({ const query = useQuery({
queryKey: ['bridge-networks', cfg.apiHost], queryKey: ['bridge-networks', cfg.apiHost, cfg.env],
queryFn: async ({ signal }): Promise<ApiNetwork[]> => { queryFn: async ({ signal }): Promise<ApiNetwork[]> => {
const url = new URL('/api/networks', cfg.apiHost) const url = new URL('/api/networks', cfg.apiHost)
// app/server (routes/networks.ts) selects between mainnetSettings,
// testnetSettings, devnetSettings based on ?version. Without this
// query param the server defaults to mainnet, so a testnet env build
// would silently fetch the mainnet registry and then filter to 0
// rows. Pin the param to cfg.env so the boundary stays explicit.
url.searchParams.set('version', cfg.env)
const resp = await fetch(url.toString(), { signal }) const resp = await fetch(url.toString(), { signal })
if (!resp.ok) { if (!resp.ok) {
throw new Error(`networks fetch failed: HTTP ${resp.status}`) throw new Error(`networks fetch failed: HTTP ${resp.status}`)
+12 -8
View File
@@ -372,13 +372,14 @@ export function useTransfers(): TransferState {
}) })
} }
// Non-EVM source chains MUST use deposit-address mode (we have no // Every source uses the MPC-derived deposit address. The server's
// wagmi connector for Bitcoin/Solana/TON/XRP/Cardano/Polkadot). The // createMPCWalletForDeposit() returns a chain-appropriate address
// caller can also force it on for EVM sources by passing the flag // (ETH for EVM, BTC for Bitcoin, SOL for Solana, etc.) via a single
// explicitly — useful for paste-the-address flows from cold wallets. // MPC keygen, so we don't need a wagmi writeContract path or
const useDepositAddress = // teleporter-contract dispatch on the client. Callers can still pass
input.useDepositAddress ?? // `useDepositAddress: false` to opt back into the legacy teleporter
(fromChain.family !== 'evm' && fromChain.family !== 'lux') // flow on EVM sources, but the SDK default is the MPC flow.
const useDepositAddress = input.useDepositAddress ?? true
try { try {
const swap = await createSwap( const swap = await createSwap(
@@ -395,7 +396,10 @@ export function useTransfers(): TransferState {
...(account.address ? { sender: account.address } : {}), ...(account.address ? { sender: account.address } : {}),
refuel: input.refuel ?? false, refuel: input.refuel ?? false,
useDepositAddress, useDepositAddress,
useTeleporter: fromChain.family === 'lux' || toChain.family === 'lux', // MPC pipeline handles cross-chain delivery end-to-end —
// teleport-processor.ts (background loop watching teleporter
// contracts) is no longer on our happy path.
useTeleporter: false,
appName: input.appName ?? cfg.brand?.name ?? '@luxfi/bridge', appName: input.appName ?? cfg.brand?.name ?? '@luxfi/bridge',
...(cosigners.length > 0 ? { cosigners } : {}), ...(cosigners.length > 0 ? { cosigners } : {}),
}, },
+66 -14
View File
@@ -18,10 +18,18 @@
import { import {
arbitrum, arbitrum,
arbitrumSepolia,
base, base,
baseSepolia,
bsc,
bscTestnet,
holesky,
mainnet, mainnet,
optimism, optimism,
optimismSepolia,
polygon, polygon,
polygonAmoy,
sepolia,
} from 'viem/chains' } from 'viem/chains'
import type { Chain as ViemChain } from 'viem/chains' import type { Chain as ViemChain } from 'viem/chains'
import { createConfig, http, type Config, type CreateConnectorFn } from 'wagmi' import { createConfig, http, type Config, type CreateConnectorFn } from 'wagmi'
@@ -30,18 +38,48 @@ import { coinbaseWallet, injected, walletConnect } from 'wagmi/connectors'
import type { BridgeConfig } from '../../types' import type { BridgeConfig } from '../../types'
/** /**
* EVM chains the bridge can talk to out of the box. * EVM chains the bridge can talk to out of the box, partitioned by env.
* *
* Tenants narrow this via `BridgeConfig.wallet.supportedChainIds`; chains * `mainnet` env builds get production chains; `testnet` env builds get the
* outside the allow-list are dropped from the wagmi config. Non-EVM chains * testnet/devnet equivalents. The bridge backend's `?version=testnet` filter
* (Lux native, Solana, Bitcoin) don't appear here — they're signed via the * gates the chain registry server-side; wagmi must also be configured for
* MPC threshold layer, not the user wallet. * those chains or `switchChain(11155111)` rejects with "chain not configured."
*
* Tenants narrow either set via `BridgeConfig.wallet.supportedChainIds`;
* chains outside the allow-list are dropped from the wagmi config. Non-EVM
* chains (Lux native, Solana, Bitcoin) don't appear here — they're signed
* via the MPC threshold layer, not the user wallet.
*/ */
const ALL_EVM_CHAINS: ViemChain[] = [mainnet, arbitrum, base, polygon, optimism] const MAINNET_EVM_CHAINS: ViemChain[] = [mainnet, arbitrum, base, polygon, optimism, bsc]
const TESTNET_EVM_CHAINS: ViemChain[] = [
sepolia,
arbitrumSepolia,
baseSepolia,
optimismSepolia,
polygonAmoy,
holesky,
bscTestnet,
]
/** Lookup viem Chain by numeric chainId. */ /**
* EVM chains for a given env. `testnet` and `devnet` both use the testnet
* set (devnet currently rides Sepolia / Holesky for EVM legs too).
*/
function chainsForEnv(env: string): ViemChain[] {
return env === 'testnet' || env === 'devnet'
? TESTNET_EVM_CHAINS
: MAINNET_EVM_CHAINS
}
/** Back-compat export — defaults to the mainnet set. */
const ALL_EVM_CHAINS: ViemChain[] = MAINNET_EVM_CHAINS
/** Lookup viem Chain by numeric chainId across both mainnet + testnet sets. */
export function viemChainById(chainId: number): ViemChain | undefined { export function viemChainById(chainId: number): ViemChain | undefined {
return ALL_EVM_CHAINS.find((c) => c.id === chainId) return (
MAINNET_EVM_CHAINS.find((c) => c.id === chainId) ??
TESTNET_EVM_CHAINS.find((c) => c.id === chainId)
)
} }
/** /**
@@ -54,16 +92,30 @@ export function viemChainById(chainId: number): ViemChain | undefined {
export function buildWagmiConfig(cfg: BridgeConfig): Config { export function buildWagmiConfig(cfg: BridgeConfig): Config {
const wallet = cfg.wallet const wallet = cfg.wallet
const supported = wallet?.supportedChainIds const supported = wallet?.supportedChainIds
const envChains = chainsForEnv(cfg.env)
// Filter to the tenant's allow-list when present; otherwise expose all. // Filter to the tenant's allow-list when present; otherwise expose all
const chains = supported && supported.length > 0 // chains appropriate for the current env. The allow-list may legitimately
? ALL_EVM_CHAINS.filter((c) => supported.includes(c.id)) // span both sets (a tenant could allow [1, 11155111] to switch between
: ALL_EVM_CHAINS // mainnet+sepolia for cross-env testing), so we filter the union.
//
// Crucially: always materialize a fresh array. The hoist step below uses
// splice(), which would otherwise mutate the shared MAINNET_/TESTNET_EVM_CHAINS
// constants and silently corrupt subsequent buildWagmiConfig() calls in
// the same process (this hit the wagmi-config.test.ts suite when default-
// chain hoisting reordered the shared array).
const chains: ViemChain[] = supported && supported.length > 0
? [...MAINNET_EVM_CHAINS, ...TESTNET_EVM_CHAINS].filter((c) =>
supported.includes(c.id),
)
: [...envChains]
if (chains.length === 0) { if (chains.length === 0) {
// Defensive: if the tenant's allow-list excludes every supported chain, // Defensive: if the tenant's allow-list excludes every supported chain,
// fall back to mainnet so the wagmi createConfig() doesn't reject. // fall back to env-appropriate chains[0] so wagmi createConfig() doesn't
chains.push(mainnet) // reject. For testnet env that's sepolia; for mainnet env that's mainnet.
const fallback = envChains[0] ?? mainnet
chains.push(fallback)
} }
// Order chains so wallet.defaultChainId (when provided + allowed) is first. // Order chains so wallet.defaultChainId (when provided + allowed) is first.