zhangchunrui 3 týždňov pred
rodič
commit
4330693ee6
45 zmenil súbory, kde vykonal 2296 pridanie a 241 odobranie
  1. 7 0
      .dockerignore
  2. 25 0
      .env.example
  3. 5 0
      .gitignore
  4. 96 1
      CLAUDE.md
  5. 41 0
      Dockerfile
  6. 106 20
      README.md
  7. 19 0
      docker-compose.yml
  8. 2 0
      docker-entrypoint.sh
  9. 14 0
      src/app/api/config/route.ts
  10. 30 0
      src/app/api/control/route.ts
  11. 18 0
      src/app/api/emergency/route.ts
  12. 18 0
      src/app/api/history/route.ts
  13. 15 0
      src/app/api/rewards/route.ts
  14. 42 0
      src/app/api/status/route.ts
  15. 235 0
      src/app/config/page.tsx
  16. 157 0
      src/app/history/page.tsx
  17. 28 16
      src/app/layout.tsx
  18. 195 60
      src/app/page.tsx
  19. 22 0
      src/components/layout/header.tsx
  20. 42 0
      src/components/layout/sidebar.tsx
  21. 12 0
      src/components/providers.tsx
  22. 162 0
      src/components/ui/alert-dialog.tsx
  23. 49 0
      src/components/ui/badge.tsx
  24. 21 21
      src/components/ui/button.tsx
  25. 92 0
      src/components/ui/card.tsx
  26. 20 0
      src/components/ui/input.tsx
  27. 20 0
      src/components/ui/label.tsx
  28. 190 0
      src/components/ui/select.tsx
  29. 21 0
      src/components/ui/separator.tsx
  30. 52 0
      src/components/ui/slider.tsx
  31. 32 0
      src/components/ui/switch.tsx
  32. 89 0
      src/components/ui/table.tsx
  33. 75 0
      src/components/ui/tabs.tsx
  34. 54 0
      src/components/ui/tooltip.tsx
  35. 49 0
      src/hooks/useEngineStatus.ts
  36. 9 1
      src/lib/chain/client.ts
  37. 2 7
      src/lib/chain/index.ts
  38. 1 6
      src/lib/chain/liquidity.ts
  39. 11 6
      src/lib/chain/pair.ts
  40. 34 16
      src/lib/chain/router.ts
  41. 1 1
      src/lib/config.ts
  42. 42 15
      src/lib/db/queries.ts
  43. 138 68
      src/lib/engine/engine.ts
  44. 2 2
      src/lib/utils.ts
  45. 1 1
      tsconfig.json

+ 7 - 0
.dockerignore

@@ -0,0 +1,7 @@
+node_modules
+.next
+data
+.env.local
+.git
+.gitignore
+*.md

+ 25 - 0
.env.example

@@ -0,0 +1,25 @@
+# === Wallet ===
+PRIVATE_KEY=0x_YOUR_PRIVATE_KEY_HERE
+
+# === RPC ===
+MONAD_RPC_URL=https://monad-mainnet.g.alchemy.com/v2/YOUR_KEY
+MONAD_RPC_FALLBACK=
+
+# === Contracts (defaults pre-filled) ===
+LB_ROUTER_ADDRESS=0x18556DA13313f3532c54711497A8FedAC273220E
+LB_FACTORY_ADDRESS=0xb43120c4745967fa9b93E79C149E66B0f2D6Fe0c
+LB_PAIR_ADDRESS=0x5afd3ec861f6104af26e8755abcc1f876de77620
+WMON_ADDRESS=0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A
+USDC_ADDRESS=0x754704bc059f8c67012fed69bc8a327a5aafb603
+
+# === Strategy ===
+NUM_BINS=3
+POSITION_SIZE_USD=50
+POLL_INTERVAL_MS=3000
+REBALANCE_COOLDOWN_MS=10000
+SLIPPAGE_BPS=50
+MAX_DAILY_REBALANCES=50
+
+# === Notifications (optional) ===
+NOTIFY_PROVIDER=bark
+NOTIFY_ENDPOINT=https://api.day.app/YOUR_KEY

+ 5 - 0
.gitignore

@@ -22,8 +22,12 @@
 
 # misc
 .DS_Store
+._*
 *.pem
 
+# data
+/data
+
 # debug
 npm-debug.log*
 yarn-debug.log*
@@ -32,6 +36,7 @@ yarn-error.log*
 
 # env files (can opt-in for committing if needed)
 .env*
+!.env.example
 
 # vercel
 .vercel

+ 96 - 1
CLAUDE.md

@@ -1 +1,96 @@
-@AGENTS.md
+# CLAUDE.md
+
+## Project Overview
+
+LFJ Liquidity Book LP Auto-Rebalancer for Monad mainnet. Provides narrow-range concentrated liquidity for MON/USDC pair on LFJ DEX, auto-rebalances when price moves out of range.
+
+## Tech Stack
+
+- **Framework**: Next.js 16 (App Router, standalone output)
+- **Language**: TypeScript (ES2020 target)
+- **Chain**: viem (Monad mainnet, chain ID 143)
+- **Database**: SQLite via better-sqlite3 (WAL mode)
+- **UI**: Tailwind CSS v4 + shadcn/ui (base-ui)
+- **Real-time**: Server-Sent Events (SSE)
+- **Package manager**: pnpm
+
+## Code Style
+
+- Prettier: single quotes, no semicolons, 100 print width, trailing commas, 2-space indent
+- Config: `.prettierrc` at project root
+- Format: `pnpm prettier --write "src/**/*.{ts,tsx}"`
+
+## Architecture
+
+```
+src/
+  lib/
+    config.ts          — env-based config, BotConfig interface, constants
+    chain/
+      client.ts        — viem public/wallet clients, Monad chain definition
+      abi.ts           — LB_PAIR_ABI, LB_ROUTER_ABI, ERC20_ABI
+      pair.ts          — LBPair read helpers (getActiveId, bins, price)
+      router.ts        — LBRouter write helpers (add/remove liquidity, swap)
+      balances.ts      — wallet balance queries (MON native, USDC ERC20)
+      liquidity.ts     — high-level orchestrators (openPosition, closePosition, rebalanceSwap)
+    engine/
+      engine.ts        — RebalancerEngine singleton (while-loop, not setInterval)
+      types.ts         — EngineStatus, EngineState, RebalanceResult
+    db/
+      index.ts         — SQLite singleton
+      schema.ts        — table definitions (rebalance_log, current_position, rewards_log, engine_state)
+      queries.ts       — prepared statement CRUD (logRebalance uses null-defaulted params)
+    notifications/
+      index.ts         — Bark/Ntfy/Pushover dispatcher
+  app/
+    page.tsx           — Dashboard (SSE real-time status)
+    config/page.tsx    — Engine control + strategy settings
+    history/page.tsx   — Rebalance history table
+    api/
+      status/route.ts  — SSE endpoint (ReadableStream)
+      control/route.ts — POST start/pause/stop
+      config/route.ts  — GET current strategy config
+      emergency/route.ts — POST emergency withdraw
+      history/route.ts — GET paginated rebalance_log
+      rewards/route.ts — GET rewards history
+  hooks/
+    useEngineStatus.ts — EventSource SSE hook
+```
+
+## Key Design Decisions
+
+- **tokenX = WMON (18 decimals), tokenY = USDC (6 decimals)** — confirmed on-chain
+- **Price formula needs decimal adjustment**: `rawPrice * 10^(18-6)` to get human-readable USDC/MON
+- **addLiquidityNATIVE**: msg.value = amountX (MON), contract auto-wraps to WMON
+- **removeLiquidityNATIVE**: token param = USDC address (not WMON)
+- **Single-sided liquidity supported**: if user only has MON or USDC, allocates what's available
+- **Engine uses sequential while-loop**: not setInterval, prevents concurrent poll cycles
+- **Error cooldown 30s**: prevents spam on failures
+- **better-sqlite3 named params**: all keys must exist (use null, not undefined)
+- **Distribution calc**: divide PRECISION by count of eligible bins per side (not total bins)
+
+## Confirmed Contract Addresses (Monad Mainnet)
+
+| Contract | Address |
+|----------|---------|
+| LBRouter V2.2 | 0x18556DA13313f3532c54711497A8FedAC273220E |
+| LBPair (MON/USDC) | 0x5afd3ec861f6104af26e8755abcc1f876de77620 |
+| WMON | 0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A |
+| USDC | 0x754704bc059f8c67012fed69bc8a327a5aafb603 |
+| BinStep | 5 (0.05% per bin) |
+
+## Common Commands
+
+```bash
+pnpm dev              # development server
+pnpm build            # production build
+pnpm start            # production server
+pnpm prettier --write "src/**/*.{ts,tsx}"  # format code
+```
+
+## Known Issues / Gotchas
+
+- External drive (Lexar) creates macOS `._*` resource fork files — added to .gitignore
+- Monad chain ID is **143** (mainnet), not 10143 (testnet)
+- `Hooks__CallFailed (0x6c93cb9b)` revert happens when amounts are heavily imbalanced
+- Gas reserve: 0.5 MON held back from position to cover tx fees

+ 41 - 0
Dockerfile

@@ -0,0 +1,41 @@
+FROM node:22-alpine AS base
+RUN corepack enable && corepack prepare pnpm@latest --activate
+WORKDIR /app
+
+FROM base AS deps
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+
+FROM base AS builder
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+RUN pnpm build
+
+FROM node:22-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs && \
+    adduser --system --uid 1001 nextjs && \
+    apk add --no-cache curl
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
+
+COPY docker-entrypoint.sh /app/docker-entrypoint.sh
+RUN chmod +x /app/docker-entrypoint.sh
+
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=30s \
+  CMD curl -f http://localhost:3000 || exit 1
+
+USER nextjs
+ENTRYPOINT ["/app/docker-entrypoint.sh"]

+ 106 - 20
README.md

@@ -1,36 +1,122 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# LFJ Liquidity Book Auto-Rebalancer
 
-## Getting Started
+Monad mainnet MON/USDC concentrated liquidity auto-rebalancer for LFJ (Liquidity Book) DEX.
 
-First, run the development server:
+Maintains a narrow (3-10 bin) LP position around the active price. When price moves out of range, automatically removes liquidity and re-adds at the new active bin to keep earning rewards.
+
+## Features
+
+- Auto-rebalance on price movement out of range
+- Single-sided or dual-sided liquidity (uses whatever tokens are available)
+- Real-time dashboard with SSE
+- Configurable bin count, position size, slippage, cooldown
+- Rebalance history and stats tracking (SQLite)
+- Bark / Ntfy / Pushover push notifications
+- Docker deployment ready
+
+## Quick Start
 
 ```bash
-npm run dev
-# or
-yarn dev
-# or
+# Install
+pnpm install
+
+# Configure
+cp .env.example .env.local
+# Edit .env.local with your PRIVATE_KEY and RPC
+
+# Run
 pnpm dev
-# or
-bun dev
 ```
 
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open http://localhost:3000, go to **Config** page, click **Start**.
+
+## Environment Variables
+
+```env
+# Required
+PRIVATE_KEY=0x...
+MONAD_RPC_URL=https://your-rpc-endpoint
+
+# Contracts (pre-filled defaults)
+LB_ROUTER_ADDRESS=0x18556DA13313f3532c54711497A8FedAC273220E
+LB_PAIR_ADDRESS=0x5afd3ec861f6104af26e8755abcc1f876de77620
+
+# Strategy
+NUM_BINS=3                    # 3-10 bins
+POSITION_SIZE_USD=50          # USD value per position
+POLL_INTERVAL_MS=3000         # polling frequency
+REBALANCE_COOLDOWN_MS=10000   # min time between rebalances
+SLIPPAGE_BPS=50               # slippage tolerance (0.5%)
+MAX_DAILY_REBALANCES=50
+
+# Notifications (optional)
+NOTIFY_PROVIDER=bark          # bark | ntfy | pushover
+NOTIFY_ENDPOINT=https://api.day.app/YOUR_KEY
+```
+
+## How It Works
+
+```
+Every 3s:
+  1. Read active bin from LBPair
+  2. If no position → open new position
+  3. If active bin outside position range:
+     a. Remove all liquidity (removeLiquidityNATIVE)
+     b. Swap to rebalance if needed
+     c. Open new position at current active bin (addLiquidityNATIVE)
+     d. Log event + send notification
+```
 
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+## Dashboard Pages
 
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+| Page | Description |
+|------|-------------|
+| `/` | Real-time status: engine state, price, position range, wallet balances, bin chart |
+| `/config` | Strategy settings, engine control (start/pause/stop), emergency withdraw |
+| `/history` | Rebalance event history table with stats |
 
-## Learn More
+## Tech Stack
 
-To learn more about Next.js, take a look at the following resources:
+- Next.js 16 (App Router) + TypeScript
+- viem for Monad chain interaction
+- SQLite (better-sqlite3) for persistence
+- Tailwind CSS + shadcn/ui
+- Server-Sent Events for real-time updates
 
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## Docker Deployment
 
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+```bash
+docker compose up -d --build
+```
+
+Data persisted in `./data/rebalancer.db`.
+
+## Project Structure
+
+```
+src/
+  lib/
+    config.ts             # Environment config + types
+    chain/                # viem clients, ABIs, on-chain operations
+    engine/               # Rebalancer engine (poll loop, state machine)
+    db/                   # SQLite schema + queries
+    notifications/        # Push notification providers
+  app/
+    page.tsx              # Dashboard
+    config/page.tsx       # Configuration
+    history/page.tsx      # History
+    api/                  # SSE, control, history API routes
+  hooks/
+    useEngineStatus.ts    # SSE client hook
+```
 
-## Deploy on Vercel
+## Key Contracts
 
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+| Contract | Address | Chain |
+|----------|---------|-------|
+| LBPair (MON/USDC) | `0x5afd...7620` | Monad (143) |
+| LBRouter V2.2 | `0x1855...220E` | Monad (143) |
+| WMON (tokenX) | `0x3bd3...433A` | Monad (143) |
+| USDC (tokenY) | `0x7547...b603` | Monad (143) |
 
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+Bin step: 5 (0.05% per bin)

+ 19 - 0
docker-compose.yml

@@ -0,0 +1,19 @@
+services:
+  app:
+    build: .
+    container_name: lfj-rebalancer
+    restart: unless-stopped
+    ports:
+      - '3000:3000'
+    volumes:
+      - ./data:/app/data
+    env_file:
+      - .env.local
+    environment:
+      - NODE_ENV=production
+    healthcheck:
+      test: ['CMD', 'curl', '-f', 'http://localhost:3000']
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 30s

+ 2 - 0
docker-entrypoint.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+exec node server.js

+ 14 - 0
src/app/api/config/route.ts

@@ -0,0 +1,14 @@
+import { NextResponse } from 'next/server'
+import { config } from '@/lib/config'
+
+export async function GET() {
+  return NextResponse.json({
+    numBins: config.strategy.numBins,
+    positionSizeUSD: config.strategy.positionSizeUSD,
+    slippageBps: config.strategy.slippageBps,
+    rebalanceCooldownMs: config.strategy.rebalanceCooldownMs,
+    maxDailyRebalances: config.strategy.maxDailyRebalances,
+    pollIntervalMs: config.strategy.pollIntervalMs,
+    distribution: config.strategy.distribution,
+  })
+}

+ 30 - 0
src/app/api/control/route.ts

@@ -0,0 +1,30 @@
+import { NextResponse } from 'next/server'
+import { RebalancerEngine } from '@/lib/engine'
+
+export async function POST(request: Request) {
+  try {
+    const body = await request.json()
+    const { action } = body
+    const engine = RebalancerEngine.getInstance()
+
+    switch (action) {
+      case 'start':
+        engine.start()
+        return NextResponse.json({ status: 'running' })
+
+      case 'pause':
+        engine.pause()
+        return NextResponse.json({ status: 'paused' })
+
+      case 'stop':
+        engine.stop()
+        return NextResponse.json({ status: 'idle' })
+
+      default:
+        return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
+    }
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    return NextResponse.json({ error: message }, { status: 500 })
+  }
+}

+ 18 - 0
src/app/api/emergency/route.ts

@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server'
+import { RebalancerEngine } from '@/lib/engine'
+
+export async function POST() {
+  try {
+    const engine = RebalancerEngine.getInstance()
+    const txHash = await engine.emergencyWithdraw()
+
+    return NextResponse.json({
+      success: true,
+      txHash,
+      message: txHash ? 'Position withdrawn' : 'No position to withdraw',
+    })
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    return NextResponse.json({ error: message }, { status: 500 })
+  }
+}

+ 18 - 0
src/app/api/history/route.ts

@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server'
+import { getRebalanceHistory, getRebalanceStats } from '@/lib/db/queries'
+
+export async function GET(request: Request) {
+  try {
+    const { searchParams } = new URL(request.url)
+    const limit = Number(searchParams.get('limit') ?? '50')
+    const offset = Number(searchParams.get('offset') ?? '0')
+
+    const history = getRebalanceHistory(limit, offset)
+    const stats = getRebalanceStats()
+
+    return NextResponse.json({ history, stats })
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    return NextResponse.json({ error: message }, { status: 500 })
+  }
+}

+ 15 - 0
src/app/api/rewards/route.ts

@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server'
+import { getRewardsHistory } from '@/lib/db/queries'
+
+export async function GET(request: Request) {
+  try {
+    const { searchParams } = new URL(request.url)
+    const limit = Number(searchParams.get('limit') ?? '100')
+
+    const rewards = getRewardsHistory(limit)
+    return NextResponse.json({ rewards })
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err)
+    return NextResponse.json({ error: message }, { status: 500 })
+  }
+}

+ 42 - 0
src/app/api/status/route.ts

@@ -0,0 +1,42 @@
+import { RebalancerEngine } from '@/lib/engine'
+
+export const dynamic = 'force-dynamic'
+
+export async function GET() {
+  const encoder = new TextEncoder()
+  let interval: ReturnType<typeof setInterval> | null = null
+
+  const stream = new ReadableStream({
+    start(controller) {
+      const engine = RebalancerEngine.getInstance()
+
+      // Send initial state immediately
+      engine
+        .getState()
+        .then((state) => {
+          controller.enqueue(encoder.encode(`data: ${JSON.stringify(state)}\n\n`))
+        })
+        .catch(() => {})
+
+      interval = setInterval(async () => {
+        try {
+          const state = await engine.getState()
+          controller.enqueue(encoder.encode(`data: ${JSON.stringify(state)}\n\n`))
+        } catch {
+          // skip this tick
+        }
+      }, 3000)
+    },
+    cancel() {
+      if (interval) clearInterval(interval)
+    },
+  })
+
+  return new Response(stream, {
+    headers: {
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache',
+      Connection: 'keep-alive',
+    },
+  })
+}

+ 235 - 0
src/app/config/page.tsx

@@ -0,0 +1,235 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useEngineStatus } from '@/hooks/useEngineStatus'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Slider } from '@/components/ui/slider'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { Play, Pause, Square, AlertTriangle } from 'lucide-react'
+
+interface StrategyConfig {
+  numBins: number
+  positionSizeUSD: number
+  slippageBps: number
+  rebalanceCooldownMs: number
+  maxDailyRebalances: number
+}
+
+export default function ConfigPage() {
+  const { data } = useEngineStatus()
+  const [cfg, setCfg] = useState<StrategyConfig | null>(null)
+  const [numBins, setNumBins] = useState(3)
+  const [loading, setLoading] = useState<string | null>(null)
+
+  useEffect(() => {
+    fetch('/api/config')
+      .then((r) => r.json())
+      .then((c: StrategyConfig) => {
+        setCfg(c)
+        setNumBins(c.numBins)
+      })
+      .catch(console.error)
+  }, [])
+
+  async function controlEngine(action: string) {
+    setLoading(action)
+    try {
+      await fetch('/api/control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ action }),
+      })
+    } finally {
+      setLoading(null)
+    }
+  }
+
+  async function emergencyWithdraw() {
+    setLoading('emergency')
+    try {
+      await fetch('/api/emergency', { method: 'POST' })
+    } finally {
+      setLoading(null)
+    }
+  }
+
+  const status = data?.status ?? 'idle'
+
+  return (
+    <div className="space-y-6">
+      <h2 className="text-2xl font-bold">Configuration</h2>
+
+      {/* Engine Control */}
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center justify-between">
+            Engine Control
+            <Badge
+              variant={
+                status === 'running' ? 'default' : status === 'error' ? 'destructive' : 'secondary'
+              }
+            >
+              {status}
+            </Badge>
+          </CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="flex gap-2">
+            <Button
+              onClick={() => controlEngine('start')}
+              disabled={status === 'running' || loading !== null}
+              className="gap-2"
+            >
+              <Play className="h-4 w-4" />
+              Start
+            </Button>
+            <Button
+              variant="secondary"
+              onClick={() => controlEngine('pause')}
+              disabled={status !== 'running' || loading !== null}
+              className="gap-2"
+            >
+              <Pause className="h-4 w-4" />
+              Pause
+            </Button>
+            <Button
+              variant="secondary"
+              onClick={() => controlEngine('stop')}
+              disabled={status === 'idle' || loading !== null}
+              className="gap-2"
+            >
+              <Square className="h-4 w-4" />
+              Stop
+            </Button>
+          </div>
+
+          <AlertDialog>
+            <AlertDialogTrigger
+              render={
+                <Button variant="destructive" className="gap-2">
+                  <AlertTriangle className="h-4 w-4" />
+                  Emergency Withdraw
+                </Button>
+              }
+            />
+            <AlertDialogContent>
+              <AlertDialogHeader>
+                <AlertDialogTitle>Emergency Withdraw</AlertDialogTitle>
+                <AlertDialogDescription>
+                  This will immediately stop the engine and remove all liquidity. Are you sure?
+                </AlertDialogDescription>
+              </AlertDialogHeader>
+              <AlertDialogFooter>
+                <AlertDialogCancel>Cancel</AlertDialogCancel>
+                <AlertDialogAction onClick={emergencyWithdraw}>Confirm Withdraw</AlertDialogAction>
+              </AlertDialogFooter>
+            </AlertDialogContent>
+          </AlertDialog>
+        </CardContent>
+      </Card>
+
+      {/* Strategy Settings */}
+      <Card>
+        <CardHeader>
+          <CardTitle>Strategy Settings</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-6">
+          <div className="space-y-3">
+            <div className="flex items-center justify-between">
+              <Label>Number of Bins</Label>
+              <span className="text-sm font-medium">{numBins}</span>
+            </div>
+            <Slider
+              value={[numBins]}
+              onValueChange={(v) => setNumBins(Array.isArray(v) ? v[0] : v)}
+              min={3}
+              max={10}
+              step={1}
+            />
+            <p className="text-xs text-muted-foreground">
+              Range: 3 (tightest, highest APR) to 10 (wider, less IL risk)
+            </p>
+          </div>
+
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="space-y-2">
+              <Label>Position Size (USD)</Label>
+              <Input
+                type="number"
+                key={cfg?.positionSizeUSD}
+                defaultValue={cfg?.positionSizeUSD ?? ''}
+                min={1}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>Slippage (bps)</Label>
+              <Input
+                type="number"
+                key={cfg?.slippageBps}
+                defaultValue={cfg?.slippageBps ?? ''}
+                min={10}
+                max={500}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>Cooldown (seconds)</Label>
+              <Input
+                type="number"
+                key={cfg?.rebalanceCooldownMs}
+                defaultValue={cfg ? cfg.rebalanceCooldownMs / 1000 : ''}
+                min={5}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>Max Daily Rebalances</Label>
+              <Input
+                type="number"
+                key={cfg?.maxDailyRebalances}
+                defaultValue={cfg?.maxDailyRebalances ?? ''}
+                min={1}
+              />
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Info */}
+      <Card>
+        <CardHeader>
+          <CardTitle>Contract Addresses</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-2 text-sm">
+          <InfoRow label="LBPair" value="0x5afd...7620" />
+          <InfoRow label="LBRouter" value="0x1855...220E" />
+          <InfoRow label="WMON (tokenX)" value="0x3bd3...433A" />
+          <InfoRow label="USDC (tokenY)" value="0x7547...b603" />
+          <InfoRow label="Bin Step" value="5 (0.05%)" />
+          <InfoRow label="Chain" value="Monad (143)" />
+        </CardContent>
+      </Card>
+    </div>
+  )
+}
+
+function InfoRow({ label, value }: { label: string; value: string }) {
+  return (
+    <div className="flex justify-between">
+      <span className="text-muted-foreground">{label}</span>
+      <span className="font-mono">{value}</span>
+    </div>
+  )
+}

+ 157 - 0
src/app/history/page.tsx

@@ -0,0 +1,157 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { Button } from '@/components/ui/button'
+import type { RebalanceLog } from '@/lib/db/queries'
+
+interface HistoryResponse {
+  history: RebalanceLog[]
+  stats: {
+    total: number
+    today: number
+    avgDurationMs: number
+    totalGasUsed: string
+  }
+}
+
+export default function HistoryPage() {
+  const [data, setData] = useState<HistoryResponse | null>(null)
+  const [page, setPage] = useState(0)
+  const limit = 20
+
+  useEffect(() => {
+    fetch(`/api/history?limit=${limit}&offset=${page * limit}`)
+      .then((r) => r.json())
+      .then(setData)
+      .catch(console.error)
+  }, [page])
+
+  return (
+    <div className="space-y-6">
+      <h2 className="text-2xl font-bold">Rebalance History</h2>
+
+      {/* Stats */}
+      <div className="grid gap-4 sm:grid-cols-4">
+        <StatCard title="Total" value={String(data?.stats.total ?? 0)} />
+        <StatCard title="Today" value={String(data?.stats.today ?? 0)} />
+        <StatCard title="Avg Duration" value={`${data?.stats.avgDurationMs ?? 0}ms`} />
+        <StatCard title="Total Gas" value={data?.stats.totalGasUsed ?? '0'} />
+      </div>
+
+      {/* Table */}
+      <Card>
+        <CardHeader>
+          <CardTitle>Events</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {data?.history && data.history.length > 0 ? (
+            <>
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead>Time</TableHead>
+                    <TableHead>Prev Range</TableHead>
+                    <TableHead>New Range</TableHead>
+                    <TableHead>Swap</TableHead>
+                    <TableHead>Duration</TableHead>
+                    <TableHead>Status</TableHead>
+                    <TableHead>TX</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {data.history.map((row) => (
+                    <TableRow key={row.id}>
+                      <TableCell className="text-xs">
+                        {new Date(row.timestamp).toLocaleString()}
+                      </TableCell>
+                      <TableCell className="font-mono text-xs">
+                        [{row.prevMinBin ?? '?'}, {row.prevMaxBin ?? '?'}]
+                      </TableCell>
+                      <TableCell className="font-mono text-xs">
+                        [{row.newMinBin}, {row.newMaxBin}]
+                      </TableCell>
+                      <TableCell className="text-xs">{row.swapDirection ?? '—'}</TableCell>
+                      <TableCell className="text-xs">
+                        {row.durationMs ? `${row.durationMs}ms` : '—'}
+                      </TableCell>
+                      <TableCell>
+                        <Badge
+                          variant={
+                            row.status === 'success'
+                              ? 'default'
+                              : row.status === 'partial'
+                                ? 'secondary'
+                                : 'destructive'
+                          }
+                        >
+                          {row.status}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="text-xs">
+                        {row.addTxHash ? (
+                          <a
+                            href={`https://monadvision.com/tx/${row.addTxHash}`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-primary hover:underline"
+                          >
+                            {row.addTxHash.slice(0, 8)}...
+                          </a>
+                        ) : (
+                          '—'
+                        )}
+                      </TableCell>
+                    </TableRow>
+                  ))}
+                </TableBody>
+              </Table>
+
+              <div className="mt-4 flex justify-between">
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setPage((p) => Math.max(0, p - 1))}
+                  disabled={page === 0}
+                >
+                  Previous
+                </Button>
+                <span className="text-sm text-muted-foreground">Page {page + 1}</span>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => setPage((p) => p + 1)}
+                  disabled={(data?.history.length ?? 0) < limit}
+                >
+                  Next
+                </Button>
+              </div>
+            </>
+          ) : (
+            <p className="py-8 text-center text-muted-foreground">No rebalance events yet</p>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  )
+}
+
+function StatCard({ title, value }: { title: string; value: string }) {
+  return (
+    <Card>
+      <CardContent className="pt-6">
+        <p className="text-sm text-muted-foreground">{title}</p>
+        <p className="text-2xl font-bold">{value}</p>
+      </CardContent>
+    </Card>
+  )
+}

+ 28 - 16
src/app/layout.tsx

@@ -1,33 +1,45 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
+import type { Metadata } from 'next'
+import { Geist, Geist_Mono } from 'next/font/google'
+import { Providers } from '@/components/providers'
+import { Sidebar } from '@/components/layout/sidebar'
+import { Header } from '@/components/layout/header'
+import './globals.css'
 
 const geistSans = Geist({
-  variable: "--font-geist-sans",
-  subsets: ["latin"],
-});
+  variable: '--font-geist-sans',
+  subsets: ['latin'],
+})
 
 const geistMono = Geist_Mono({
-  variable: "--font-geist-mono",
-  subsets: ["latin"],
-});
+  variable: '--font-geist-mono',
+  subsets: ['latin'],
+})
 
 export const metadata: Metadata = {
-  title: "Create Next App",
-  description: "Generated by create next app",
-};
+  title: 'LFJ Rebalancer',
+  description: 'MON/USDC Liquidity Book Auto-Rebalancer',
+}
 
 export default function RootLayout({
   children,
 }: Readonly<{
-  children: React.ReactNode;
+  children: React.ReactNode
 }>) {
   return (
     <html
       lang="en"
-      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+      className={`${geistSans.variable} ${geistMono.variable}`}
+      suppressHydrationWarning
     >
-      <body className="min-h-full flex flex-col">{children}</body>
+      <body className="flex h-screen overflow-hidden bg-background font-sans text-foreground">
+        <Providers>
+          <Sidebar />
+          <div className="flex flex-1 flex-col overflow-hidden">
+            <Header />
+            <main className="flex-1 overflow-y-auto p-6">{children}</main>
+          </div>
+        </Providers>
+      </body>
     </html>
-  );
+  )
 }

+ 195 - 60
src/app/page.tsx

@@ -1,65 +1,200 @@
-import Image from "next/image";
+'use client'
+
+import { useEngineStatus } from '@/hooks/useEngineStatus'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Activity, TrendingUp, Layers, Wallet } from 'lucide-react'
+
+const statusColors: Record<string, string> = {
+  running: 'bg-green-500',
+  paused: 'bg-yellow-500',
+  idle: 'bg-gray-500',
+  error: 'bg-red-500',
+}
+
+export default function DashboardPage() {
+  const { data, connected } = useEngineStatus()
 
-export default function Home() {
   return (
-    <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
-      <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
-        <Image
-          className="dark:invert"
-          src="/next.svg"
-          alt="Next.js logo"
-          width={100}
-          height={20}
-          priority
-        />
-        <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
-          <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
-            To get started, edit the page.tsx file.
-          </h1>
-          <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
-            Looking for a starting point or more instructions? Head over to{" "}
-            <a
-              href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Templates
-            </a>{" "}
-            or the{" "}
-            <a
-              href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Learning
-            </a>{" "}
-            center.
-          </p>
-        </div>
-        <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
-          <a
-            className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
-            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <Image
-              className="dark:invert"
-              src="/vercel.svg"
-              alt="Vercel logomark"
-              width={16}
-              height={16}
+    <div className="space-y-6">
+      <div className="flex items-center justify-between">
+        <h2 className="text-2xl font-bold">Dashboard</h2>
+        <Badge variant={connected ? 'default' : 'destructive'}>
+          {connected ? 'Connected' : 'Disconnected'}
+        </Badge>
+      </div>
+
+      {/* Status Cards */}
+      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <CardTitle className="text-sm font-medium">Engine Status</CardTitle>
+            <Activity className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="flex items-center gap-2">
+              <div className={`h-2.5 w-2.5 rounded-full ${statusColors[data?.status ?? 'idle']}`} />
+              <span className="text-2xl font-bold capitalize">{data?.status ?? 'idle'}</span>
+            </div>
+            {data?.uptime ? (
+              <p className="text-xs text-muted-foreground">Uptime: {formatDuration(data.uptime)}</p>
+            ) : null}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <CardTitle className="text-sm font-medium">Active Price</CardTitle>
+            <TrendingUp className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="text-2xl font-bold">
+              {data?.currentPrice ? `$${data.currentPrice.toFixed(5)}` : '—'}
+            </div>
+            <p className="text-xs text-muted-foreground">Bin #{data?.currentActiveId ?? '—'}</p>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <CardTitle className="text-sm font-medium">Position Range</CardTitle>
+            <Layers className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            {data?.positionMinBin != null ? (
+              <>
+                <div className="text-2xl font-bold">
+                  [{data.positionMinBin}, {data.positionMaxBin}]
+                </div>
+                <p className="text-xs text-muted-foreground">
+                  {data.currentActiveId != null &&
+                  data.currentActiveId >= data.positionMinBin! &&
+                  data.currentActiveId <= data.positionMaxBin! ? (
+                    <span className="text-green-500">In Range</span>
+                  ) : (
+                    <span className="text-red-500">Out of Range</span>
+                  )}
+                  {' | '}
+                  {data.numBins} bins
+                </p>
+              </>
+            ) : (
+              <div className="text-2xl font-bold text-muted-foreground">No Position</div>
+            )}
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader className="flex flex-row items-center justify-between pb-2">
+            <CardTitle className="text-sm font-medium">Wallet</CardTitle>
+            <Wallet className="h-4 w-4 text-muted-foreground" />
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-1">
+              <p className="text-sm">
+                <span className="font-bold">{formatBalance(data?.walletMon)}</span> MON
+              </p>
+              <p className="text-sm">
+                <span className="font-bold">{formatBalance(data?.walletUsdc)}</span> USDC
+              </p>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Bin Distribution */}
+      <Card>
+        <CardHeader>
+          <CardTitle>Bin Distribution</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {data?.positionBinIds && data.positionBinIds.length > 0 ? (
+            <BinChart binIds={data.positionBinIds} activeId={data.currentActiveId} />
+          ) : (
+            <p className="py-8 text-center text-muted-foreground">No active position</p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Stats + Errors */}
+      <div className="grid gap-4 md:grid-cols-2">
+        <Card>
+          <CardHeader>
+            <CardTitle>Rebalance Stats</CardTitle>
+          </CardHeader>
+          <CardContent className="space-y-2">
+            <div className="flex justify-between text-sm">
+              <span className="text-muted-foreground">Total Rebalances</span>
+              <span className="font-medium">{data?.totalRebalances ?? 0}</span>
+            </div>
+            <div className="flex justify-between text-sm">
+              <span className="text-muted-foreground">Today</span>
+              <span className="font-medium">{data?.todayRebalances ?? 0}</span>
+            </div>
+            <div className="flex justify-between text-sm">
+              <span className="text-muted-foreground">Last Rebalance</span>
+              <span className="font-medium">
+                {data?.lastRebalanceAt ? new Date(data.lastRebalanceAt).toLocaleString() : 'Never'}
+              </span>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card>
+          <CardHeader>
+            <CardTitle>Recent Errors</CardTitle>
+          </CardHeader>
+          <CardContent>
+            {data?.errors && data.errors.length > 0 ? (
+              <div className="max-h-40 space-y-1 overflow-y-auto">
+                {data.errors.map((err, i) => (
+                  <p key={i} className="text-xs text-red-500">
+                    {err}
+                  </p>
+                ))}
+              </div>
+            ) : (
+              <p className="text-sm text-muted-foreground">No errors</p>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  )
+}
+
+function BinChart({ binIds, activeId }: { binIds: number[]; activeId: number | null }) {
+  return (
+    <div className="flex items-end justify-center gap-1" style={{ height: 120 }}>
+      {binIds.map((binId) => {
+        const isActive = binId === activeId
+        return (
+          <div key={binId} className="flex flex-col items-center gap-1">
+            <div
+              className={`w-10 rounded-t ${isActive ? 'bg-primary' : 'bg-muted-foreground/30'}`}
+              style={{ height: isActive ? 80 : 50 }}
             />
-            Deploy Now
-          </a>
-          <a
-            className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
-            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            Documentation
-          </a>
-        </div>
-      </main>
+            <span className="text-[10px] text-muted-foreground">{binId}</span>
+          </div>
+        )
+      })}
     </div>
-  );
+  )
+}
+
+function formatBalance(val?: string): string {
+  if (!val) return '0.00'
+  const num = Number(val)
+  if (num >= 1000) return num.toFixed(2)
+  if (num >= 1) return num.toFixed(4)
+  return num.toFixed(6)
+}
+
+function formatDuration(ms: number): string {
+  const sec = Math.floor(ms / 1000)
+  const min = Math.floor(sec / 60)
+  const hr = Math.floor(min / 60)
+  if (hr > 0) return `${hr}h ${min % 60}m`
+  if (min > 0) return `${min}m ${sec % 60}s`
+  return `${sec}s`
 }

+ 22 - 0
src/components/layout/header.tsx

@@ -0,0 +1,22 @@
+'use client'
+
+import { useTheme } from 'next-themes'
+import { Sun, Moon } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+export function Header() {
+  const { theme, setTheme } = useTheme()
+
+  return (
+    <header className="flex h-14 items-center justify-end border-b px-4">
+      <Button
+        variant="ghost"
+        size="icon"
+        onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
+      >
+        <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+        <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+      </Button>
+    </header>
+  )
+}

+ 42 - 0
src/components/layout/sidebar.tsx

@@ -0,0 +1,42 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { LayoutDashboard, Settings, History } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+const navItems = [
+  { href: '/', label: 'Dashboard', icon: LayoutDashboard },
+  { href: '/config', label: 'Config', icon: Settings },
+  { href: '/history', label: 'History', icon: History },
+]
+
+export function Sidebar() {
+  const pathname = usePathname()
+
+  return (
+    <aside className="flex w-60 flex-col border-r bg-card">
+      <div className="border-b p-4">
+        <h1 className="text-lg font-bold">LFJ Rebalancer</h1>
+        <p className="text-xs text-muted-foreground">MON/USDC Auto LP</p>
+      </div>
+      <nav className="flex-1 space-y-1 p-2">
+        {navItems.map((item) => (
+          <Link
+            key={item.href}
+            href={item.href}
+            className={cn(
+              'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
+              pathname === item.href
+                ? 'bg-primary text-primary-foreground'
+                : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
+            )}
+          >
+            <item.icon className="h-4 w-4" />
+            {item.label}
+          </Link>
+        ))}
+      </nav>
+    </aside>
+  )
+}

+ 12 - 0
src/components/providers.tsx

@@ -0,0 +1,12 @@
+'use client'
+
+import { ThemeProvider } from 'next-themes'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+export function Providers({ children }: { children: React.ReactNode }) {
+  return (
+    <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
+      <TooltipProvider>{children}</TooltipProvider>
+    </ThemeProvider>
+  )
+}

+ 162 - 0
src/components/ui/alert-dialog.tsx

@@ -0,0 +1,162 @@
+'use client'
+
+import * as React from 'react'
+import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+  return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+  return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+  return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+}
+
+function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
+  return (
+    <AlertDialogPrimitive.Backdrop
+      data-slot="alert-dialog-overlay"
+      className={cn(
+        'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogContent({
+  className,
+  size = 'default',
+  ...props
+}: AlertDialogPrimitive.Popup.Props & {
+  size?: 'default' | 'sm'
+}) {
+  return (
+    <AlertDialogPortal>
+      <AlertDialogOverlay />
+      <AlertDialogPrimitive.Popup
+        data-slot="alert-dialog-content"
+        data-size={size}
+        className={cn(
+          'group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
+          className,
+        )}
+        {...props}
+      />
+    </AlertDialogPortal>
+  )
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="alert-dialog-header"
+      className={cn(
+        'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="alert-dialog-footer"
+      className={cn(
+        '-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="alert-dialog-media"
+      className={cn(
+        "mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+  return (
+    <AlertDialogPrimitive.Title
+      data-slot="alert-dialog-title"
+      className={cn(
+        'font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+  return (
+    <AlertDialogPrimitive.Description
+      data-slot="alert-dialog-description"
+      className={cn(
+        'text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
+  return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />
+}
+
+function AlertDialogCancel({
+  className,
+  variant = 'outline',
+  size = 'default',
+  ...props
+}: AlertDialogPrimitive.Close.Props &
+  Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
+  return (
+    <AlertDialogPrimitive.Close
+      data-slot="alert-dialog-cancel"
+      className={cn(className)}
+      render={<Button variant={variant} size={size} />}
+      {...props}
+    />
+  )
+}
+
+export {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogMedia,
+  AlertDialogOverlay,
+  AlertDialogPortal,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+}

+ 49 - 0
src/components/ui/badge.tsx

@@ -0,0 +1,49 @@
+import { mergeProps } from '@base-ui/react/merge-props'
+import { useRender } from '@base-ui/react/use-render'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+  'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
+  {
+    variants: {
+      variant: {
+        default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
+        secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
+        destructive:
+          'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
+        outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
+        ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
+        link: 'text-primary underline-offset-4 hover:underline',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+    },
+  },
+)
+
+function Badge({
+  className,
+  variant = 'default',
+  render,
+  ...props
+}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
+  return useRender({
+    defaultTagName: 'span',
+    props: mergeProps<'span'>(
+      {
+        className: cn(badgeVariants({ variant }), className),
+      },
+      props,
+    ),
+    render,
+    state: {
+      slot: 'badge',
+      variant,
+    },
+  })
+}
+
+export { Badge, badgeVariants }

+ 21 - 21
src/components/ui/button.tsx

@@ -1,49 +1,49 @@
-import { Button as ButtonPrimitive } from "@base-ui/react/button"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Button as ButtonPrimitive } from '@base-ui/react/button'
+import { cva, type VariantProps } from 'class-variance-authority'
 
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils'
 
 const buttonVariants = cva(
   "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
   {
     variants: {
       variant: {
-        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
         outline:
-          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+          'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
         secondary:
-          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+          'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
         ghost:
-          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+          'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
         destructive:
-          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
-        link: "text-primary underline-offset-4 hover:underline",
+          'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
+        link: 'text-primary underline-offset-4 hover:underline',
       },
       size: {
         default:
-          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+          'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
         xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
         sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
-        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
-        icon: "size-8",
-        "icon-xs":
+        lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
+        icon: 'size-8',
+        'icon-xs':
           "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
-        "icon-sm":
-          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
-        "icon-lg": "size-9",
+        'icon-sm':
+          'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
+        'icon-lg': 'size-9',
       },
     },
     defaultVariants: {
-      variant: "default",
-      size: "default",
+      variant: 'default',
+      size: 'default',
     },
-  }
+  },
 )
 
 function Button({
   className,
-  variant = "default",
-  size = "default",
+  variant = 'default',
+  size = 'default',
   ...props
 }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
   return (

+ 92 - 0
src/components/ui/card.tsx

@@ -0,0 +1,92 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Card({
+  className,
+  size = 'default',
+  ...props
+}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
+  return (
+    <div
+      data-slot="card"
+      data-size={size}
+      className={cn(
+        'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn(
+        'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn('text-sm text-muted-foreground', className)}
+      {...props}
+    />
+  )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-action"
+      className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
+      {...props}
+    />
+  )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
+      {...props}
+    />
+  )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn(
+        'flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

+ 20 - 0
src/components/ui/input.tsx

@@ -0,0 +1,20 @@
+import * as React from 'react'
+import { Input as InputPrimitive } from '@base-ui/react/input'
+
+import { cn } from '@/lib/utils'
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+  return (
+    <InputPrimitive
+      type={type}
+      data-slot="input"
+      className={cn(
+        'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Input }

+ 20 - 0
src/components/ui/label.tsx

@@ -0,0 +1,20 @@
+'use client'
+
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Label({ className, ...props }: React.ComponentProps<'label'>) {
+  return (
+    <label
+      data-slot="label"
+      className={cn(
+        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Label }

+ 190 - 0
src/components/ui/select.tsx

@@ -0,0 +1,190 @@
+'use client'
+
+import * as React from 'react'
+import { Select as SelectPrimitive } from '@base-ui/react/select'
+
+import { cn } from '@/lib/utils'
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+  return (
+    <SelectPrimitive.Group
+      data-slot="select-group"
+      className={cn('scroll-my-1 p-1', className)}
+      {...props}
+    />
+  )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+  return (
+    <SelectPrimitive.Value
+      data-slot="select-value"
+      className={cn('flex flex-1 text-left', className)}
+      {...props}
+    />
+  )
+}
+
+function SelectTrigger({
+  className,
+  size = 'default',
+  children,
+  ...props
+}: SelectPrimitive.Trigger.Props & {
+  size?: 'sm' | 'default'
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon
+        render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
+      />
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  side = 'bottom',
+  sideOffset = 4,
+  align = 'center',
+  alignOffset = 0,
+  alignItemWithTrigger = true,
+  ...props
+}: SelectPrimitive.Popup.Props &
+  Pick<
+    SelectPrimitive.Positioner.Props,
+    'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'
+  >) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Positioner
+        side={side}
+        sideOffset={sideOffset}
+        align={align}
+        alignOffset={alignOffset}
+        alignItemWithTrigger={alignItemWithTrigger}
+        className="isolate z-50"
+      >
+        <SelectPrimitive.Popup
+          data-slot="select-content"
+          data-align-trigger={alignItemWithTrigger}
+          className={cn(
+            'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
+            className,
+          )}
+          {...props}
+        >
+          <SelectScrollUpButton />
+          <SelectPrimitive.List>{children}</SelectPrimitive.List>
+          <SelectScrollDownButton />
+        </SelectPrimitive.Popup>
+      </SelectPrimitive.Positioner>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
+  return (
+    <SelectPrimitive.GroupLabel
+      data-slot="select-label"
+      className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className,
+      )}
+      {...props}
+    >
+      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
+        {children}
+      </SelectPrimitive.ItemText>
+      <SelectPrimitive.ItemIndicator
+        render={
+          <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+        }
+      >
+        <CheckIcon className="pointer-events-none" />
+      </SelectPrimitive.ItemIndicator>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
+      {...props}
+    />
+  )
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+  return (
+    <SelectPrimitive.ScrollUpArrow
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronUpIcon />
+    </SelectPrimitive.ScrollUpArrow>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+  return (
+    <SelectPrimitive.ScrollDownArrow
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className,
+      )}
+      {...props}
+    >
+      <ChevronDownIcon />
+    </SelectPrimitive.ScrollDownArrow>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

+ 21 - 0
src/components/ui/separator.tsx

@@ -0,0 +1,21 @@
+'use client'
+
+import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
+
+import { cn } from '@/lib/utils'
+
+function Separator({ className, orientation = 'horizontal', ...props }: SeparatorPrimitive.Props) {
+  return (
+    <SeparatorPrimitive
+      data-slot="separator"
+      orientation={orientation}
+      className={cn(
+        'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Separator }

+ 52 - 0
src/components/ui/slider.tsx

@@ -0,0 +1,52 @@
+import { Slider as SliderPrimitive } from '@base-ui/react/slider'
+
+import { cn } from '@/lib/utils'
+
+function Slider({
+  className,
+  defaultValue,
+  value,
+  min = 0,
+  max = 100,
+  ...props
+}: SliderPrimitive.Root.Props) {
+  const _values = Array.isArray(value)
+    ? value
+    : Array.isArray(defaultValue)
+      ? defaultValue
+      : [min, max]
+
+  return (
+    <SliderPrimitive.Root
+      className={cn('data-horizontal:w-full data-vertical:h-full', className)}
+      data-slot="slider"
+      defaultValue={defaultValue}
+      value={value}
+      min={min}
+      max={max}
+      thumbAlignment="edge"
+      {...props}
+    >
+      <SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
+        <SliderPrimitive.Track
+          data-slot="slider-track"
+          className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
+        >
+          <SliderPrimitive.Indicator
+            data-slot="slider-range"
+            className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
+          />
+        </SliderPrimitive.Track>
+        {Array.from({ length: _values.length }, (_, index) => (
+          <SliderPrimitive.Thumb
+            data-slot="slider-thumb"
+            key={index}
+            className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
+          />
+        ))}
+      </SliderPrimitive.Control>
+    </SliderPrimitive.Root>
+  )
+}
+
+export { Slider }

+ 32 - 0
src/components/ui/switch.tsx

@@ -0,0 +1,32 @@
+'use client'
+
+import { Switch as SwitchPrimitive } from '@base-ui/react/switch'
+
+import { cn } from '@/lib/utils'
+
+function Switch({
+  className,
+  size = 'default',
+  ...props
+}: SwitchPrimitive.Root.Props & {
+  size?: 'sm' | 'default'
+}) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      data-size={size}
+      className={cn(
+        'peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50',
+        className,
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
+      />
+    </SwitchPrimitive.Root>
+  )
+}
+
+export { Switch }

+ 89 - 0
src/components/ui/table.tsx

@@ -0,0 +1,89 @@
+'use client'
+
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Table({ className, ...props }: React.ComponentProps<'table'>) {
+  return (
+    <div data-slot="table-container" className="relative w-full overflow-x-auto">
+      <table
+        data-slot="table"
+        className={cn('w-full caption-bottom text-sm', className)}
+        {...props}
+      />
+    </div>
+  )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
+  return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn('[&_tr:last-child]:border-0', className)}
+      {...props}
+    />
+  )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
+      {...props}
+    />
+  )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
+      {...props}
+    />
+  )
+}
+
+function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn('mt-4 text-sm text-muted-foreground', className)}
+      {...props}
+    />
+  )
+}
+
+export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

+ 75 - 0
src/components/ui/tabs.tsx

@@ -0,0 +1,75 @@
+'use client'
+
+import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
+      {...props}
+    />
+  )
+}
+
+const tabsListVariants = cva(
+  'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
+  {
+    variants: {
+      variant: {
+        default: 'bg-muted',
+        line: 'gap-1 bg-transparent',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+    },
+  },
+)
+
+function TabsList({
+  className,
+  variant = 'default',
+  ...props
+}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+  return (
+    <TabsPrimitive.Tab
+      data-slot="tabs-trigger"
+      className={cn(
+        "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
+        'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
+        'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
+        className,
+      )}
+      {...props}
+    />
+  )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+  return (
+    <TabsPrimitive.Panel
+      data-slot="tabs-content"
+      className={cn('flex-1 text-sm outline-none', className)}
+      {...props}
+    />
+  )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

+ 54 - 0
src/components/ui/tooltip.tsx

@@ -0,0 +1,54 @@
+'use client'
+
+import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
+
+import { cn } from '@/lib/utils'
+
+function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
+  return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
+}
+
+function TooltipContent({
+  className,
+  side = 'top',
+  sideOffset = 4,
+  align = 'center',
+  alignOffset = 0,
+  children,
+  ...props
+}: TooltipPrimitive.Popup.Props &
+  Pick<TooltipPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset'>) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Positioner
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+        className="isolate z-50"
+      >
+        <TooltipPrimitive.Popup
+          data-slot="tooltip-content"
+          className={cn(
+            'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
+            className,
+          )}
+          {...props}
+        >
+          {children}
+          <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
+        </TooltipPrimitive.Popup>
+      </TooltipPrimitive.Positioner>
+    </TooltipPrimitive.Portal>
+  )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 49 - 0
src/hooks/useEngineStatus.ts

@@ -0,0 +1,49 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import type { EngineState } from '@/lib/engine/types'
+
+export function useEngineStatus() {
+  const [data, setData] = useState<EngineState | null>(null)
+  const [connected, setConnected] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const esRef = useRef<EventSource | null>(null)
+
+  useEffect(() => {
+    let retryTimeout: ReturnType<typeof setTimeout>
+
+    function connect() {
+      const es = new EventSource('/api/status')
+      esRef.current = es
+
+      es.onopen = () => {
+        setConnected(true)
+        setError(null)
+      }
+
+      es.onmessage = (event) => {
+        try {
+          const parsed = JSON.parse(event.data)
+          setData(parsed)
+        } catch {
+          // ignore parse errors
+        }
+      }
+
+      es.onerror = () => {
+        setConnected(false)
+        es.close()
+        retryTimeout = setTimeout(connect, 3000)
+      }
+    }
+
+    connect()
+
+    return () => {
+      esRef.current?.close()
+      clearTimeout(retryTimeout)
+    }
+  }, [])
+
+  return { data, connected, error }
+}

+ 9 - 1
src/lib/chain/client.ts

@@ -1,4 +1,4 @@
-import { createPublicClient, createWalletClient, http, type Chain } from 'viem'
+import { createPublicClient, createWalletClient, http, type Chain, type Hash } from 'viem'
 import { privateKeyToAccount } from 'viem/accounts'
 import { config, CHAIN_ID } from '../config'
 
@@ -49,3 +49,11 @@ export function getWalletClient() {
 export function getDeadline(seconds = 300): bigint {
   return BigInt(Math.floor(Date.now() / 1000) + seconds)
 }
+
+export async function waitForTx(hash: Hash): Promise<void> {
+  const client = getPublicClient()
+  const receipt = await client.waitForTransactionReceipt({ hash })
+  if (receipt.status === 'reverted') {
+    throw new Error(`Transaction reverted on-chain: ${hash}`)
+  }
+}

+ 2 - 7
src/lib/chain/index.ts

@@ -1,4 +1,4 @@
-export { monad, getPublicClient, getWalletClient, getAccount, getDeadline } from './client'
+export { monad, getPublicClient, getWalletClient, getAccount, getDeadline, waitForTx } from './client'
 export { LB_PAIR_ABI, LB_ROUTER_ABI, ERC20_ABI } from './abi'
 export {
   getActiveId,
@@ -31,10 +31,5 @@ export {
   estimatePositionValueUSD,
 } from './balances'
 export type { WalletBalances } from './balances'
-export {
-  openPosition,
-  closePosition,
-  rebalanceSwap,
-  calculatePositionAmounts,
-} from './liquidity'
+export { openPosition, closePosition, rebalanceSwap, calculatePositionAmounts } from './liquidity'
 export type { PositionResult, RemoveResult } from './liquidity'

+ 1 - 6
src/lib/chain/liquidity.ts

@@ -95,12 +95,7 @@ export async function closePosition(binIds: number[]): Promise<RemoveResult | nu
   const amountTokenMin = (totalYReserves * slippage) / 10000n // USDC min
   const amountNativeMin = (totalXReserves * slippage) / 10000n // MON min
 
-  const txHash = await removeLiquidityNative(
-    activeBinIds,
-    amounts,
-    amountTokenMin,
-    amountNativeMin,
-  )
+  const txHash = await removeLiquidityNative(activeBinIds, amounts, amountTokenMin, amountNativeMin)
 
   return {
     txHash,

+ 11 - 6
src/lib/chain/pair.ts

@@ -1,4 +1,4 @@
-import { config, BIN_STEP } from '../config'
+import { config, BIN_STEP, MON_DECIMALS, USDC_DECIMALS } from '../config'
 import { getPublicClient, getAccount } from './client'
 import { LB_PAIR_ABI } from './abi'
 
@@ -56,9 +56,7 @@ export async function getUserBinBalance(binId: number): Promise<bigint> {
   })
 }
 
-export async function getUserBinBalancesBatch(
-  binIds: number[],
-): Promise<Map<number, bigint>> {
+export async function getUserBinBalancesBatch(binIds: number[]): Promise<Map<number, bigint>> {
   const client = getPublicClient()
   const account = getAccount()
   const accounts = binIds.map(() => account.address)
@@ -96,12 +94,19 @@ export async function isApprovedForAll(spender: `0x${string}`): Promise<boolean>
   })
 }
 
+/**
+ * Returns human-readable price (USDC per MON).
+ * Raw LB price needs decimal adjustment: price * 10^(decimalsX - decimalsY)
+ * tokenX=WMON(18), tokenY=USDC(6), so multiply by 10^12
+ */
 export function getPriceFromBinId(binId: number, binStep = BIN_STEP): number {
-  return (1 + binStep / 10_000) ** (binId - 8388608)
+  const rawPrice = (1 + binStep / 10_000) ** (binId - 8388608)
+  return rawPrice * 10 ** (MON_DECIMALS - USDC_DECIMALS)
 }
 
 export function getBinIdFromPrice(price: number, binStep = BIN_STEP): number {
-  return Math.round(Math.log(price) / Math.log(1 + binStep / 10_000) + 8388608)
+  const rawPrice = price / 10 ** (MON_DECIMALS - USDC_DECIMALS)
+  return Math.round(Math.log(rawPrice) / Math.log(1 + binStep / 10_000) + 8388608)
 }
 
 export async function getActivePrice(): Promise<{ activeId: number; price: number }> {

+ 34 - 16
src/lib/chain/router.ts

@@ -1,6 +1,6 @@
 import { type Hash, maxUint256 } from 'viem'
 import { config, BIN_STEP } from '../config'
-import { getPublicClient, getWalletClient, getAccount, getDeadline } from './client'
+import { getPublicClient, getWalletClient, getAccount, getDeadline, monad, waitForTx } from './client'
 import { LB_ROUTER_ABI, LB_PAIR_ABI, ERC20_ABI } from './abi'
 import { isApprovedForAll } from './pair'
 
@@ -41,12 +41,14 @@ export async function ensureApprovals(): Promise<void> {
   if (usdcAllowance < maxUint256 / 2n) {
     console.log('[router] Approving USDC for router...')
     const hash = await wallet.writeContract({
+      chain: monad,
+      account: getAccount(),
       address: config.contracts.usdc,
       abi: ERC20_ABI,
       functionName: 'approve',
       args: [router, maxUint256],
     })
-    await client.waitForTransactionReceipt({ hash })
+    await waitForTx(hash)
     console.log('[router] USDC approved:', hash)
   }
 
@@ -55,12 +57,14 @@ export async function ensureApprovals(): Promise<void> {
   if (!approved) {
     console.log('[router] Approving LBPair for router...')
     const hash = await wallet.writeContract({
+      chain: monad,
+      account: getAccount(),
       address: config.contracts.lbPair,
       abi: LB_PAIR_ABI,
       functionName: 'approveForAll',
       args: [router, true],
     })
-    await client.waitForTransactionReceipt({ hash })
+    await waitForTx(hash)
     console.log('[router] LBPair approved:', hash)
   }
 }
@@ -78,8 +82,11 @@ export function buildLiquidityParams(
   const slippageFactor = BigInt(10000 - config.strategy.slippageBps)
 
   // X token distributed at active bin and above, Y at active bin and below
-  const distributionX = deltaIds.map((d) => (d >= 0n ? PRECISION / BigInt(numBins) : 0n))
-  const distributionY = deltaIds.map((d) => (d <= 0n ? PRECISION / BigInt(numBins) : 0n))
+  // Each side must sum to exactly PRECISION (1e18)
+  const xBinCount = deltaIds.filter((d) => d >= 0n).length
+  const yBinCount = deltaIds.filter((d) => d <= 0n).length
+  const distributionX = deltaIds.map((d) => (d >= 0n ? PRECISION / BigInt(xBinCount) : 0n))
+  const distributionY = deltaIds.map((d) => (d <= 0n ? PRECISION / BigInt(yBinCount) : 0n))
 
   return {
     tokenX: config.contracts.tokenX,
@@ -103,17 +110,22 @@ export function buildLiquidityParams(
 export async function addLiquidityNative(params: LiquidityParameters): Promise<Hash> {
   const wallet = getWalletClient()
   const client = getPublicClient()
+  const account = getAccount()
 
-  // msg.value = amountX because tokenX = WMON (native)
-  const hash = await wallet.writeContract({
+  const callParams = {
     address: config.contracts.lbRouter,
     abi: LB_ROUTER_ABI,
-    functionName: 'addLiquidityNATIVE',
-    args: [params],
+    functionName: 'addLiquidityNATIVE' as const,
+    args: [params] as const,
     value: params.amountX,
-  })
+    account,
+    chain: monad,
+  }
+
+  await client.simulateContract(callParams)
+  const hash = await wallet.writeContract(callParams)
 
-  await client.waitForTransactionReceipt({ hash })
+  await waitForTx(hash)
   console.log('[router] addLiquidityNATIVE tx:', hash)
   return hash
 }
@@ -130,6 +142,8 @@ export async function removeLiquidityNative(
 
   // token param = USDC (non-native token) for removeLiquidityNATIVE
   const hash = await wallet.writeContract({
+    chain: monad,
+    account: getAccount(),
     address: config.contracts.lbRouter,
     abi: LB_ROUTER_ABI,
     functionName: 'removeLiquidityNATIVE',
@@ -145,7 +159,7 @@ export async function removeLiquidityNative(
     ],
   })
 
-  await client.waitForTransactionReceipt({ hash })
+  await waitForTx(hash)
   console.log('[router] removeLiquidityNATIVE tx:', hash)
   return hash
 }
@@ -160,11 +174,13 @@ export async function swapExactNativeForTokens(
 
   const path = {
     pairBinSteps: [BigInt(BIN_STEP)],
-    versions: [2], // V2.2
+    versions: [3], // V2.2 enum: V1=0, V2=1, V2_1=2, V2_2=3
     tokenPath: [config.contracts.wmon, config.contracts.usdc],
   }
 
   const hash = await wallet.writeContract({
+    chain: monad,
+    account: getAccount(),
     address: config.contracts.lbRouter,
     abi: LB_ROUTER_ABI,
     functionName: 'swapExactNATIVEForTokens',
@@ -172,7 +188,7 @@ export async function swapExactNativeForTokens(
     value: amountIn,
   })
 
-  await client.waitForTransactionReceipt({ hash })
+  await waitForTx(hash)
   console.log('[router] swapExactNATIVEForTokens tx:', hash)
   return hash
 }
@@ -187,18 +203,20 @@ export async function swapExactTokensForNative(
 
   const path = {
     pairBinSteps: [BigInt(BIN_STEP)],
-    versions: [2], // V2.2
+    versions: [3], // V2.2 enum: V1=0, V2=1, V2_1=2, V2_2=3
     tokenPath: [config.contracts.usdc, config.contracts.wmon],
   }
 
   const hash = await wallet.writeContract({
+    chain: monad,
+    account: getAccount(),
     address: config.contracts.lbRouter,
     abi: LB_ROUTER_ABI,
     functionName: 'swapExactTokensForNATIVE',
     args: [amountIn, amountOutMin, path, account.address, getDeadline()],
   })
 
-  await client.waitForTransactionReceipt({ hash })
+  await waitForTx(hash)
   console.log('[router] swapExactTokensForNATIVE tx:', hash)
   return hash
 }

+ 1 - 1
src/lib/config.ts

@@ -37,7 +37,7 @@ const addr = (key: string, fallback = ''): `0x${string}` => {
   return v as `0x${string}`
 }
 
-export const CHAIN_ID = 10143
+export const CHAIN_ID = 143
 export const BIN_STEP = Number(env('BIN_STEP', '5'))
 export const MON_DECIMALS = 18
 export const USDC_DECIMALS = 6

+ 42 - 15
src/lib/db/queries.ts

@@ -67,14 +67,37 @@ export function logRebalance(data: RebalanceLogInsert): number {
       @status, @errorMessage, @durationMs
     )
   `)
-  const result = stmt.run(data)
+  // better-sqlite3 requires all named params to exist and not be undefined
+  const params: Record<string, unknown> = {
+    prevActiveId: null,
+    newActiveId: null,
+    prevMinBin: null,
+    prevMaxBin: null,
+    newMinBin: null,
+    newMaxBin: null,
+    amountXRemoved: null,
+    amountYRemoved: null,
+    swapDirection: null,
+    swapAmount: null,
+    amountXAdded: null,
+    amountYAdded: null,
+    removeTxHash: null,
+    swapTxHash: null,
+    addTxHash: null,
+    gasUsed: null,
+    gasPrice: null,
+    status: null,
+    errorMessage: null,
+    durationMs: null,
+  }
+  for (const [k, v] of Object.entries(data)) {
+    params[k] = v === undefined ? null : v
+  }
+  const result = stmt.run(params)
   return Number(result.lastInsertRowid)
 }
 
-export function getRebalanceHistory(
-  limit = 50,
-  offset = 0,
-): RebalanceLog[] {
+export function getRebalanceHistory(limit = 50, offset = 0): RebalanceLog[] {
   const db = getDb()
   const rows = db
     .prepare('SELECT * FROM rebalance_log ORDER BY id DESC LIMIT ? OFFSET ?')
@@ -96,9 +119,7 @@ export function getRebalanceStats(): {
 
   const today = (
     db
-      .prepare(
-        "SELECT COUNT(*) as count FROM rebalance_log WHERE date(timestamp) = date('now')",
-      )
+      .prepare("SELECT COUNT(*) as count FROM rebalance_log WHERE date(timestamp) = date('now')")
       .get() as { count: number }
   ).count
 
@@ -108,7 +129,7 @@ export function getRebalanceStats(): {
 
   const gasRow = db
     .prepare(
-      "SELECT COALESCE(SUM(CAST(gas_used AS REAL)), 0) as total FROM rebalance_log WHERE gas_used IS NOT NULL",
+      'SELECT COALESCE(SUM(CAST(gas_used AS REAL)), 0) as total FROM rebalance_log WHERE gas_used IS NOT NULL',
     )
     .get() as { total: number }
 
@@ -122,13 +143,15 @@ export function getRebalanceStats(): {
 
 export function upsertPosition(data: PositionData): void {
   const db = getDb()
-  db.prepare(`
+  db.prepare(
+    `
     INSERT OR REPLACE INTO current_position (
       id, active_id, min_bin, max_bin, num_bins, bin_ids, amounts, price_at_open, updated_at
     ) VALUES (
       1, @activeId, @minBin, @maxBin, @numBins, @binIds, @amounts, @priceAtOpen, datetime('now')
     )
-  `).run({
+  `,
+  ).run({
     activeId: data.activeId,
     minBin: data.minBin,
     maxBin: data.maxBin,
@@ -167,10 +190,12 @@ export function deleteCurrentPosition(): void {
 
 export function snapshotRewards(data: RewardsData): void {
   const db = getDb()
-  db.prepare(`
+  db.prepare(
+    `
     INSERT INTO rewards_log (balance_mon, balance_usdc, balance_usd, cumulative_gas_usd)
     VALUES (@balanceMon, @balanceUsdc, @balanceUsd, @cumulativeGasUsd)
-  `).run(data)
+  `,
+  ).run(data)
 }
 
 export function getRewardsHistory(limit = 100): RewardsData[] {
@@ -190,10 +215,12 @@ export function getEngineState(key: string): string | null {
 
 export function setEngineState(key: string, value: string): void {
   const db = getDb()
-  db.prepare(`
+  db.prepare(
+    `
     INSERT OR REPLACE INTO engine_state (key, value, updated_at)
     VALUES (?, ?, datetime('now'))
-  `).run(key, value)
+  `,
+  ).run(key, value)
 }
 
 export function getDailyRebalanceCount(): number {

+ 138 - 68
src/lib/engine/engine.ts

@@ -1,4 +1,4 @@
-import { formatUnits } from 'viem'
+import { formatUnits, parseUnits } from 'viem'
 import { config, MON_DECIMALS, USDC_DECIMALS } from '../config'
 import {
   getActiveId,
@@ -8,7 +8,6 @@ import {
   openPosition,
   closePosition,
   rebalanceSwap,
-  calculatePositionAmounts,
 } from '../chain'
 import {
   getCurrentPosition,
@@ -21,18 +20,21 @@ import {
   setEngineState,
   getEngineState,
 } from '../db/queries'
-import { sendNotification, notifyRebalance, notifyError } from '../notifications'
+import { notifyRebalance, notifyError, sendNotification } from '../notifications'
 import type { EngineStatus, EngineState, RebalanceResult } from './types'
 
+const GAS_RESERVE_MON = 0.5
+const ERROR_COOLDOWN_MS = 30_000 // 30s cooldown after error
+
 export class RebalancerEngine {
   private static instance: RebalancerEngine | null = null
 
-  private pollTimer: ReturnType<typeof setInterval> | null = null
   private status: EngineStatus = 'idle'
   private cooldownUntil = 0
   private errors: string[] = []
   private startedAt = 0
-  private polling = false
+  private running = false
+  private stopRequested = false
 
   private constructor() {}
 
@@ -44,28 +46,25 @@ export class RebalancerEngine {
   }
 
   start(): void {
-    if (this.status === 'running') return
+    if (this.running) return
     this.status = 'running'
     this.startedAt = Date.now()
+    this.stopRequested = false
     setEngineState('status', 'running')
     console.log('[engine] Started')
-
-    this.pollTimer = setInterval(() => this.pollCycle(), config.strategy.pollIntervalMs)
+    this.runLoop()
   }
 
   pause(): void {
-    if (this.pollTimer) clearInterval(this.pollTimer)
-    this.pollTimer = null
+    this.stopRequested = true
     this.status = 'paused'
     setEngineState('status', 'paused')
     console.log('[engine] Paused')
   }
 
   stop(): void {
-    if (this.pollTimer) clearInterval(this.pollTimer)
-    this.pollTimer = null
+    this.stopRequested = true
     this.status = 'idle'
-    this.polling = false
     setEngineState('status', 'idle')
     console.log('[engine] Stopped')
   }
@@ -109,7 +108,7 @@ export class RebalancerEngine {
       currentActiveId = ap.activeId
       currentPrice = ap.price
     } catch {
-      // chain read failed, leave as null
+      // chain read failed
     }
 
     const position = getCurrentPosition()
@@ -144,65 +143,120 @@ export class RebalancerEngine {
     }
   }
 
-  private async pollCycle(): Promise<void> {
-    if (this.status !== 'running' || this.polling) return
-    this.polling = true
+  // ── Main loop: sequential, waits for each cycle to finish ──
+
+  private async runLoop(): Promise<void> {
+    if (this.running) return
+    this.running = true
+
+    while (!this.stopRequested) {
+      try {
+        await this.pollCycle()
+      } catch (err) {
+        const msg = err instanceof Error ? err.message : String(err)
+        this.addError(msg)
+        console.error('[engine] Poll error:', msg)
+        // Cooldown after unexpected error
+        this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ERROR_COOLDOWN_MS)
+      }
 
-    try {
-      const currentActiveId = await getActiveId()
-      const position = getCurrentPosition()
+      // Wait before next cycle
+      await this.sleep(config.strategy.pollIntervalMs)
+    }
 
-      if (!position) {
-        console.log('[engine] No position, opening new one at bin', currentActiveId)
-        await this.openNewPosition(currentActiveId)
-        return
-      }
+    this.running = false
+    console.log('[engine] Loop exited')
+  }
 
-      // Check if active bin is within position range
-      if (currentActiveId >= position.minBin && currentActiveId <= position.maxBin) {
-        return // In range, no action needed
-      }
+  private async pollCycle(): Promise<void> {
+    // Respect cooldown
+    const now = Date.now()
+    if (now < this.cooldownUntil) return
 
-      // Out of range — check cooldown
-      if (Date.now() < this.cooldownUntil) {
-        console.log('[engine] Cooldown active, skipping rebalance')
-        return
-      }
+    const currentActiveId = await getActiveId()
+    const position = getCurrentPosition()
 
-      // Check daily limit
-      const dailyCount = getDailyRebalanceCount()
-      if (dailyCount >= config.strategy.maxDailyRebalances) {
-        console.log('[engine] Daily rebalance limit reached:', dailyCount)
-        return
-      }
+    if (!position) {
+      console.log('[engine] No position found, opening at bin', currentActiveId)
+      await this.openNewPosition(currentActiveId)
+      // Cooldown after opening to let chain settle
+      this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
+      return
+    }
 
-      console.log(
-        `[engine] Out of range! Active: ${currentActiveId}, Range: [${position.minBin}, ${position.maxBin}]`,
-      )
-      await this.executeRebalance(currentActiveId, position)
-    } catch (err) {
-      const msg = err instanceof Error ? err.message : String(err)
-      this.addError(msg)
-      console.error('[engine] Poll cycle error:', msg)
-    } finally {
-      this.polling = false
+    // In range → nothing to do
+    if (currentActiveId >= position.minBin && currentActiveId <= position.maxBin) {
+      return
     }
+
+    // Out of range → check daily limit
+    const dailyCount = getDailyRebalanceCount()
+    if (dailyCount >= config.strategy.maxDailyRebalances) {
+      return
+    }
+
+    console.log(
+      `[engine] Out of range: active=${currentActiveId}, position=[${position.minBin}, ${position.maxBin}]`,
+    )
+    await this.executeRebalance(currentActiveId, position)
+    this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
   }
 
+  // ── Open position ──
+
   private async openNewPosition(activeId: number): Promise<void> {
     const price = getPriceFromBinId(activeId)
     const balances = await getWalletBalances()
     const monBalance = Number(formatUnits(balances.mon, MON_DECIMALS))
     const usdcBalance = Number(formatUnits(balances.usdc, USDC_DECIMALS))
-    const totalUsd = monBalance * price + usdcBalance
 
-    const positionSizeUsd = Math.min(config.strategy.positionSizeUSD, totalUsd * 0.95)
+    const availableMon = Math.max(0, monBalance - GAS_RESERVE_MON)
+    const availableMonUsd = availableMon * price
+    const availableUsdcUsd = usdcBalance
+    const totalAvailableUsd = availableMonUsd + availableUsdcUsd
+
+    const positionSizeUsd = Math.min(config.strategy.positionSizeUSD, totalAvailableUsd * 0.95)
+
     if (positionSizeUsd < 1) {
-      this.addError('Insufficient balance to open position')
+      this.addError(
+        `Insufficient balance: ${availableMonUsd.toFixed(2)}U MON, ${availableUsdcUsd.toFixed(2)}U USDC`,
+      )
+      // Long cooldown to avoid spamming
+      this.cooldownUntil = Date.now() + 60_000
       return
     }
 
-    const { amountX, amountY } = calculatePositionAmounts(positionSizeUsd, price)
+    // Allocate each side: ideal 50/50, but allow single-sided
+    const idealHalf = positionSizeUsd / 2
+    let monUsdToUse = Math.min(idealHalf, availableMonUsd)
+    let usdcUsdToUse = Math.min(idealHalf, availableUsdcUsd)
+
+    // If one side can't fill half, give remainder to the other
+    if (monUsdToUse < idealHalf) {
+      usdcUsdToUse = Math.min(usdcUsdToUse + (idealHalf - monUsdToUse), availableUsdcUsd)
+    }
+    if (usdcUsdToUse < idealHalf) {
+      monUsdToUse = Math.min(monUsdToUse + (idealHalf - usdcUsdToUse), availableMonUsd)
+    }
+
+    const amountX =
+      monUsdToUse > 0
+        ? parseUnits((monUsdToUse / price).toFixed(MON_DECIMALS), MON_DECIMALS)
+        : 0n
+    const amountY =
+      usdcUsdToUse > 0
+        ? parseUnits(usdcUsdToUse.toFixed(USDC_DECIMALS), USDC_DECIMALS)
+        : 0n
+
+    if (amountX === 0n && amountY === 0n) {
+      this.addError('Insufficient balance after gas reserve')
+      return
+    }
+
+    console.log(
+      `[engine] Opening: ${monUsdToUse.toFixed(2)}U MON + ${usdcUsdToUse.toFixed(2)}U USDC = $${(monUsdToUse + usdcUsdToUse).toFixed(2)}`,
+    )
+
     const result = await openPosition(activeId, config.strategy.numBins, amountX, amountY)
 
     upsertPosition({
@@ -216,10 +270,12 @@ export class RebalancerEngine {
     })
 
     console.log(
-      `[engine] Position opened at bin ${activeId}, range [${result.minBin}, ${result.maxBin}]`,
+      `[engine] Position opened: bin=${activeId}, range=[${result.minBin}, ${result.maxBin}], tx=${result.txHash}`,
     )
   }
 
+  // ── Rebalance ──
+
   private async executeRebalance(
     newActiveId: number,
     position: NonNullable<ReturnType<typeof getCurrentPosition>>,
@@ -234,26 +290,34 @@ export class RebalancerEngine {
 
     try {
       // Step 1: Remove liquidity
-      console.log('[engine] Removing liquidity...')
+      console.log('[engine] Step 1/3: Removing liquidity...')
       const removeResult = await closePosition(position.binIds)
       if (removeResult) {
         result.removeTxHash = removeResult.txHash
         result.amountXRemoved = removeResult.amountX.toString()
         result.amountYRemoved = removeResult.amountY.toString()
+        console.log(`[engine] Removed: tx=${removeResult.txHash}`)
       }
       deleteCurrentPosition()
 
-      // Step 2: Swap to rebalance if needed
+      // Step 2: Swap to rebalance if needed (non-blocking: if swap fails, continue with available tokens)
+      console.log('[engine] Step 2/3: Checking swap...')
       const price = getPriceFromBinId(newActiveId)
-      console.log('[engine] Checking swap rebalance...')
-      const swapResult = await rebalanceSwap(price)
-      if (swapResult.swapTxHash) {
-        result.swapTxHash = swapResult.swapTxHash
-        result.swapDirection = swapResult.direction
+      try {
+        const swapResult = await rebalanceSwap(price)
+        if (swapResult.swapTxHash) {
+          result.swapTxHash = swapResult.swapTxHash
+          result.swapDirection = swapResult.direction
+          console.log(`[engine] Swapped: ${swapResult.direction}, tx=${swapResult.swapTxHash}`)
+        } else {
+          console.log('[engine] No swap needed')
+        }
+      } catch (swapErr) {
+        console.warn('[engine] Swap failed, continuing with available tokens:', swapErr instanceof Error ? swapErr.message : swapErr)
       }
 
       // Step 3: Open new position
-      console.log('[engine] Opening new position at bin', newActiveId)
+      console.log('[engine] Step 3/3: Opening new position at bin', newActiveId)
       await this.openNewPosition(newActiveId)
 
       const pos = getCurrentPosition()
@@ -262,7 +326,6 @@ export class RebalancerEngine {
       result.success = true
       result.durationMs = Date.now() - startTime
 
-      // Log to DB
       logRebalance({
         prevActiveId: result.prevActiveId!,
         newActiveId: result.newActiveId!,
@@ -280,12 +343,12 @@ export class RebalancerEngine {
         durationMs: result.durationMs,
       })
 
-      // Update state
-      this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
       incrementDailyRebalanceCount()
       setEngineState('last_rebalance_at', new Date().toISOString())
 
-      console.log(`[engine] Rebalance complete in ${result.durationMs}ms`)
+      console.log(
+        `[engine] Rebalance complete: [${result.prevMinBin},${result.prevMaxBin}] → [${result.newMinBin},${result.newMaxBin}] in ${result.durationMs}ms`,
+      )
       await notifyRebalance(result as RebalanceResult)
     } catch (err) {
       const msg = err instanceof Error ? err.message : String(err)
@@ -306,12 +369,19 @@ export class RebalancerEngine {
       })
 
       this.addError(`Rebalance failed: ${msg}`)
+      console.error('[engine] Rebalance failed:', msg)
       await notifyError(`Rebalance failed: ${msg}`)
     }
   }
 
+  // ── Helpers ──
+
   private addError(msg: string): void {
-    this.errors.push(`${new Date().toISOString()} ${msg}`)
+    this.errors.push(`${new Date().toISOString().slice(11, 19)} ${msg}`)
     if (this.errors.length > 20) this.errors.shift()
   }
+
+  private sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms))
+  }
 }

+ 2 - 2
src/lib/utils.ts

@@ -1,5 +1,5 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
 
 export function cn(...inputs: ClassValue[]) {
   return twMerge(clsx(inputs))

+ 1 - 1
tsconfig.json

@@ -1,6 +1,6 @@
 {
   "compilerOptions": {
-    "target": "ES2017",
+    "target": "ES2020",
     "lib": ["dom", "dom.iterable", "esnext"],
     "allowJs": true,
     "skipLibCheck": true,