mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+10
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user