feat(pkg/bridge): inline bridge UI under src/app/

Phase 1.5 — sever the @luxbridge/app workspace cycle that Phase 2
exposed as a P0 blank-page bug. The SDK now ships its own bridge UI
inline; there is no runtime dependency on any sibling tenant package.

Layout:

  pkg/bridge/src/app/
    BridgeApp.tsx           top-level <BridgeApp/>
    components/             Header, SwapForm, ChainSelector, AssetInput,
                            WalletConnect, TransferStatus, Card
    hooks/                  useWallet, useSwap, useTransfers
    lib/                    chains, assets, format
    styles/theme.css        minimal CSS variables (brand-* hooks)

Features covered:
  - Header with brand block + wallet connect
  - Swap form: From/To chain selector, asset input + select, quote
    panel (rate / fee / ETA), reverse button, submit
  - Wallet connect button + connected-address pill
  - Transfer status feed with MPC-phase rendering (pending → signing
    → broadcasting → completed)

Design constraints:
  - PQ-safe: no classical-crypto assumptions anywhere. The MPC signer
    layer (wired in Phase 3 R3) plugs Corona-lattice + ECDSA-CMP
    hybrid; nothing here defeats post-quantum.
  - Leaderless / permissionless: chain + asset registries are static
    client-side data, not bound to any central authority. Quotes are
    deterministic display mocks; the real backend (also leaderless)
    arrives in Phase 3 R3.
  - Phase 3 R2 swap target: components are seam-isolated so swapping
    inline-styled `<div>` / native `<select>` for @hanzo/gui v7
    primitives is mechanical. The shape of BridgeApp.tsx stays stable.

Stats: 15 new files, 1336 LoC. Renders identical output to the
former @luxbridge/app App component for the four MVP features.
This commit is contained in:
2026-05-21 15:03:16 -07:00
parent 6011df4fe8
commit 45e08a1a5d
16 changed files with 1361 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
// BridgeApp — top-level inlined bridge UI for @luxfi/bridge.
//
// Previously this code shipped in the separate `@luxbridge/app` workspace
// package, which created a runtime cycle: `@luxfi/bridge` lazy-imported
// `@luxbridge/app`, and tenant repos (notably Phase 2's slimmed `app/bridge`)
// then stubbed `@luxbridge/app` to a no-op `App` component to break the
// build cycle — producing the blank-page bug Phase 1.5 fixes by inlining
// here.
//
// Design notes:
// - No central authority. Chain + asset lists are static client-side data
// today; in production the bridge backend serves them, but the trust
// model is the same — the user signs every transfer via threshold MPC.
// - PQ-safe. The signing layer (wired by Phase 3 R3) uses Corona-lattice
// + ECDSA-CMP hybrid; nothing in this file makes a classical-only
// assumption.
// - Tamagui swap. Phase 3 R2 replaces inline styles + native `<select>`
// with `@hanzo/gui` primitives. This file's shape is stable across
// that swap — the components in `./components/` are the seams.
import { useEffect, type FC } from 'react'
import { getConfig } from '../config'
import { Header } from './components/Header'
import { SwapForm } from './components/SwapForm'
import { TransferStatus } from './components/TransferStatus'
import { useSwap } from './hooks/useSwap'
import { useTransfers } from './hooks/useTransfers'
import { useWallet } from './hooks/useWallet'
import './styles/theme.css'
const shell: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
background: 'var(--bridge-bg)',
color: 'var(--bridge-text)',
}
const main: React.CSSProperties = {
flex: 1,
display: 'flex',
justifyContent: 'center',
padding: '32px 16px',
}
const stack: React.CSSProperties = {
width: '100%',
maxWidth: 480,
display: 'flex',
flexDirection: 'column',
gap: 16,
}
const footer: React.CSSProperties = {
textAlign: 'center',
padding: '20px 16px',
fontSize: 11,
color: 'var(--bridge-text-subtle)',
borderTop: '1px solid var(--bridge-border)',
}
export const BridgeApp: FC = () => {
// Read SDK config — Bridge.tsx guarantees setConfig() ran first.
const cfg = getConfig()
const wallet = useWallet()
const swap = useSwap()
const transfers = useTransfers()
// Document-level theme hint — lets host pages opt in to dark mode without
// having to mirror our CSS variables.
useEffect(() => {
if (typeof document === 'undefined') return
document.documentElement.classList.add('bridge-theme')
return () => {
document.documentElement.classList.remove('bridge-theme')
}
}, [])
const supportEmail = cfg.brand?.supportEmail
const docsUrl = cfg.brand?.docsUrl
const env = cfg.env
return (
<div className="bridge-root" style={shell}>
<Header wallet={wallet} defaultChainId={swap.fromChain.id} />
<main style={main}>
<div style={stack}>
<SwapForm swap={swap} wallet={wallet} transfers={transfers} />
<TransferStatus transfers={transfers.transfers} />
</div>
</main>
<footer style={footer}>
<span>Network: {env}</span>
{docsUrl ? (
<>
{' · '}
<a
href={docsUrl}
target="_blank"
rel="noreferrer noopener"
style={{ color: 'var(--bridge-text-muted)' }}
>
Docs
</a>
</>
) : null}
{supportEmail ? (
<>
{' · '}
<a
href={`mailto:${supportEmail}`}
style={{ color: 'var(--bridge-text-muted)' }}
>
{supportEmail}
</a>
</>
) : null}
</footer>
</div>
)
}
export default BridgeApp
@@ -0,0 +1,122 @@
// AssetInput — amount input + native asset select.
//
// Side-by-side text input (amount) and select (asset). Validates numeric
// input on type. Phase 3 R2 replaces this composition with @hanzo/gui's
// `Input` and `Select` primitives.
import type { CSSProperties, FC } from 'react'
import type { Asset } from '../lib/assets'
import { parseAmount } from '../lib/format'
export interface AssetInputProps {
label: string
amount: string
onAmountChange: (s: string) => void
asset: Asset
assets: Asset[]
onAssetChange: (a: Asset) => void
readOnly?: boolean
placeholder?: string
}
const wrap: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
}
const labelRow: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 11,
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}
const row: CSSProperties = {
display: 'flex',
gap: 8,
alignItems: 'stretch',
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-sm)',
padding: '8px 10px',
}
const inputStyle: CSSProperties = {
flex: 1,
background: 'transparent',
border: 'none',
color: 'var(--bridge-text)',
fontSize: 22,
fontWeight: 500,
outline: 'none',
minWidth: 0,
padding: 0,
}
const selectStyle: CSSProperties = {
background: 'transparent',
border: 'none',
color: 'var(--bridge-text)',
fontSize: 15,
fontWeight: 500,
outline: 'none',
cursor: 'pointer',
paddingRight: 4,
}
export const AssetInput: FC<AssetInputProps> = ({
label,
amount,
onAmountChange,
asset,
assets,
onAssetChange,
readOnly = false,
placeholder = '0.0',
}) => (
<div style={wrap}>
<div style={labelRow}>
<span>{label}</span>
{readOnly && <span>estimated</span>}
</div>
<div style={row}>
<input
style={inputStyle}
type="text"
inputMode="decimal"
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={amount}
readOnly={readOnly}
onChange={(e) => {
const v = e.target.value
// Allow empty, partial decimals, or valid positive numbers.
if (v === '' || v === '.' || parseAmount(v) !== null) {
onAmountChange(v)
}
}}
aria-label={`${label} amount`}
/>
<select
style={selectStyle}
value={asset.id}
onChange={(e) => {
const next = assets.find((a) => a.id === e.target.value)
if (next) onAssetChange(next)
}}
aria-label={`${label} asset`}
>
{assets.map((a) => (
<option key={a.id} value={a.id}>
{a.symbol}
</option>
))}
</select>
</div>
</div>
)
+36
View File
@@ -0,0 +1,36 @@
// Card primitive — a styled container.
//
// Phase 3 R2 will replace this with @hanzo/gui's `Card`. The wrapper exists
// so the swap is mechanical: every call site uses `<Card>` and Phase 3 only
// touches this one file.
import type { CSSProperties, FC, ReactNode } from 'react'
export interface CardProps {
children: ReactNode
style?: CSSProperties
/** Skip default padding (caller provides its own). */
bare?: boolean
}
const base: CSSProperties = {
background: 'var(--bridge-bg-elevated)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}
export const Card: FC<CardProps> = ({ children, style, bare }) => (
<div
style={{
...base,
...(bare ? { padding: 0 } : null),
...style,
}}
>
{children}
</div>
)
@@ -0,0 +1,72 @@
// ChainSelector — native <select> based chain picker.
//
// Native select avoids dragging a popover / portal lib into the SDK and ships
// real working UI today. Phase 3 R2 swaps this for @hanzo/gui's `Select` so
// the visual story matches the rest of the design system.
import type { CSSProperties, FC } from 'react'
import type { Chain } from '../lib/chains'
export interface ChainSelectorProps {
label: string
chains: Chain[]
current: Chain
onChange: (c: Chain) => void
style?: CSSProperties
}
const wrap: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 0,
flex: 1,
}
const labelStyle: CSSProperties = {
fontSize: 11,
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}
const selectStyle: CSSProperties = {
background: 'var(--bridge-bg-input)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-sm)',
color: 'var(--bridge-text)',
padding: '10px 12px',
fontSize: 14,
outline: 'none',
width: '100%',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
}
export const ChainSelector: FC<ChainSelectorProps> = ({
label,
chains,
current,
onChange,
style,
}) => (
<div style={{ ...wrap, ...style }}>
<span style={labelStyle}>{label}</span>
<select
style={selectStyle}
value={current.id}
onChange={(e) => {
const next = chains.find((c) => c.id === e.target.value)
if (next) onChange(next)
}}
aria-label={`${label} chain`}
>
{chains.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
)
+79
View File
@@ -0,0 +1,79 @@
// Header — brand area + wallet connect.
//
// Reads brand metadata from the SDK config (which downstream consumers set
// once at mount time via setConfig). Phase 3 R2 swaps the styling layer.
import type { CSSProperties, FC } from 'react'
import { getConfig } from '../../config'
import type { WalletState } from '../hooks/useWallet'
import { WalletConnect } from './WalletConnect'
export interface HeaderProps {
wallet: WalletState
defaultChainId: string
}
const header: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 20px',
borderBottom: '1px solid var(--bridge-border)',
background: 'var(--bridge-bg)',
position: 'sticky',
top: 0,
zIndex: 10,
}
const brand: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
}
const brandLogo: CSSProperties = {
width: 28,
height: 28,
borderRadius: 6,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background:
'linear-gradient(135deg, var(--bridge-accent), var(--bridge-accent-hover))',
color: 'white',
fontSize: 14,
fontWeight: 700,
}
const brandName: CSSProperties = {
fontSize: 15,
fontWeight: 600,
}
export const Header: FC<HeaderProps> = ({ wallet, defaultChainId }) => {
// Safe read — getConfig() throws only if BridgeApp renders before setConfig;
// the Bridge.tsx wrapper guarantees order, so we can read directly.
const cfg = getConfig()
const name = cfg.brand?.name ?? 'Bridge'
const logo = cfg.brand?.logoUrl
const initial = name.charAt(0).toUpperCase()
return (
<header style={header}>
<div style={brand}>
{logo ? (
<img
src={logo}
alt={name}
style={{ width: 28, height: 28, borderRadius: 6 }}
/>
) : (
<span style={brandLogo} aria-hidden>
{initial}
</span>
)}
<span style={brandName}>{name}</span>
</div>
<WalletConnect wallet={wallet} defaultChainId={defaultChainId} />
</header>
)
}
+198
View File
@@ -0,0 +1,198 @@
// SwapForm — the primary bridge UI.
//
// Composes ChainSelector + AssetInput + a reverse button + a submit button
// against the SwapState hook. Submitting drops a transfer onto the
// TransferState which TransferStatus renders below.
import { useState, type CSSProperties, type FC } from 'react'
import type { SwapState } from '../hooks/useSwap'
import type { WalletState } from '../hooks/useWallet'
import type { TransferState } from '../hooks/useTransfers'
import { formatAmount, formatUsd } from '../lib/format'
import { Card } from './Card'
import { ChainSelector } from './ChainSelector'
import { AssetInput } from './AssetInput'
export interface SwapFormProps {
swap: SwapState
wallet: WalletState
transfers: TransferState
}
const chainsRow: CSSProperties = {
display: 'flex',
gap: 10,
position: 'relative',
alignItems: 'flex-end',
}
const reverseBtn: CSSProperties = {
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
background: 'var(--bridge-bg-elevated)',
border: '1px solid var(--bridge-border)',
borderRadius: '50%',
width: 28,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--bridge-text-muted)',
fontSize: 14,
fontWeight: 700,
cursor: 'pointer',
lineHeight: 1,
}
const quoteRow: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
color: 'var(--bridge-text-muted)',
}
const submit: CSSProperties = {
marginTop: 8,
background: 'var(--bridge-accent)',
color: 'white',
border: 'none',
borderRadius: 'var(--bridge-radius-sm)',
padding: '12px 16px',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
width: '100%',
}
const submitDisabled: CSSProperties = {
...submit,
background: 'var(--bridge-bg-input)',
color: 'var(--bridge-text-muted)',
cursor: 'not-allowed',
}
const errorBox: CSSProperties = {
background: 'rgba(244, 67, 54, 0.08)',
border: '1px solid rgba(244, 67, 54, 0.4)',
color: 'var(--bridge-danger)',
borderRadius: 'var(--bridge-radius-sm)',
padding: '8px 10px',
fontSize: 12,
}
export const SwapForm: FC<SwapFormProps> = ({ swap, wallet, transfers }) => {
const [error, setError] = useState<string | null>(null)
const canSubmit =
wallet.address !== null &&
swap.quote !== null &&
swap.fromChain.id !== swap.toChain.id
const submitLabel = (() => {
if (!wallet.address) return 'Connect wallet to bridge'
if (swap.fromChain.id === swap.toChain.id) {
return 'Source and destination must differ'
}
if (!swap.quote) return 'Enter an amount'
return `Bridge ${swap.fromAsset.symbol} → ${swap.toAsset.symbol}`
})()
const onSubmit = () => {
setError(null)
if (!swap.quote || !wallet.address) {
setError('Wallet must be connected with a valid quote.')
return
}
transfers.submit({
fromChainId: swap.fromChain.id,
toChainId: swap.toChain.id,
fromAssetId: swap.fromAsset.id,
toAssetId: swap.toAsset.id,
inAmount: Number(swap.amount),
outAmount: swap.quote.outAmount,
})
// Clear input on successful submit so the next intent starts fresh.
swap.setAmount('')
}
return (
<Card>
<div style={chainsRow}>
<ChainSelector
label="From"
chains={swap.chains}
current={swap.fromChain}
onChange={swap.setFromChain}
/>
<button
type="button"
style={reverseBtn}
onClick={swap.reverse}
title="Swap from / to"
aria-label="Swap from and to chains"
>
⇄
</button>
<ChainSelector
label="To"
chains={swap.chains}
current={swap.toChain}
onChange={swap.setToChain}
/>
</div>
<AssetInput
label="You send"
amount={swap.amount}
onAmountChange={swap.setAmount}
asset={swap.fromAsset}
assets={swap.fromAssetOptions}
onAssetChange={swap.setFromAsset}
/>
<AssetInput
label="You receive"
amount={swap.quote ? formatAmount(swap.quote.outAmount, 6) : ''}
onAmountChange={() => {/* read-only */}}
asset={swap.toAsset}
assets={swap.toAssetOptions}
onAssetChange={swap.setToAsset}
readOnly
placeholder="—"
/>
{swap.quote ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={quoteRow}>
<span>Rate</span>
<span>
1 {swap.fromAsset.symbol} ≈ {formatAmount(swap.quote.rate, 6)}{' '}
{swap.toAsset.symbol}
</span>
</div>
<div style={quoteRow}>
<span>Fee</span>
<span>{formatUsd(swap.quote.feeUsd)}</span>
</div>
<div style={quoteRow}>
<span>Est. time</span>
<span>{swap.quote.etaText}</span>
</div>
</div>
) : null}
{error ? <div style={errorBox}>{error}</div> : null}
<button
type="button"
style={canSubmit ? submit : submitDisabled}
disabled={!canSubmit}
onClick={onSubmit}
>
{submitLabel}
</button>
</Card>
)
}
@@ -0,0 +1,101 @@
// TransferStatus — list of in-flight + recent transfers.
//
// Renders the transfer feed surfaced by useTransfers. Each row shows the
// phase as a colored dot + label. Phase 3 R2 will swap the typography to
// @hanzo/gui's `Text` primitive.
import type { CSSProperties, FC } from 'react'
import type { Transfer, TransferPhase } from '../hooks/useTransfers'
import { DEFAULT_CHAINS, findChain } from '../lib/chains'
import { formatAmount } from '../lib/format'
import { Card } from './Card'
export interface TransferStatusProps {
transfers: Transfer[]
}
const phaseLabel: Record<TransferPhase, string> = {
pending: 'Pending',
signing: 'Signing (MPC threshold)',
broadcasting: 'Broadcasting',
completed: 'Completed',
failed: 'Failed',
}
const phaseColor: Record<TransferPhase, string> = {
pending: 'var(--bridge-text-muted)',
signing: 'var(--bridge-accent)',
broadcasting: 'var(--bridge-accent-hover)',
completed: 'var(--bridge-success)',
failed: 'var(--bridge-danger)',
}
const heading: CSSProperties = {
fontSize: 12,
color: 'var(--bridge-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 4,
}
const empty: CSSProperties = {
fontSize: 12,
color: 'var(--bridge-text-subtle)',
fontStyle: 'italic',
}
const row: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid var(--bridge-border)',
fontSize: 13,
}
const dot: CSSProperties = {
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
marginRight: 8,
}
function shortChainName(chainId: string): string {
return findChain(DEFAULT_CHAINS, chainId)?.name ?? chainId
}
export const TransferStatus: FC<TransferStatusProps> = ({ transfers }) => (
<Card>
<div style={heading}>Transfers</div>
{transfers.length === 0 ? (
<div style={empty}>No transfers yet. Submit a bridge above to start.</div>
) : (
<div>
{transfers.map((t) => (
<div key={t.id} style={row}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span>
{formatAmount(t.inAmount, 6)} → {formatAmount(t.outAmount, 6)}
</span>
<span
style={{ fontSize: 11, color: 'var(--bridge-text-muted)' }}
>
{shortChainName(t.fromChainId)} → {shortChainName(t.toChainId)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span
style={{ ...dot, background: phaseColor[t.phase] }}
aria-hidden
/>
<span style={{ color: phaseColor[t.phase] }}>
{phaseLabel[t.phase]}
</span>
</div>
</div>
))}
</div>
)}
</Card>
)
@@ -0,0 +1,88 @@
// WalletConnect — connect / disconnect button + connected address pill.
//
// Today this stubs the connection (see useWallet.ts). Phase 3 R3 wires
// the real threshold-MPC session — the *button* contract here stays.
import type { CSSProperties, FC } from 'react'
import type { WalletState } from '../hooks/useWallet'
import { shortAddress } from '../lib/format'
export interface WalletConnectProps {
wallet: WalletState
/** Chain to connect against when the user clicks "Connect". */
defaultChainId: string
}
const buttonBase: CSSProperties = {
background: 'var(--bridge-accent)',
color: 'white',
border: 'none',
borderRadius: 'var(--bridge-radius-sm)',
padding: '8px 14px',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
transition: 'background-color 120ms ease',
}
const pillBase: CSSProperties = {
background: 'var(--bridge-bg-input)',
color: 'var(--bridge-text)',
border: '1px solid var(--bridge-border)',
borderRadius: 'var(--bridge-radius-sm)',
padding: '6px 10px',
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
}
export const WalletConnect: FC<WalletConnectProps> = ({
wallet,
defaultChainId,
}) => {
if (!wallet.address) {
return (
<button
type="button"
style={{
...buttonBase,
opacity: wallet.connecting ? 0.7 : 1,
}}
disabled={wallet.connecting}
onClick={() => {
void wallet.connect(defaultChainId)
}}
>
{wallet.connecting ? 'Connecting…' : 'Connect Wallet'}
</button>
)
}
return (
<button
type="button"
style={pillBase}
onClick={wallet.disconnect}
title="Disconnect wallet"
aria-label={`Disconnect wallet ${wallet.address}`}
>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--bridge-success)',
}}
aria-hidden
/>
{shortAddress(wallet.address)}
</button>
)
}
+10
View File
@@ -0,0 +1,10 @@
// Side-effect-only CSS imports inside the SDK source tree.
//
// The Vite bundler in downstream consumers handles `.css` imports natively;
// this declaration just keeps `tsc --noEmit` happy when typechecking the SDK
// in isolation.
declare module '*.css' {
const css: string
export default css
}
+166
View File
@@ -0,0 +1,166 @@
// Swap form state hook for the inlined bridge UI.
//
// Owns the from/to chain selection, asset selection, amount, and a derived
// quote. The quote here is a deterministic display-only mock — Phase 3 R3
// wires the real cross-chain quote against the bridge backend (which itself
// is permissionless: rates are computed from on-chain reserves + MPC node
// attestations, no central pricing authority).
import { useState, useMemo, useCallback } from 'react'
import {
DEFAULT_ASSETS,
type Asset,
assetsForChain,
} from '../lib/assets'
import { DEFAULT_CHAINS, type Chain, findChain } from '../lib/chains'
export interface Quote {
/** Amount the user receives on the destination chain. */
outAmount: number
/** USD fee charged by the bridge. */
feeUsd: number
/** Gas estimate on the destination chain (in destination native units). */
destGas: number
/** Estimated time-to-finality, e.g. `~5min`. */
etaText: string
/** Implied effective rate, `outAmount / inAmount`. */
rate: number
}
export interface SwapState {
chains: Chain[]
assets: Asset[]
fromChain: Chain
toChain: Chain
fromAsset: Asset
toAsset: Asset
amount: string
quote: Quote | null
setFromChain: (c: Chain) => void
setToChain: (c: Chain) => void
setFromAsset: (a: Asset) => void
setToAsset: (a: Asset) => void
setAmount: (s: string) => void
reverse: () => void
fromAssetOptions: Asset[]
toAssetOptions: Asset[]
}
/**
* Compute a deterministic display-only quote from an input amount.
*
* Replaced by `useQuote(backend, from, to, amount)` once Phase 3 R3 ships
* the API client.
*/
function computeQuote(amountStr: string, from: Asset, to: Asset): Quote | null {
const n = Number(amountStr)
if (!isFinite(n) || n <= 0) return null
// Tiny mock rate matrix — stable but obviously placeholder.
const stableSym = (s: string) =>
s === 'USDC' || s === 'USDT' || s === 'USDL' || s === 'DAI'
const usd = (sym: string) => {
switch (sym) {
case 'ETH':
return 3500
case 'LUX':
return 12
case 'MATIC':
return 0.6
case 'SOL':
return 175
default:
return stableSym(sym) ? 1 : 1
}
}
const inUsd = n * usd(from.symbol)
const out = (inUsd / usd(to.symbol)) * 0.998 // 0.2% bridge fee
const fee = inUsd * 0.002
const rate = out / n
return {
outAmount: out,
feeUsd: fee,
destGas: 0.0008,
etaText: '~3 min',
rate,
}
}
export function useSwap(): SwapState {
const chains = DEFAULT_CHAINS
const assets = DEFAULT_ASSETS
const initFrom = findChain(chains, 'lux:96369') ?? chains[0]
const initTo = findChain(chains, 'evm:1') ?? chains[1] ?? chains[0]
const [fromChain, setFromChain] = useState<Chain>(initFrom)
const [toChain, setToChain] = useState<Chain>(initTo)
const [fromAsset, setFromAsset] = useState<Asset>(
assetsForChain(assets, initFrom.id)[0]!,
)
const [toAsset, setToAsset] = useState<Asset>(
assetsForChain(assets, initTo.id)[0]!,
)
const [amount, setAmount] = useState<string>('')
const fromAssetOptions = useMemo(
() => assetsForChain(assets, fromChain.id),
[assets, fromChain.id],
)
const toAssetOptions = useMemo(
() => assetsForChain(assets, toChain.id),
[assets, toChain.id],
)
// Keep selected asset in sync with chain change.
const setFromChainSafe = useCallback(
(c: Chain) => {
setFromChain(c)
const opts = assetsForChain(assets, c.id)
if (opts[0] && opts.findIndex((a) => a.id === fromAsset.id) < 0) {
setFromAsset(opts[0])
}
},
[assets, fromAsset.id],
)
const setToChainSafe = useCallback(
(c: Chain) => {
setToChain(c)
const opts = assetsForChain(assets, c.id)
if (opts[0] && opts.findIndex((a) => a.id === toAsset.id) < 0) {
setToAsset(opts[0])
}
},
[assets, toAsset.id],
)
const reverse = useCallback(() => {
setFromChain(toChain)
setToChain(fromChain)
setFromAsset(toAsset)
setToAsset(fromAsset)
}, [fromChain, toChain, fromAsset, toAsset])
const quote = useMemo(
() => computeQuote(amount, fromAsset, toAsset),
[amount, fromAsset, toAsset],
)
return {
chains,
assets,
fromChain,
toChain,
fromAsset,
toAsset,
amount,
quote,
setFromChain: setFromChainSafe,
setToChain: setToChainSafe,
setFromAsset,
setToAsset,
setAmount,
reverse,
fromAssetOptions,
toAssetOptions,
}
}
+82
View File
@@ -0,0 +1,82 @@
// Transfer status hook — tracks in-flight cross-chain transfers.
//
// In production these state transitions come from streaming bridge backend
// events (MPC ceremony rounds, source chain inclusion, dest chain finality).
// Here we expose the shape the UI binds to so Phase 3 R3 can swap the
// `submit()` body without changing TransferStatus.tsx.
import { useState, useCallback } from 'react'
export type TransferPhase =
| 'pending' // submitted, waiting for source chain inclusion
| 'signing' // MPC threshold ceremony in progress
| 'broadcasting' // dest tx broadcast, waiting for finality
| 'completed' // dest finality reached
| 'failed' // any leg failed; .error has the reason
export interface Transfer {
id: string
fromChainId: string
toChainId: string
fromAssetId: string
toAssetId: string
inAmount: number
outAmount: number
phase: TransferPhase
createdAt: number
error?: string
}
export interface TransferState {
transfers: Transfer[]
active: Transfer | null
submit: (t: Omit<Transfer, 'id' | 'phase' | 'createdAt'>) => Transfer
clear: () => void
}
let _id = 0
function nextId(): string {
_id += 1
return `xfer_${Date.now().toString(36)}_${_id}`
}
export function useTransfers(): TransferState {
const [transfers, setTransfers] = useState<Transfer[]>([])
const [active, setActive] = useState<Transfer | null>(null)
const submit = useCallback(
(t: Omit<Transfer, 'id' | 'phase' | 'createdAt'>) => {
const x: Transfer = {
...t,
id: nextId(),
phase: 'pending',
createdAt: Date.now(),
}
setTransfers((prev) => [x, ...prev])
setActive(x)
// Mock phase progression — Phase 3 R3 replaces this with real events.
const advance = (phase: TransferPhase, after: number) => {
setTimeout(() => {
setTransfers((prev) =>
prev.map((p) => (p.id === x.id ? { ...p, phase } : p)),
)
setActive((curr) => (curr && curr.id === x.id ? { ...curr, phase } : curr))
}, after)
}
advance('signing', 600)
advance('broadcasting', 1400)
advance('completed', 2400)
return x
},
[],
)
const clear = useCallback(() => {
setTransfers([])
setActive(null)
}, [])
return { transfers, active, submit, clear }
}
+49
View File
@@ -0,0 +1,49 @@
// Minimal wallet state hook for the inlined bridge UI.
//
// This is a stub — it exposes a stable shape (`address`, `chainId`, `connect`,
// `disconnect`, `signing`) that the UI consumes today. Phase 3 R2 + the
// threshold MPC integration will plug a real `connect()` flow that derives
// keys via Corona + ECDSA-CMP threshold signers — never a single-key
// custody model (no central authority over the user's funds).
//
// Until then: `connect()` resolves to a deterministic display-only address
// so the UI renders identically to a connected wallet. No private-key
// material is generated, no funds are at risk.
import { useState, useCallback } from 'react'
export interface WalletState {
address: string | null
chainId: string | null
connecting: boolean
signing: boolean
connect: (chainId: string) => Promise<void>
disconnect: () => void
}
// Display-only deterministic address (the leading bytes spell "LUXBRIDGE").
// Replaced by Phase 3 R2 with the actual MPC-derived address per session.
const DISPLAY_ADDRESS = '0xLUXBRIDGE000000000000000000000000DEADBEEF'
export function useWallet(): WalletState {
const [address, setAddress] = useState<string | null>(null)
const [chainId, setChainId] = useState<string | null>(null)
const [connecting, setConnecting] = useState(false)
const [signing] = useState(false)
const connect = useCallback(async (cid: string) => {
setConnecting(true)
// Simulated handshake latency; replaced by real MPC keygen round in R3.
await new Promise((r) => setTimeout(r, 350))
setAddress(DISPLAY_ADDRESS)
setChainId(cid)
setConnecting(false)
}, [])
const disconnect = useCallback(() => {
setAddress(null)
setChainId(null)
}, [])
return { address, chainId, connecting, signing, connect, disconnect }
}
+58
View File
@@ -0,0 +1,58 @@
// Supported asset registry for the inlined bridge UI.
//
// Like chains.ts, this is a minimal static set sufficient to render a real
// swap form. The real bridge resolves assets per-chain from the API host
// at runtime — Phase 3 R2 swaps this for `useAssets(chainId)`.
//
// PQ-safe note: asset symbols are surface-only labels. All signing is done
// by the SDK's MPC layer (Corona + ECDSA hybrid) — not by anything in
// this file. The asset list is data, not trust.
export interface Asset {
/** Stable per-chain ID, e.g. `lux:96369:LUX`, `evm:1:USDC`. */
id: string
/** Display symbol. */
symbol: string
/** Long name shown in the selector list. */
name: string
/** ID of the chain this asset lives on (chains.ts → Chain.id). */
chainId: string
/** Decimals. */
decimals: number
/** Optional logo URL. */
logoUrl?: string
}
export const DEFAULT_ASSETS: Asset[] = [
// Lux native
{
id: 'lux:96369:LUX',
symbol: 'LUX',
name: 'Lux',
chainId: 'lux:96369',
decimals: 18,
},
// Ethereum
{ id: 'evm:1:ETH', symbol: 'ETH', name: 'Ether', chainId: 'evm:1', decimals: 18 },
{ id: 'evm:1:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'evm:1', decimals: 6 },
{ id: 'evm:1:USDT', symbol: 'USDT', name: 'Tether USD', chainId: 'evm:1', decimals: 6 },
// Arbitrum
{ id: 'evm:42161:ETH', symbol: 'ETH', name: 'Ether', chainId: 'evm:42161', decimals: 18 },
{ id: 'evm:42161:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'evm:42161', decimals: 6 },
// Base
{ id: 'evm:8453:ETH', symbol: 'ETH', name: 'Ether', chainId: 'evm:8453', decimals: 18 },
{ id: 'evm:8453:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'evm:8453', decimals: 6 },
// Polygon
{ id: 'evm:137:MATIC', symbol: 'MATIC', name: 'Matic', chainId: 'evm:137', decimals: 18 },
{ id: 'evm:137:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'evm:137', decimals: 6 },
// Optimism
{ id: 'evm:10:ETH', symbol: 'ETH', name: 'Ether', chainId: 'evm:10', decimals: 18 },
{ id: 'evm:10:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'evm:10', decimals: 6 },
// Solana
{ id: 'svm:101:SOL', symbol: 'SOL', name: 'Solana', chainId: 'svm:101', decimals: 9 },
{ id: 'svm:101:USDC', symbol: 'USDC', name: 'USD Coin', chainId: 'svm:101', decimals: 6 },
]
export function assetsForChain(assets: Asset[], chainId: string): Asset[] {
return assets.filter((a) => a.chainId === chainId)
}
+81
View File
@@ -0,0 +1,81 @@
// Supported chain registry for the inlined bridge UI.
//
// This is a minimal static registry sufficient to wire the swap form. The
// real bridge backend resolves the full chain set from the API host at
// runtime — Phase 3 R2 swaps this for an API-driven `useChains()` hook.
//
// Permissionless-by-design: chains are referenced by namespaced ID
// (`evm:1`, `lux:96369`) — no central authority maps names to chain IDs
// and any consumer can extend the list at the SDK boundary.
export interface Chain {
/** Stable namespaced ID, e.g. `evm:1` (Ethereum mainnet), `lux:96369`. */
id: string
/** Display name shown in the selector. */
name: string
/** Short symbol, e.g. `ETH`, `LUX`. */
symbol: string
/** Decimals for the native token. */
decimals: number
/** Chain family — drives signer + RPC adaptor selection. */
family: 'evm' | 'lux' | 'svm' | 'btc'
/** Optional human-readable description. */
description?: string
}
export const DEFAULT_CHAINS: Chain[] = [
{
id: 'lux:96369',
name: 'Lux Network',
symbol: 'LUX',
decimals: 18,
family: 'lux',
description: 'Lux primary network — leaderless Quasar consensus',
},
{
id: 'evm:1',
name: 'Ethereum',
symbol: 'ETH',
decimals: 18,
family: 'evm',
},
{
id: 'evm:42161',
name: 'Arbitrum One',
symbol: 'ETH',
decimals: 18,
family: 'evm',
},
{
id: 'evm:8453',
name: 'Base',
symbol: 'ETH',
decimals: 18,
family: 'evm',
},
{
id: 'evm:137',
name: 'Polygon',
symbol: 'MATIC',
decimals: 18,
family: 'evm',
},
{
id: 'evm:10',
name: 'Optimism',
symbol: 'ETH',
decimals: 18,
family: 'evm',
},
{
id: 'svm:101',
name: 'Solana',
symbol: 'SOL',
decimals: 9,
family: 'svm',
},
]
export function findChain(chains: Chain[], id: string): Chain | undefined {
return chains.find((c) => c.id === id)
}
+38
View File
@@ -0,0 +1,38 @@
// Display formatters for the inlined bridge UI.
//
// Pure functions, no React. Phase 3 R2 will pull these against @hanzo/gui's
// `Text` primitive for typography, but the formatters themselves stay here.
/** Truncate a 0x address or similar to `0x1234…ABCD`. */
export function shortAddress(addr: string, head = 6, tail = 4): string {
if (!addr) return ''
if (addr.length <= head + tail + 2) return addr
return `${addr.slice(0, head)}…${addr.slice(-tail)}`
}
/** Format a decimal amount with up to N significant digits, trimming zeros. */
export function formatAmount(value: number | string, maxDigits = 6): string {
const n = typeof value === 'string' ? Number(value) : value
if (!isFinite(n) || n === 0) return '0'
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(2)}B`
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(2)}M`
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(2)}K`
return n.toLocaleString('en-US', { maximumFractionDigits: maxDigits })
}
/** Format USD value, e.g. `$1,234.56`. */
export function formatUsd(value: number): string {
if (!isFinite(value)) return '$0.00'
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
})
}
/** Parse a user-typed amount, allowing leading `.` and partial decimals. */
export function parseAmount(input: string): number | null {
if (input === '' || input === '.') return null
const n = Number(input)
return isFinite(n) && n >= 0 ? n : null
}
+55
View File
@@ -0,0 +1,55 @@
/*
* @luxfi/bridge — inlined theme.
*
* Minimal CSS variables consumed by the bridge UI. Brand overrides set by
* `applyBrandMetadata()` (config.ts) take precedence at the `:root` level
* via `--brand-primary` / `--brand-secondary`.
*
* Phase 3 R2 will replace these inline styles with @hanzo/gui primitives;
* this file is intentionally small and additive.
*/
:root {
--bridge-bg: #0a0a0a;
--bridge-bg-elevated: #141414;
--bridge-bg-input: #1a1a1a;
--bridge-border: #2a2a2a;
--bridge-border-focus: #404040;
--bridge-text: #ffffff;
--bridge-text-muted: #888888;
--bridge-text-subtle: #555555;
--bridge-accent: var(--brand-primary, #5b8def);
--bridge-accent-hover: var(--brand-secondary, #7a9ff5);
--bridge-danger: #f44336;
--bridge-success: #4caf50;
--bridge-radius: 10px;
--bridge-radius-sm: 6px;
--bridge-spacing: 12px;
--bridge-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
.bridge-root {
font-family: var(--bridge-font);
background: var(--bridge-bg);
color: var(--bridge-text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.bridge-root *,
.bridge-root *::before,
.bridge-root *::after {
box-sizing: border-box;
}
.bridge-root button {
font-family: inherit;
cursor: pointer;
}
.bridge-root input,
.bridge-root select {
font-family: inherit;
}