feat(state): zustand swap+bridge slices

Pure data slices for Swap and Bridge state machines. Async lives in the
hooks; the store keeps the screen + flow phases in sync.

  swap   : idle → quoting → ready → approving → swapping → done | error
  bridge : idle → quoting → ready → locking → signing → minting → done | error

Quote shapes carry a `ts` timestamp; consumers reject quotes >30s old.
Route kinds locked: V2/V3 = AMM, V4 = DEX (rendered verbatim in UI).
This commit is contained in:
Hanzo AI
2026-04-30 17:53:23 -07:00
parent f028efd393
commit afe7ce1f12
2 changed files with 222 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* Bridge-flow state. Drives `screens/bridge/*`.
*
* idle → editing form, no quote
* quoting → fetching route from gateway
* ready → quote fresh, "Confirm bridge" enabled
* locking → source-chain lock tx in flight
* signing → MPC ceremony in progress on M-Chain
* minting → destination mint observed, awaiting confirmation
* done → mint tx surfaced; user can navigate away
* error → any leg failed; `error` populated
*
* The MPC ceremony is what differentiates Lux Teleport from a vanilla
* lock-and-mint: the user signs ONLY the source lock; the destination
* mint is signed by the bridge committee via threshold signatures. This
* is reflected in the status machine — `signing` belongs to the M-Chain,
* not to the user.
*/
import { create } from "zustand"
import type { Asset } from "../lib/asset"
export type BridgeStatus =
| "idle"
| "quoting"
| "ready"
| "locking"
| "signing"
| "minting"
| "done"
| "error"
export interface BridgeQuote {
/** Amount the user receives on the destination chain (smallest units). */
amountOut: string
/** Bridge fee taken from the input amount (smallest units, source asset). */
fee: string
/** Estimated time to finality in seconds. */
etaSeconds: number
/** Path of chain ids — usually `[from, "lux-m", to]`. */
path: string[]
/** MPC threshold notation, e.g. "2/3". Rendered verbatim. */
threshold: string
/** Destination-chain mint contract address (informational). */
mintContract?: `0x${string}`
/** Quote freshness timestamp, ms epoch. Stale > 30s discarded before submit. */
ts: number
}
export interface BridgeState {
fromChainId: string
toChainId: string
asset: Asset | null
amount: string
/** Recipient on destination — empty means "same wallet" (caller's address). */
recipient: string
quote: BridgeQuote | null
status: BridgeStatus
lockTxHash: string | null
mintTxHash: string | null
error: string | null
setFromChainId: (id: string) => void
setToChainId: (id: string) => void
flipChains: () => void
setAsset: (a: Asset | null) => void
setAmount: (v: string) => void
setRecipient: (v: string) => void
setQuote: (q: BridgeQuote | null) => void
setStatus: (s: BridgeStatus) => void
setLockTxHash: (h: string | null) => void
setMintTxHash: (h: string | null) => void
setError: (e: string | null) => void
reset: () => void
}
const INITIAL = {
// Default: bridge from Lux C-Chain → Zoo L1, the canonical "demo" route.
fromChainId: "lux-c",
toChainId: "zoo-l1",
asset: null as Asset | null,
amount: "",
recipient: "",
quote: null as BridgeQuote | null,
status: "idle" as BridgeStatus,
lockTxHash: null as string | null,
mintTxHash: null as string | null,
error: null as string | null,
}
export const useBridgeStore = create<BridgeState>((set, get) => ({
...INITIAL,
setFromChainId: (fromChainId) => set({ fromChainId, quote: null }),
setToChainId: (toChainId) => set({ toChainId, quote: null }),
flipChains: () => {
const { fromChainId, toChainId } = get()
set({ fromChainId: toChainId, toChainId: fromChainId, quote: null, asset: null })
},
setAsset: (asset) => set({ asset, quote: null }),
setAmount: (amount) => set({ amount, quote: null }),
setRecipient: (recipient) => set({ recipient }),
setQuote: (quote) => set({ quote }),
setStatus: (status) => set({ status }),
setLockTxHash: (lockTxHash) => set({ lockTxHash }),
setMintTxHash: (mintTxHash) => set({ mintTxHash }),
setError: (error) => set({ error }),
reset: () => set({ ...INITIAL }),
}))
+115
View File
@@ -0,0 +1,115 @@
/**
* Swap-flow state. One store, six explicit phases.
*
* idle → editing form, no quote yet
* quoting → fetching route + price impact
* ready → quote fresh, "Confirm swap" enabled
* approving → erc20.approve in flight (only if needed)
* swapping → router/v4 swap tx in flight
* done → tx confirmed
* error → quote / approve / swap failed; `error` populated
*
* Reset is explicit: `/swap` mount clears state so a stale quote can never
* replay across sessions.
*
* Pattern mirrors `store/send.ts` — pure data slice; async lives in hooks.
*/
import { create } from "zustand"
import type { Asset } from "../lib/asset"
export type SwapStatus =
| "idle"
| "quoting"
| "ready"
| "approving"
| "swapping"
| "done"
| "error"
/**
* Naming convention is LOCKED per spec: V2/V3 = AMM, V4 = DEX. The `kind`
* field below is rendered verbatim in `QuoteRoute.tsx`.
*/
export type RouteKind = "amm-v2" | "amm-v3" | "dex-v4"
export interface SwapQuote {
/** Amount in (smallest units, decimal string). */
amountIn: string
/** Amount out (smallest units, decimal string). */
amountOut: string
/** Effective price `amountOut / amountIn` as a decimal string. */
price: string
/** Price impact, basis points (1 bp = 0.01 %). 010000. */
priceImpactBps: number
/** Route shown to the user — chain of token symbols. */
path: string[]
/** AMM v2/v3 vs DEX v4. */
kind: RouteKind
/** Pool fee tier in bps for v3/v4 (e.g. 3000 = 0.30 %). 0 if N/A. */
feeBps: number
/** Router contract that will execute the swap. */
router: `0x${string}`
/** Calldata produced by the gateway / quoter. Trusted only after slippage
* check + signing modal user confirmation. */
calldata: `0x${string}`
/** Wallet-recipient (zero address means caller). */
recipient: `0x${string}`
/** Quote freshness timestamp, ms epoch. Stale > 30s discarded. */
ts: number
}
export interface SwapState {
fromAsset: Asset | null
toAsset: Asset | null
amount: string
/** Slippage tolerance in basis points (e.g. 50 = 0.50 %). */
slippageBps: number
quote: SwapQuote | null
status: SwapStatus
approveTxHash: string | null
swapTxHash: string | null
error: string | null
setFromAsset: (a: Asset | null) => void
setToAsset: (a: Asset | null) => void
flipAssets: () => void
setAmount: (v: string) => void
setSlippageBps: (bps: number) => void
setQuote: (q: SwapQuote | null) => void
setStatus: (s: SwapStatus) => void
setApproveTxHash: (h: string | null) => void
setSwapTxHash: (h: string | null) => void
setError: (e: string | null) => void
reset: () => void
}
const INITIAL = {
fromAsset: null as Asset | null,
toAsset: null as Asset | null,
amount: "",
// 0.50 % default per SCREENS.md §3.
slippageBps: 50,
quote: null as SwapQuote | null,
status: "idle" as SwapStatus,
approveTxHash: null as string | null,
swapTxHash: null as string | null,
error: null as string | null,
}
export const useSwapStore = create<SwapState>((set, get) => ({
...INITIAL,
setFromAsset: (fromAsset) => set({ fromAsset, quote: null }),
setToAsset: (toAsset) => set({ toAsset, quote: null }),
flipAssets: () => {
const { fromAsset, toAsset } = get()
set({ fromAsset: toAsset, toAsset: fromAsset, quote: null })
},
setAmount: (amount) => set({ amount, quote: null }),
setSlippageBps: (slippageBps) => set({ slippageBps }),
setQuote: (quote) => set({ quote }),
setStatus: (status) => set({ status }),
setApproveTxHash: (approveTxHash) => set({ approveTxHash }),
setSwapTxHash: (swapTxHash) => set({ swapTxHash }),
setError: (error) => set({ error }),
reset: () => set({ ...INITIAL }),
}))