Browse Source

feat: initial commit

zhangchunrui 3 weeks ago
parent
commit
12582d323a

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "singleQuote": true,
+  "semi": false,
+  "printWidth": 100,
+  "trailingComma": "all",
+  "tabWidth": 2
+}

+ 25 - 0
components.json

@@ -0,0 +1,25 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "base-nova",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/app/globals.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "menuColor": "default",
+  "menuAccent": "subtle",
+  "registries": {}
+}

+ 5 - 4
next.config.ts

@@ -1,7 +1,8 @@
-import type { NextConfig } from "next";
+import type { NextConfig } from 'next'
 
 
 const nextConfig: NextConfig = {
 const nextConfig: NextConfig = {
-  /* config options here */
-};
+  output: 'standalone',
+  serverExternalPackages: ['better-sqlite3'],
+}
 
 
-export default nextConfig;
+export default nextConfig

+ 19 - 1
package.json

@@ -9,17 +9,35 @@
     "lint": "eslint"
     "lint": "eslint"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@base-ui/react": "^1.3.0",
+    "better-sqlite3": "^12.8.0",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^1.8.0",
     "next": "16.2.3",
     "next": "16.2.3",
+    "next-themes": "^0.4.6",
     "react": "19.2.4",
     "react": "19.2.4",
-    "react-dom": "19.2.4"
+    "react-dom": "19.2.4",
+    "shadcn": "^4.2.0",
+    "swr": "^2.4.1",
+    "tailwind-merge": "^3.5.0",
+    "tw-animate-css": "^1.4.0",
+    "viem": "^2.47.12"
+  },
+  "pnpm": {
+    "onlyBuiltDependencies": [
+      "better-sqlite3"
+    ]
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@tailwindcss/postcss": "^4",
     "@tailwindcss/postcss": "^4",
+    "@types/better-sqlite3": "^7.6.13",
     "@types/node": "^20",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react": "^19",
     "@types/react-dom": "^19",
     "@types/react-dom": "^19",
     "eslint": "^9",
     "eslint": "^9",
     "eslint-config-next": "16.2.3",
     "eslint-config-next": "16.2.3",
+    "prettier": "^3.8.2",
     "tailwindcss": "^4",
     "tailwindcss": "^4",
     "typescript": "^5"
     "typescript": "^5"
   }
   }

File diff suppressed because it is too large
+ 522 - 107
pnpm-lock.yaml


+ 118 - 14
src/app/globals.css

@@ -1,26 +1,130 @@
 @import "tailwindcss";
 @import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
 
 
-:root {
-  --background: #ffffff;
-  --foreground: #171717;
-}
+@custom-variant dark (&:is(.dark *));
 
 
 @theme inline {
 @theme inline {
   --color-background: var(--background);
   --color-background: var(--background);
   --color-foreground: var(--foreground);
   --color-foreground: var(--foreground);
-  --font-sans: var(--font-geist-sans);
+  --font-sans: var(--font-sans);
   --font-mono: var(--font-geist-mono);
   --font-mono: var(--font-geist-mono);
+  --font-heading: var(--font-sans);
+  --color-sidebar-ring: var(--sidebar-ring);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar: var(--sidebar);
+  --color-chart-5: var(--chart-5);
+  --color-chart-4: var(--chart-4);
+  --color-chart-3: var(--chart-3);
+  --color-chart-2: var(--chart-2);
+  --color-chart-1: var(--chart-1);
+  --color-ring: var(--ring);
+  --color-input: var(--input);
+  --color-border: var(--border);
+  --color-destructive: var(--destructive);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-accent: var(--accent);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-muted: var(--muted);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-secondary: var(--secondary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-primary: var(--primary);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-popover: var(--popover);
+  --color-card-foreground: var(--card-foreground);
+  --color-card: var(--card);
+  --radius-sm: calc(var(--radius) * 0.6);
+  --radius-md: calc(var(--radius) * 0.8);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) * 1.4);
+  --radius-2xl: calc(var(--radius) * 1.8);
+  --radius-3xl: calc(var(--radius) * 2.2);
+  --radius-4xl: calc(var(--radius) * 2.6);
 }
 }
 
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
-  }
+:root {
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.97 0 0);
+  --muted-foreground: oklch(0.556 0 0);
+  --accent: oklch(0.97 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.577 0.245 27.325);
+  --border: oklch(0.922 0 0);
+  --input: oklch(0.922 0 0);
+  --ring: oklch(0.708 0 0);
+  --chart-1: oklch(0.87 0 0);
+  --chart-2: oklch(0.556 0 0);
+  --chart-3: oklch(0.439 0 0);
+  --chart-4: oklch(0.371 0 0);
+  --chart-5: oklch(0.269 0 0);
+  --radius: 0.625rem;
+  --sidebar: oklch(0.985 0 0);
+  --sidebar-foreground: oklch(0.145 0 0);
+  --sidebar-primary: oklch(0.205 0 0);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.97 0 0);
+  --sidebar-accent-foreground: oklch(0.205 0 0);
+  --sidebar-border: oklch(0.922 0 0);
+  --sidebar-ring: oklch(0.708 0 0);
 }
 }
 
 
-body {
-  background: var(--background);
-  color: var(--foreground);
-  font-family: Arial, Helvetica, sans-serif;
+.dark {
+  --background: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.205 0 0);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.922 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.269 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.269 0 0);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.216);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.556 0 0);
+  --chart-1: oklch(0.87 0 0);
+  --chart-2: oklch(0.556 0 0);
+  --chart-3: oklch(0.439 0 0);
+  --chart-4: oklch(0.371 0 0);
+  --chart-5: oklch(0.269 0 0);
+  --sidebar: oklch(0.205 0 0);
+  --sidebar-foreground: oklch(0.985 0 0);
+  --sidebar-primary: oklch(0.488 0.243 264.376);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.269 0 0);
+  --sidebar-accent-foreground: oklch(0.985 0 0);
+  --sidebar-border: oklch(1 0 0 / 10%);
+  --sidebar-ring: oklch(0.556 0 0);
 }
 }
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+  html {
+    @apply font-sans;
+  }
+}

+ 58 - 0
src/components/ui/button.tsx

@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+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",
+        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",
+        secondary:
+          "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",
+        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",
+      },
+      size: {
+        default:
+          "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":
+          "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",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+  return (
+    <ButtonPrimitive
+      data-slot="button"
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 340 - 0
src/lib/chain/abi.ts

@@ -0,0 +1,340 @@
+export const LB_PAIR_ABI = [
+  {
+    name: 'getActiveId',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'activeId', type: 'uint24' }],
+  },
+  {
+    name: 'getBinStep',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'binStep', type: 'uint16' }],
+  },
+  {
+    name: 'getReserves',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [
+      { name: 'reserveX', type: 'uint128' },
+      { name: 'reserveY', type: 'uint128' },
+    ],
+  },
+  {
+    name: 'getBin',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [{ name: 'id', type: 'uint24' }],
+    outputs: [
+      { name: 'binReserveX', type: 'uint128' },
+      { name: 'binReserveY', type: 'uint128' },
+    ],
+  },
+  {
+    name: 'balanceOf',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'account', type: 'address' },
+      { name: 'id', type: 'uint256' },
+    ],
+    outputs: [{ name: 'balance', type: 'uint256' }],
+  },
+  {
+    name: 'balanceOfBatch',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'accounts', type: 'address[]' },
+      { name: 'ids', type: 'uint256[]' },
+    ],
+    outputs: [{ name: 'balances', type: 'uint256[]' }],
+  },
+  {
+    name: 'approveForAll',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'spender', type: 'address' },
+      { name: 'approved', type: 'bool' },
+    ],
+    outputs: [],
+  },
+  {
+    name: 'isApprovedForAll',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'owner', type: 'address' },
+      { name: 'spender', type: 'address' },
+    ],
+    outputs: [{ name: 'approved', type: 'bool' }],
+  },
+  {
+    name: 'totalSupply',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [{ name: 'id', type: 'uint256' }],
+    outputs: [{ name: 'supply', type: 'uint256' }],
+  },
+  {
+    name: 'getTokenX',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'tokenX', type: 'address' }],
+  },
+  {
+    name: 'getTokenY',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'tokenY', type: 'address' }],
+  },
+] as const
+
+const liquidityParametersTuple = {
+  name: 'liquidityParameters',
+  type: 'tuple',
+  components: [
+    { name: 'tokenX', type: 'address' },
+    { name: 'tokenY', type: 'address' },
+    { name: 'binStep', type: 'uint256' },
+    { name: 'amountX', type: 'uint256' },
+    { name: 'amountY', type: 'uint256' },
+    { name: 'amountXMin', type: 'uint256' },
+    { name: 'amountYMin', type: 'uint256' },
+    { name: 'activeIdDesired', type: 'uint256' },
+    { name: 'idSlippage', type: 'uint256' },
+    { name: 'deltaIds', type: 'int256[]' },
+    { name: 'distributionX', type: 'uint256[]' },
+    { name: 'distributionY', type: 'uint256[]' },
+    { name: 'to', type: 'address' },
+    { name: 'refundTo', type: 'address' },
+    { name: 'deadline', type: 'uint256' },
+  ],
+} as const
+
+const liquidityOutputs = [
+  { name: 'amountXAdded', type: 'uint256' },
+  { name: 'amountYAdded', type: 'uint256' },
+  { name: 'amountXLeft', type: 'uint256' },
+  { name: 'amountYLeft', type: 'uint256' },
+  { name: 'depositIds', type: 'uint256[]' },
+  { name: 'liquidityMinted', type: 'uint256[]' },
+] as const
+
+const pathTuple = {
+  name: 'path',
+  type: 'tuple',
+  components: [
+    { name: 'pairBinSteps', type: 'uint256[]' },
+    { name: 'versions', type: 'uint8[]' },
+    { name: 'tokenPath', type: 'address[]' },
+  ],
+} as const
+
+export const LB_ROUTER_ABI = [
+  {
+    name: 'getFactory',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'lbFactory', type: 'address' }],
+  },
+  {
+    name: 'getWNATIVE',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'wnative', type: 'address' }],
+  },
+  {
+    name: 'getIdFromPrice',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'pair', type: 'address' },
+      { name: 'price', type: 'uint256' },
+    ],
+    outputs: [{ name: 'id', type: 'uint24' }],
+  },
+  {
+    name: 'getPriceFromId',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'pair', type: 'address' },
+      { name: 'id', type: 'uint24' },
+    ],
+    outputs: [{ name: 'price', type: 'uint256' }],
+  },
+  {
+    name: 'getSwapIn',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'pair', type: 'address' },
+      { name: 'amountOut', type: 'uint128' },
+      { name: 'swapForY', type: 'bool' },
+    ],
+    outputs: [
+      { name: 'amountIn', type: 'uint128' },
+      { name: 'amountOutLeft', type: 'uint128' },
+      { name: 'fee', type: 'uint128' },
+    ],
+  },
+  {
+    name: 'getSwapOut',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'pair', type: 'address' },
+      { name: 'amountIn', type: 'uint128' },
+      { name: 'swapForY', type: 'bool' },
+    ],
+    outputs: [
+      { name: 'amountInLeft', type: 'uint128' },
+      { name: 'amountOut', type: 'uint128' },
+      { name: 'fee', type: 'uint128' },
+    ],
+  },
+  {
+    name: 'addLiquidity',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [liquidityParametersTuple],
+    outputs: [...liquidityOutputs],
+  },
+  {
+    name: 'addLiquidityNATIVE',
+    type: 'function',
+    stateMutability: 'payable',
+    inputs: [liquidityParametersTuple],
+    outputs: [...liquidityOutputs],
+  },
+  {
+    name: 'removeLiquidity',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'tokenX', type: 'address' },
+      { name: 'tokenY', type: 'address' },
+      { name: 'binStep', type: 'uint16' },
+      { name: 'amountXMin', type: 'uint256' },
+      { name: 'amountYMin', type: 'uint256' },
+      { name: 'ids', type: 'uint256[]' },
+      { name: 'amounts', type: 'uint256[]' },
+      { name: 'to', type: 'address' },
+      { name: 'deadline', type: 'uint256' },
+    ],
+    outputs: [
+      { name: 'amountX', type: 'uint256' },
+      { name: 'amountY', type: 'uint256' },
+    ],
+  },
+  {
+    name: 'removeLiquidityNATIVE',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'token', type: 'address' },
+      { name: 'binStep', type: 'uint16' },
+      { name: 'amountTokenMin', type: 'uint256' },
+      { name: 'amountNATIVEMin', type: 'uint256' },
+      { name: 'ids', type: 'uint256[]' },
+      { name: 'amounts', type: 'uint256[]' },
+      { name: 'to', type: 'address' },
+      { name: 'deadline', type: 'uint256' },
+    ],
+    outputs: [
+      { name: 'amountToken', type: 'uint256' },
+      { name: 'amountNATIVE', type: 'uint256' },
+    ],
+  },
+  {
+    name: 'swapExactTokensForTokens',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'amountIn', type: 'uint256' },
+      { name: 'amountOutMin', type: 'uint256' },
+      pathTuple,
+      { name: 'to', type: 'address' },
+      { name: 'deadline', type: 'uint256' },
+    ],
+    outputs: [{ name: 'amountOut', type: 'uint256' }],
+  },
+  {
+    name: 'swapExactNATIVEForTokens',
+    type: 'function',
+    stateMutability: 'payable',
+    inputs: [
+      { name: 'amountOutMin', type: 'uint256' },
+      pathTuple,
+      { name: 'to', type: 'address' },
+      { name: 'deadline', type: 'uint256' },
+    ],
+    outputs: [{ name: 'amountOut', type: 'uint256' }],
+  },
+  {
+    name: 'swapExactTokensForNATIVE',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'amountIn', type: 'uint256' },
+      { name: 'amountOutMinNATIVE', type: 'uint256' },
+      pathTuple,
+      { name: 'to', type: 'address' },
+      { name: 'deadline', type: 'uint256' },
+    ],
+    outputs: [{ name: 'amountOut', type: 'uint256' }],
+  },
+] as const
+
+export const ERC20_ABI = [
+  {
+    name: 'approve',
+    type: 'function',
+    stateMutability: 'nonpayable',
+    inputs: [
+      { name: 'spender', type: 'address' },
+      { name: 'amount', type: 'uint256' },
+    ],
+    outputs: [{ name: 'success', type: 'bool' }],
+  },
+  {
+    name: 'allowance',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [
+      { name: 'owner', type: 'address' },
+      { name: 'spender', type: 'address' },
+    ],
+    outputs: [{ name: 'remaining', type: 'uint256' }],
+  },
+  {
+    name: 'balanceOf',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [{ name: 'account', type: 'address' }],
+    outputs: [{ name: 'balance', type: 'uint256' }],
+  },
+  {
+    name: 'decimals',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'decimals', type: 'uint8' }],
+  },
+  {
+    name: 'symbol',
+    type: 'function',
+    stateMutability: 'view',
+    inputs: [],
+    outputs: [{ name: 'symbol', type: 'string' }],
+  },
+] as const

+ 48 - 0
src/lib/chain/balances.ts

@@ -0,0 +1,48 @@
+import { formatUnits } from 'viem'
+import { config, MON_DECIMALS, USDC_DECIMALS } from '../config'
+import { getPublicClient, getAccount } from './client'
+import { ERC20_ABI } from './abi'
+
+export async function getMonBalance(address?: `0x${string}`): Promise<bigint> {
+  const client = getPublicClient()
+  const addr = address ?? getAccount().address
+  return await client.getBalance({ address: addr })
+}
+
+export async function getUsdcBalance(address?: `0x${string}`): Promise<bigint> {
+  const client = getPublicClient()
+  const addr = address ?? getAccount().address
+  return await client.readContract({
+    address: config.contracts.usdc,
+    abi: ERC20_ABI,
+    functionName: 'balanceOf',
+    args: [addr],
+  })
+}
+
+export interface WalletBalances {
+  mon: bigint
+  usdc: bigint
+  monFormatted: string
+  usdcFormatted: string
+}
+
+export async function getWalletBalances(address?: `0x${string}`): Promise<WalletBalances> {
+  const [mon, usdc] = await Promise.all([getMonBalance(address), getUsdcBalance(address)])
+  return {
+    mon,
+    usdc,
+    monFormatted: formatUnits(mon, MON_DECIMALS),
+    usdcFormatted: formatUnits(usdc, USDC_DECIMALS),
+  }
+}
+
+export function estimatePositionValueUSD(
+  monAmount: bigint,
+  usdcAmount: bigint,
+  monPriceUsd: number,
+): number {
+  const monValue = Number(formatUnits(monAmount, MON_DECIMALS)) * monPriceUsd
+  const usdcValue = Number(formatUnits(usdcAmount, USDC_DECIMALS))
+  return monValue + usdcValue
+}

+ 51 - 0
src/lib/chain/client.ts

@@ -0,0 +1,51 @@
+import { createPublicClient, createWalletClient, http, type Chain } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { config, CHAIN_ID } from '../config'
+
+export const monad: Chain = {
+  id: CHAIN_ID,
+  name: 'Monad',
+  nativeCurrency: { name: 'MON', symbol: 'MON', decimals: 18 },
+  rpcUrls: {
+    default: { http: [config.rpc.primary] },
+  },
+  blockExplorers: {
+    default: { name: 'MonadVision', url: 'https://monadvision.com' },
+  },
+}
+
+let _publicClient: ReturnType<typeof createPublicClient> | null = null
+let _walletClient: ReturnType<typeof createWalletClient> | null = null
+
+export function getPublicClient() {
+  if (!_publicClient) {
+    const transports = [config.rpc.primary, ...config.rpc.fallback].map((url) => http(url))
+    _publicClient = createPublicClient({
+      chain: monad,
+      transport: transports.length > 1 ? transports[0] : http(config.rpc.primary),
+    })
+  }
+  return _publicClient
+}
+
+export function getAccount() {
+  const key = process.env.PRIVATE_KEY
+  if (!key) throw new Error('PRIVATE_KEY not set')
+  return privateKeyToAccount(key as `0x${string}`)
+}
+
+export function getWalletClient() {
+  if (!_walletClient) {
+    const account = getAccount()
+    _walletClient = createWalletClient({
+      account,
+      chain: monad,
+      transport: http(config.rpc.primary),
+    })
+  }
+  return _walletClient
+}
+
+export function getDeadline(seconds = 300): bigint {
+  return BigInt(Math.floor(Date.now() / 1000) + seconds)
+}

+ 40 - 0
src/lib/chain/index.ts

@@ -0,0 +1,40 @@
+export { monad, getPublicClient, getWalletClient, getAccount, getDeadline } from './client'
+export { LB_PAIR_ABI, LB_ROUTER_ABI, ERC20_ABI } from './abi'
+export {
+  getActiveId,
+  getBinStep,
+  getBinReserves,
+  getPoolReserves,
+  getUserBinBalance,
+  getUserBinBalancesBatch,
+  getBinTotalSupply,
+  isApprovedForAll,
+  getPriceFromBinId,
+  getBinIdFromPrice,
+  getActivePrice,
+  findUserBinIds,
+} from './pair'
+export {
+  ensureApprovals,
+  buildLiquidityParams,
+  addLiquidityNative,
+  removeLiquidityNative,
+  swapExactNativeForTokens,
+  swapExactTokensForNative,
+  getSwapOut,
+} from './router'
+export type { LiquidityParameters } from './router'
+export {
+  getMonBalance,
+  getUsdcBalance,
+  getWalletBalances,
+  estimatePositionValueUSD,
+} from './balances'
+export type { WalletBalances } from './balances'
+export {
+  openPosition,
+  closePosition,
+  rebalanceSwap,
+  calculatePositionAmounts,
+} from './liquidity'
+export type { PositionResult, RemoveResult } from './liquidity'

+ 163 - 0
src/lib/chain/liquidity.ts

@@ -0,0 +1,163 @@
+import { formatUnits, parseUnits } from 'viem'
+import { config, BIN_STEP, MON_DECIMALS, USDC_DECIMALS } from '../config'
+import { getAccount } from './client'
+import {
+  getActiveId,
+  getUserBinBalancesBatch,
+  getBinReserves,
+  getBinTotalSupply,
+  getPriceFromBinId,
+} from './pair'
+import {
+  ensureApprovals,
+  buildLiquidityParams,
+  addLiquidityNative,
+  removeLiquidityNative,
+  swapExactNativeForTokens,
+  swapExactTokensForNative,
+  getSwapOut,
+} from './router'
+import { getWalletBalances, estimatePositionValueUSD } from './balances'
+
+export interface PositionResult {
+  txHash: string
+  binIds: number[]
+  minBin: number
+  maxBin: number
+  amountX: bigint
+  amountY: bigint
+}
+
+export interface RemoveResult {
+  txHash: string
+  amountX: bigint
+  amountY: bigint
+}
+
+export async function openPosition(
+  activeId: number,
+  numBins: number,
+  amountX: bigint,
+  amountY: bigint,
+): Promise<PositionResult> {
+  await ensureApprovals()
+
+  const params = buildLiquidityParams(activeId, numBins, amountX, amountY)
+  const txHash = await addLiquidityNative(params)
+
+  const halfBins = Math.floor(numBins / 2)
+  const binIds = Array.from({ length: numBins }, (_, i) => activeId + i - halfBins)
+
+  return {
+    txHash,
+    binIds,
+    minBin: binIds[0],
+    maxBin: binIds[binIds.length - 1],
+    amountX,
+    amountY,
+  }
+}
+
+export async function closePosition(binIds: number[]): Promise<RemoveResult | null> {
+  if (binIds.length === 0) return null
+
+  await ensureApprovals()
+
+  const balances = await getUserBinBalancesBatch(binIds)
+  const activeBinIds: number[] = []
+  const amounts: bigint[] = []
+
+  for (const binId of binIds) {
+    const balance = balances.get(binId) ?? 0n
+    if (balance > 0n) {
+      activeBinIds.push(binId)
+      amounts.push(balance)
+    }
+  }
+
+  if (activeBinIds.length === 0) return null
+
+  // Calculate expected token amounts for slippage protection
+  let totalXReserves = 0n
+  let totalYReserves = 0n
+  for (let i = 0; i < activeBinIds.length; i++) {
+    const [reserves, totalSupply] = await Promise.all([
+      getBinReserves(activeBinIds[i]),
+      getBinTotalSupply(activeBinIds[i]),
+    ])
+    if (totalSupply > 0n) {
+      totalXReserves += (amounts[i] * reserves.reserveX) / totalSupply
+      totalYReserves += (amounts[i] * reserves.reserveY) / totalSupply
+    }
+  }
+
+  const slippage = BigInt(10000 - config.strategy.slippageBps)
+  const amountTokenMin = (totalYReserves * slippage) / 10000n // USDC min
+  const amountNativeMin = (totalXReserves * slippage) / 10000n // MON min
+
+  const txHash = await removeLiquidityNative(
+    activeBinIds,
+    amounts,
+    amountTokenMin,
+    amountNativeMin,
+  )
+
+  return {
+    txHash,
+    amountX: totalXReserves,
+    amountY: totalYReserves,
+  }
+}
+
+export async function rebalanceSwap(
+  monPriceUsd: number,
+): Promise<{ swapTxHash?: string; direction?: 'mon_to_usdc' | 'usdc_to_mon' }> {
+  const balances = await getWalletBalances()
+  const monValueUsd = Number(formatUnits(balances.mon, MON_DECIMALS)) * monPriceUsd
+  const usdcValueUsd = Number(formatUnits(balances.usdc, USDC_DECIMALS))
+  const totalValueUsd = monValueUsd + usdcValueUsd
+
+  if (totalValueUsd < 1) return {} // too small to swap
+
+  const targetEachSide = totalValueUsd / 2
+  const imbalance = Math.abs(monValueUsd - usdcValueUsd) / totalValueUsd
+
+  // Only swap if imbalance > 10%
+  if (imbalance < 0.1) return {}
+
+  const slippageFactor = BigInt(10000 - config.strategy.slippageBps)
+
+  if (monValueUsd > usdcValueUsd) {
+    // Swap MON → USDC
+    const excessMonUsd = monValueUsd - targetEachSide
+    const monToSwap = parseUnits(String(excessMonUsd / monPriceUsd), MON_DECIMALS)
+    const { amountOut } = await getSwapOut(monToSwap, true)
+    const minOut = (amountOut * slippageFactor) / 10000n
+
+    const swapTxHash = await swapExactNativeForTokens(monToSwap, minOut)
+    return { swapTxHash, direction: 'mon_to_usdc' }
+  } else {
+    // Swap USDC → MON
+    const excessUsdcUsd = usdcValueUsd - targetEachSide
+    const usdcToSwap = parseUnits(String(excessUsdcUsd), USDC_DECIMALS)
+    const { amountOut } = await getSwapOut(usdcToSwap, false)
+    const minOut = (amountOut * slippageFactor) / 10000n
+
+    const swapTxHash = await swapExactTokensForNative(usdcToSwap, minOut)
+    return { swapTxHash, direction: 'usdc_to_mon' }
+  }
+}
+
+export function calculatePositionAmounts(
+  totalValueUsd: number,
+  monPriceUsd: number,
+): { amountX: bigint; amountY: bigint } {
+  const halfUsd = totalValueUsd / 2
+  const monAmount = halfUsd / monPriceUsd
+  const usdcAmount = halfUsd
+
+  return {
+    amountX: parseUnits(monAmount.toFixed(MON_DECIMALS), MON_DECIMALS),
+    amountY: parseUnits(usdcAmount.toFixed(USDC_DECIMALS), USDC_DECIMALS),
+  }
+}

+ 133 - 0
src/lib/chain/pair.ts

@@ -0,0 +1,133 @@
+import { config, BIN_STEP } from '../config'
+import { getPublicClient, getAccount } from './client'
+import { LB_PAIR_ABI } from './abi'
+
+const pair = () => ({
+  address: config.contracts.lbPair,
+  abi: LB_PAIR_ABI,
+})
+
+export async function getActiveId(): Promise<number> {
+  const client = getPublicClient()
+  const result = await client.readContract({
+    ...pair(),
+    functionName: 'getActiveId',
+  })
+  return Number(result)
+}
+
+export async function getBinStep(): Promise<number> {
+  const client = getPublicClient()
+  const result = await client.readContract({
+    ...pair(),
+    functionName: 'getBinStep',
+  })
+  return Number(result)
+}
+
+export async function getBinReserves(
+  binId: number,
+): Promise<{ reserveX: bigint; reserveY: bigint }> {
+  const client = getPublicClient()
+  const [reserveX, reserveY] = await client.readContract({
+    ...pair(),
+    functionName: 'getBin',
+    args: [binId],
+  })
+  return { reserveX, reserveY }
+}
+
+export async function getPoolReserves(): Promise<{ reserveX: bigint; reserveY: bigint }> {
+  const client = getPublicClient()
+  const [reserveX, reserveY] = await client.readContract({
+    ...pair(),
+    functionName: 'getReserves',
+  })
+  return { reserveX, reserveY }
+}
+
+export async function getUserBinBalance(binId: number): Promise<bigint> {
+  const client = getPublicClient()
+  const account = getAccount()
+  return await client.readContract({
+    ...pair(),
+    functionName: 'balanceOf',
+    args: [account.address, BigInt(binId)],
+  })
+}
+
+export async function getUserBinBalancesBatch(
+  binIds: number[],
+): Promise<Map<number, bigint>> {
+  const client = getPublicClient()
+  const account = getAccount()
+  const accounts = binIds.map(() => account.address)
+  const ids = binIds.map((id) => BigInt(id))
+
+  const balances = await client.readContract({
+    ...pair(),
+    functionName: 'balanceOfBatch',
+    args: [accounts, ids],
+  })
+
+  const result = new Map<number, bigint>()
+  binIds.forEach((id, i) => {
+    if (balances[i] > 0n) result.set(id, balances[i])
+  })
+  return result
+}
+
+export async function getBinTotalSupply(binId: number): Promise<bigint> {
+  const client = getPublicClient()
+  return await client.readContract({
+    ...pair(),
+    functionName: 'totalSupply',
+    args: [BigInt(binId)],
+  })
+}
+
+export async function isApprovedForAll(spender: `0x${string}`): Promise<boolean> {
+  const client = getPublicClient()
+  const account = getAccount()
+  return await client.readContract({
+    ...pair(),
+    functionName: 'isApprovedForAll',
+    args: [account.address, spender],
+  })
+}
+
+export function getPriceFromBinId(binId: number, binStep = BIN_STEP): number {
+  return (1 + binStep / 10_000) ** (binId - 8388608)
+}
+
+export function getBinIdFromPrice(price: number, binStep = BIN_STEP): number {
+  return Math.round(Math.log(price) / Math.log(1 + binStep / 10_000) + 8388608)
+}
+
+export async function getActivePrice(): Promise<{ activeId: number; price: number }> {
+  const activeId = await getActiveId()
+  const price = getPriceFromBinId(activeId)
+  return { activeId, price }
+}
+
+export async function findUserBinIds(searchRange = 50): Promise<number[]> {
+  const activeId = await getActiveId()
+  const account = getAccount()
+  const client = getPublicClient()
+
+  const allBinIds: number[] = []
+  for (let offset = -searchRange; offset <= searchRange; offset++) {
+    allBinIds.push(activeId + offset)
+  }
+
+  const accounts = allBinIds.map(() => account.address)
+  const ids = allBinIds.map((id) => BigInt(id))
+
+  const balances = await client.readContract({
+    ...pair(),
+    functionName: 'balanceOfBatch',
+    args: [accounts, ids],
+  })
+
+  return allBinIds.filter((_, i) => balances[i] > 0n)
+}

+ 218 - 0
src/lib/chain/router.ts

@@ -0,0 +1,218 @@
+import { type Hash, maxUint256 } from 'viem'
+import { config, BIN_STEP } from '../config'
+import { getPublicClient, getWalletClient, getAccount, getDeadline } from './client'
+import { LB_ROUTER_ABI, LB_PAIR_ABI, ERC20_ABI } from './abi'
+import { isApprovedForAll } from './pair'
+
+const PRECISION = BigInt(1e18)
+
+export interface LiquidityParameters {
+  tokenX: `0x${string}`
+  tokenY: `0x${string}`
+  binStep: bigint
+  amountX: bigint
+  amountY: bigint
+  amountXMin: bigint
+  amountYMin: bigint
+  activeIdDesired: bigint
+  idSlippage: bigint
+  deltaIds: bigint[]
+  distributionX: bigint[]
+  distributionY: bigint[]
+  to: `0x${string}`
+  refundTo: `0x${string}`
+  deadline: bigint
+}
+
+export async function ensureApprovals(): Promise<void> {
+  const client = getPublicClient()
+  const wallet = getWalletClient()
+  const account = getAccount()
+  const router = config.contracts.lbRouter
+
+  // Check USDC allowance for router
+  const usdcAllowance = await client.readContract({
+    address: config.contracts.usdc,
+    abi: ERC20_ABI,
+    functionName: 'allowance',
+    args: [account.address, router],
+  })
+
+  if (usdcAllowance < maxUint256 / 2n) {
+    console.log('[router] Approving USDC for router...')
+    const hash = await wallet.writeContract({
+      address: config.contracts.usdc,
+      abi: ERC20_ABI,
+      functionName: 'approve',
+      args: [router, maxUint256],
+    })
+    await client.waitForTransactionReceipt({ hash })
+    console.log('[router] USDC approved:', hash)
+  }
+
+  // Check LBPair approveForAll for router
+  const approved = await isApprovedForAll(router)
+  if (!approved) {
+    console.log('[router] Approving LBPair for router...')
+    const hash = await wallet.writeContract({
+      address: config.contracts.lbPair,
+      abi: LB_PAIR_ABI,
+      functionName: 'approveForAll',
+      args: [router, true],
+    })
+    await client.waitForTransactionReceipt({ hash })
+    console.log('[router] LBPair approved:', hash)
+  }
+}
+
+export function buildLiquidityParams(
+  activeId: number,
+  numBins: number,
+  amountX: bigint,
+  amountY: bigint,
+): LiquidityParameters {
+  const account = getAccount()
+  const halfBins = Math.floor(numBins / 2)
+  const deltaIds = Array.from({ length: numBins }, (_, i) => BigInt(i - halfBins))
+
+  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))
+
+  return {
+    tokenX: config.contracts.tokenX,
+    tokenY: config.contracts.tokenY,
+    binStep: BigInt(BIN_STEP),
+    amountX,
+    amountY,
+    amountXMin: (amountX * slippageFactor) / 10000n,
+    amountYMin: (amountY * slippageFactor) / 10000n,
+    activeIdDesired: BigInt(activeId),
+    idSlippage: 5n,
+    deltaIds,
+    distributionX,
+    distributionY,
+    to: account.address,
+    refundTo: account.address,
+    deadline: getDeadline(),
+  }
+}
+
+export async function addLiquidityNative(params: LiquidityParameters): Promise<Hash> {
+  const wallet = getWalletClient()
+  const client = getPublicClient()
+
+  // msg.value = amountX because tokenX = WMON (native)
+  const hash = await wallet.writeContract({
+    address: config.contracts.lbRouter,
+    abi: LB_ROUTER_ABI,
+    functionName: 'addLiquidityNATIVE',
+    args: [params],
+    value: params.amountX,
+  })
+
+  await client.waitForTransactionReceipt({ hash })
+  console.log('[router] addLiquidityNATIVE tx:', hash)
+  return hash
+}
+
+export async function removeLiquidityNative(
+  binIds: number[],
+  amounts: bigint[],
+  amountTokenMin: bigint,
+  amountNativeMin: bigint,
+): Promise<Hash> {
+  const wallet = getWalletClient()
+  const client = getPublicClient()
+  const account = getAccount()
+
+  // token param = USDC (non-native token) for removeLiquidityNATIVE
+  const hash = await wallet.writeContract({
+    address: config.contracts.lbRouter,
+    abi: LB_ROUTER_ABI,
+    functionName: 'removeLiquidityNATIVE',
+    args: [
+      config.contracts.usdc,
+      BIN_STEP,
+      amountTokenMin,
+      amountNativeMin,
+      binIds.map(BigInt),
+      amounts,
+      account.address,
+      getDeadline(),
+    ],
+  })
+
+  await client.waitForTransactionReceipt({ hash })
+  console.log('[router] removeLiquidityNATIVE tx:', hash)
+  return hash
+}
+
+export async function swapExactNativeForTokens(
+  amountIn: bigint,
+  amountOutMin: bigint,
+): Promise<Hash> {
+  const wallet = getWalletClient()
+  const client = getPublicClient()
+  const account = getAccount()
+
+  const path = {
+    pairBinSteps: [BigInt(BIN_STEP)],
+    versions: [2], // V2.2
+    tokenPath: [config.contracts.wmon, config.contracts.usdc],
+  }
+
+  const hash = await wallet.writeContract({
+    address: config.contracts.lbRouter,
+    abi: LB_ROUTER_ABI,
+    functionName: 'swapExactNATIVEForTokens',
+    args: [amountOutMin, path, account.address, getDeadline()],
+    value: amountIn,
+  })
+
+  await client.waitForTransactionReceipt({ hash })
+  console.log('[router] swapExactNATIVEForTokens tx:', hash)
+  return hash
+}
+
+export async function swapExactTokensForNative(
+  amountIn: bigint,
+  amountOutMin: bigint,
+): Promise<Hash> {
+  const wallet = getWalletClient()
+  const client = getPublicClient()
+  const account = getAccount()
+
+  const path = {
+    pairBinSteps: [BigInt(BIN_STEP)],
+    versions: [2], // V2.2
+    tokenPath: [config.contracts.usdc, config.contracts.wmon],
+  }
+
+  const hash = await wallet.writeContract({
+    address: config.contracts.lbRouter,
+    abi: LB_ROUTER_ABI,
+    functionName: 'swapExactTokensForNATIVE',
+    args: [amountIn, amountOutMin, path, account.address, getDeadline()],
+  })
+
+  await client.waitForTransactionReceipt({ hash })
+  console.log('[router] swapExactTokensForNATIVE tx:', hash)
+  return hash
+}
+
+export async function getSwapOut(
+  amountIn: bigint,
+  swapForY: boolean,
+): Promise<{ amountOut: bigint; fee: bigint }> {
+  const client = getPublicClient()
+  const [, amountOut, fee] = await client.readContract({
+    address: config.contracts.lbRouter,
+    abi: LB_ROUTER_ABI,
+    functionName: 'getSwapOut',
+    args: [config.contracts.lbPair, amountIn, swapForY],
+  })
+  return { amountOut, fee }
+}

+ 80 - 0
src/lib/config.ts

@@ -0,0 +1,80 @@
+export interface BotConfig {
+  rpc: { primary: string; fallback: string[] }
+  contracts: {
+    lbPair: `0x${string}`
+    lbRouter: `0x${string}`
+    lbFactory: `0x${string}`
+    tokenX: `0x${string}`
+    tokenY: `0x${string}`
+    wmon: `0x${string}`
+    usdc: `0x${string}`
+  }
+  strategy: {
+    numBins: number
+    distribution: 'uniform' | 'curve'
+    positionSizeUSD: number
+    slippageBps: number
+    rebalanceCooldownMs: number
+    gasLimitMultiplier: number
+    pollIntervalMs: number
+    maxDailyRebalances: number
+  }
+  notifications: {
+    enabled: boolean
+    provider: 'bark' | 'ntfy' | 'pushover'
+    endpoint: string
+    alertOnRebalance: boolean
+    alertOnError: boolean
+    alertOnLowBalance: boolean
+  }
+}
+
+const env = (key: string, fallback = ''): string => process.env[key] ?? fallback
+
+const addr = (key: string, fallback = ''): `0x${string}` => {
+  const v = env(key, fallback)
+  if (!v.startsWith('0x')) throw new Error(`${key} must start with 0x`)
+  return v as `0x${string}`
+}
+
+export const CHAIN_ID = 10143
+export const BIN_STEP = Number(env('BIN_STEP', '5'))
+export const MON_DECIMALS = 18
+export const USDC_DECIMALS = 6
+
+export const config: BotConfig = {
+  rpc: {
+    primary: env('MONAD_RPC_URL', 'https://monad-mainnet.g.alchemy.com/v2/public'),
+    fallback: env('MONAD_RPC_FALLBACK')
+      .split(',')
+      .map((s) => s.trim())
+      .filter(Boolean),
+  },
+  contracts: {
+    lbPair: addr('LB_PAIR_ADDRESS', '0x5afd3ec861f6104af26e8755abcc1f876de77620'),
+    lbRouter: addr('LB_ROUTER_ADDRESS', '0x18556DA13313f3532c54711497A8FedAC273220E'),
+    lbFactory: addr('LB_FACTORY_ADDRESS', '0xb43120c4745967fa9b93E79C149E66B0f2D6Fe0c'),
+    tokenX: addr('WMON_ADDRESS', '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A'),
+    tokenY: addr('USDC_ADDRESS', '0x754704bc059f8c67012fed69bc8a327a5aafb603'),
+    wmon: addr('WMON_ADDRESS', '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A'),
+    usdc: addr('USDC_ADDRESS', '0x754704bc059f8c67012fed69bc8a327a5aafb603'),
+  },
+  strategy: {
+    numBins: Number(env('NUM_BINS', '3')),
+    distribution: 'uniform',
+    positionSizeUSD: Number(env('POSITION_SIZE_USD', '50')),
+    slippageBps: Number(env('SLIPPAGE_BPS', '50')),
+    rebalanceCooldownMs: Number(env('REBALANCE_COOLDOWN_MS', '10000')),
+    gasLimitMultiplier: Number(env('GAS_LIMIT_MULTIPLIER', '1.2')),
+    pollIntervalMs: Number(env('POLL_INTERVAL_MS', '3000')),
+    maxDailyRebalances: Number(env('MAX_DAILY_REBALANCES', '50')),
+  },
+  notifications: {
+    enabled: env('NOTIFY_ENDPOINT') !== '',
+    provider: (env('NOTIFY_PROVIDER', 'bark') as BotConfig['notifications']['provider']) || 'bark',
+    endpoint: env('NOTIFY_ENDPOINT'),
+    alertOnRebalance: true,
+    alertOnError: true,
+    alertOnLowBalance: true,
+  },
+}

+ 20 - 0
src/lib/db/index.ts

@@ -0,0 +1,20 @@
+import Database from 'better-sqlite3'
+import { mkdirSync } from 'fs'
+import { dirname, resolve } from 'path'
+import { initDb } from './schema'
+
+let _db: Database.Database | null = null
+
+export function getDb(): Database.Database {
+  if (_db) return _db
+
+  const dbPath = process.env.DB_PATH || resolve(process.cwd(), 'data', 'rebalancer.db')
+  mkdirSync(dirname(dbPath), { recursive: true })
+
+  _db = new Database(dbPath)
+  _db.pragma('journal_mode = WAL')
+  _db.pragma('busy_timeout = 5000')
+  initDb(_db)
+
+  return _db
+}

+ 243 - 0
src/lib/db/queries.ts

@@ -0,0 +1,243 @@
+import { getDb } from './index'
+
+export interface RebalanceLogInsert {
+  prevActiveId: number
+  newActiveId: number
+  prevMinBin?: number
+  prevMaxBin?: number
+  newMinBin: number
+  newMaxBin: number
+  amountXRemoved?: string
+  amountYRemoved?: string
+  swapDirection?: string
+  swapAmount?: string
+  amountXAdded?: string
+  amountYAdded?: string
+  removeTxHash?: string
+  swapTxHash?: string
+  addTxHash?: string
+  gasUsed?: string
+  gasPrice?: string
+  status: 'success' | 'failed' | 'partial'
+  errorMessage?: string
+  durationMs?: number
+}
+
+export interface RebalanceLog extends RebalanceLogInsert {
+  id: number
+  timestamp: string
+}
+
+export interface PositionData {
+  activeId: number
+  minBin: number
+  maxBin: number
+  numBins: number
+  binIds: number[]
+  amounts: string[]
+  priceAtOpen?: number
+}
+
+export interface CurrentPosition extends PositionData {
+  openedAt: string
+  updatedAt: string
+}
+
+export interface RewardsData {
+  balanceMon: string
+  balanceUsdc: string
+  balanceUsd?: number
+  cumulativeGasUsd?: number
+}
+
+export function logRebalance(data: RebalanceLogInsert): number {
+  const db = getDb()
+  const stmt = db.prepare(`
+    INSERT INTO rebalance_log (
+      prev_active_id, new_active_id, prev_min_bin, prev_max_bin,
+      new_min_bin, new_max_bin, amount_x_removed, amount_y_removed,
+      swap_direction, swap_amount, amount_x_added, amount_y_added,
+      remove_tx_hash, swap_tx_hash, add_tx_hash, gas_used, gas_price,
+      status, error_message, duration_ms
+    ) VALUES (
+      @prevActiveId, @newActiveId, @prevMinBin, @prevMaxBin,
+      @newMinBin, @newMaxBin, @amountXRemoved, @amountYRemoved,
+      @swapDirection, @swapAmount, @amountXAdded, @amountYAdded,
+      @removeTxHash, @swapTxHash, @addTxHash, @gasUsed, @gasPrice,
+      @status, @errorMessage, @durationMs
+    )
+  `)
+  const result = stmt.run(data)
+  return Number(result.lastInsertRowid)
+}
+
+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 ?')
+    .all(limit, offset) as Record<string, unknown>[]
+
+  return rows.map(mapRebalanceRow)
+}
+
+export function getRebalanceStats(): {
+  total: number
+  today: number
+  avgDurationMs: number
+  totalGasUsed: string
+} {
+  const db = getDb()
+  const total = (
+    db.prepare('SELECT COUNT(*) as count FROM rebalance_log').get() as { count: number }
+  ).count
+
+  const today = (
+    db
+      .prepare(
+        "SELECT COUNT(*) as count FROM rebalance_log WHERE date(timestamp) = date('now')",
+      )
+      .get() as { count: number }
+  ).count
+
+  const avgRow = db
+    .prepare('SELECT AVG(duration_ms) as avg FROM rebalance_log WHERE duration_ms IS NOT NULL')
+    .get() as { avg: number | null }
+
+  const gasRow = db
+    .prepare(
+      "SELECT COALESCE(SUM(CAST(gas_used AS REAL)), 0) as total FROM rebalance_log WHERE gas_used IS NOT NULL",
+    )
+    .get() as { total: number }
+
+  return {
+    total,
+    today,
+    avgDurationMs: Math.round(avgRow.avg ?? 0),
+    totalGasUsed: String(gasRow.total),
+  }
+}
+
+export function upsertPosition(data: PositionData): void {
+  const db = getDb()
+  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({
+    activeId: data.activeId,
+    minBin: data.minBin,
+    maxBin: data.maxBin,
+    numBins: data.numBins,
+    binIds: JSON.stringify(data.binIds),
+    amounts: JSON.stringify(data.amounts),
+    priceAtOpen: data.priceAtOpen ?? null,
+  })
+}
+
+export function getCurrentPosition(): CurrentPosition | null {
+  const db = getDb()
+  const row = db.prepare('SELECT * FROM current_position WHERE id = 1').get() as
+    | Record<string, unknown>
+    | undefined
+
+  if (!row) return null
+
+  return {
+    activeId: row.active_id as number,
+    minBin: row.min_bin as number,
+    maxBin: row.max_bin as number,
+    numBins: row.num_bins as number,
+    binIds: JSON.parse(row.bin_ids as string),
+    amounts: JSON.parse(row.amounts as string),
+    priceAtOpen: row.price_at_open as number | undefined,
+    openedAt: row.opened_at as string,
+    updatedAt: row.updated_at as string,
+  }
+}
+
+export function deleteCurrentPosition(): void {
+  const db = getDb()
+  db.prepare('DELETE FROM current_position WHERE id = 1').run()
+}
+
+export function snapshotRewards(data: RewardsData): void {
+  const db = getDb()
+  db.prepare(`
+    INSERT INTO rewards_log (balance_mon, balance_usdc, balance_usd, cumulative_gas_usd)
+    VALUES (@balanceMon, @balanceUsdc, @balanceUsd, @cumulativeGasUsd)
+  `).run(data)
+}
+
+export function getRewardsHistory(limit = 100): RewardsData[] {
+  const db = getDb()
+  return db
+    .prepare('SELECT * FROM rewards_log ORDER BY id DESC LIMIT ?')
+    .all(limit) as RewardsData[]
+}
+
+export function getEngineState(key: string): string | null {
+  const db = getDb()
+  const row = db.prepare('SELECT value FROM engine_state WHERE key = ?').get(key) as
+    | { value: string }
+    | undefined
+  return row?.value ?? null
+}
+
+export function setEngineState(key: string, value: string): void {
+  const db = getDb()
+  db.prepare(`
+    INSERT OR REPLACE INTO engine_state (key, value, updated_at)
+    VALUES (?, ?, datetime('now'))
+  `).run(key, value)
+}
+
+export function getDailyRebalanceCount(): number {
+  const db = getDb()
+  const dateStr = getEngineState('daily_rebalance_date')
+  const today = new Date().toISOString().slice(0, 10)
+
+  if (dateStr !== today) {
+    setEngineState('daily_rebalance_date', today)
+    setEngineState('daily_rebalance_count', '0')
+    return 0
+  }
+
+  return Number(getEngineState('daily_rebalance_count') ?? '0')
+}
+
+export function incrementDailyRebalanceCount(): void {
+  const current = getDailyRebalanceCount()
+  setEngineState('daily_rebalance_count', String(current + 1))
+}
+
+function mapRebalanceRow(row: Record<string, unknown>): RebalanceLog {
+  return {
+    id: row.id as number,
+    timestamp: row.timestamp as string,
+    prevActiveId: row.prev_active_id as number,
+    newActiveId: row.new_active_id as number,
+    prevMinBin: row.prev_min_bin as number | undefined,
+    prevMaxBin: row.prev_max_bin as number | undefined,
+    newMinBin: row.new_min_bin as number,
+    newMaxBin: row.new_max_bin as number,
+    amountXRemoved: row.amount_x_removed as string | undefined,
+    amountYRemoved: row.amount_y_removed as string | undefined,
+    swapDirection: row.swap_direction as string | undefined,
+    swapAmount: row.swap_amount as string | undefined,
+    amountXAdded: row.amount_x_added as string | undefined,
+    amountYAdded: row.amount_y_added as string | undefined,
+    removeTxHash: row.remove_tx_hash as string | undefined,
+    swapTxHash: row.swap_tx_hash as string | undefined,
+    addTxHash: row.add_tx_hash as string | undefined,
+    gasUsed: row.gas_used as string | undefined,
+    gasPrice: row.gas_price as string | undefined,
+    status: row.status as 'success' | 'failed' | 'partial',
+    errorMessage: row.error_message as string | undefined,
+    durationMs: row.duration_ms as number | undefined,
+  }
+}

+ 58 - 0
src/lib/db/schema.ts

@@ -0,0 +1,58 @@
+import type Database from 'better-sqlite3'
+
+export function initDb(db: Database.Database): void {
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS rebalance_log (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      timestamp TEXT NOT NULL DEFAULT (datetime('now')),
+      prev_active_id INTEGER NOT NULL,
+      new_active_id INTEGER NOT NULL,
+      prev_min_bin INTEGER,
+      prev_max_bin INTEGER,
+      new_min_bin INTEGER NOT NULL,
+      new_max_bin INTEGER NOT NULL,
+      amount_x_removed TEXT,
+      amount_y_removed TEXT,
+      swap_direction TEXT,
+      swap_amount TEXT,
+      amount_x_added TEXT,
+      amount_y_added TEXT,
+      remove_tx_hash TEXT,
+      swap_tx_hash TEXT,
+      add_tx_hash TEXT,
+      gas_used TEXT,
+      gas_price TEXT,
+      status TEXT NOT NULL DEFAULT 'success',
+      error_message TEXT,
+      duration_ms INTEGER
+    );
+
+    CREATE TABLE IF NOT EXISTS current_position (
+      id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+      active_id INTEGER NOT NULL,
+      min_bin INTEGER NOT NULL,
+      max_bin INTEGER NOT NULL,
+      num_bins INTEGER NOT NULL,
+      bin_ids TEXT NOT NULL,
+      amounts TEXT NOT NULL,
+      price_at_open REAL,
+      opened_at TEXT NOT NULL DEFAULT (datetime('now')),
+      updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS rewards_log (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      timestamp TEXT NOT NULL DEFAULT (datetime('now')),
+      balance_mon TEXT NOT NULL,
+      balance_usdc TEXT NOT NULL,
+      balance_usd REAL,
+      cumulative_gas_usd REAL
+    );
+
+    CREATE TABLE IF NOT EXISTS engine_state (
+      key TEXT PRIMARY KEY,
+      value TEXT NOT NULL,
+      updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+  `)
+}

+ 317 - 0
src/lib/engine/engine.ts

@@ -0,0 +1,317 @@
+import { formatUnits } from 'viem'
+import { config, MON_DECIMALS, USDC_DECIMALS } from '../config'
+import {
+  getActiveId,
+  getActivePrice,
+  getPriceFromBinId,
+  getWalletBalances,
+  openPosition,
+  closePosition,
+  rebalanceSwap,
+  calculatePositionAmounts,
+} from '../chain'
+import {
+  getCurrentPosition,
+  upsertPosition,
+  deleteCurrentPosition,
+  logRebalance,
+  getRebalanceStats,
+  getDailyRebalanceCount,
+  incrementDailyRebalanceCount,
+  setEngineState,
+  getEngineState,
+} from '../db/queries'
+import { sendNotification, notifyRebalance, notifyError } from '../notifications'
+import type { EngineStatus, EngineState, RebalanceResult } from './types'
+
+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 constructor() {}
+
+  static getInstance(): RebalancerEngine {
+    if (!RebalancerEngine.instance) {
+      RebalancerEngine.instance = new RebalancerEngine()
+    }
+    return RebalancerEngine.instance
+  }
+
+  start(): void {
+    if (this.status === 'running') return
+    this.status = 'running'
+    this.startedAt = Date.now()
+    setEngineState('status', 'running')
+    console.log('[engine] Started')
+
+    this.pollTimer = setInterval(() => this.pollCycle(), config.strategy.pollIntervalMs)
+  }
+
+  pause(): void {
+    if (this.pollTimer) clearInterval(this.pollTimer)
+    this.pollTimer = null
+    this.status = 'paused'
+    setEngineState('status', 'paused')
+    console.log('[engine] Paused')
+  }
+
+  stop(): void {
+    if (this.pollTimer) clearInterval(this.pollTimer)
+    this.pollTimer = null
+    this.status = 'idle'
+    this.polling = false
+    setEngineState('status', 'idle')
+    console.log('[engine] Stopped')
+  }
+
+  async emergencyWithdraw(): Promise<string | null> {
+    console.log('[engine] Emergency withdraw initiated')
+    this.stop()
+
+    try {
+      const position = getCurrentPosition()
+      if (!position) {
+        console.log('[engine] No position to withdraw')
+        return null
+      }
+
+      const result = await closePosition(position.binIds)
+      if (result) {
+        deleteCurrentPosition()
+        await sendNotification(
+          'Emergency Withdraw',
+          `Position closed. TX: ${result.txHash}`,
+          'warning',
+        )
+        return result.txHash
+      }
+      return null
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      this.addError(`Emergency withdraw failed: ${msg}`)
+      await notifyError(`Emergency withdraw failed: ${msg}`)
+      return null
+    }
+  }
+
+  async getState(): Promise<EngineState> {
+    let currentActiveId: number | null = null
+    let currentPrice: number | null = null
+
+    try {
+      const ap = await getActivePrice()
+      currentActiveId = ap.activeId
+      currentPrice = ap.price
+    } catch {
+      // chain read failed, leave as null
+    }
+
+    const position = getCurrentPosition()
+    const stats = getRebalanceStats()
+    const dailyCount = getDailyRebalanceCount()
+
+    let walletMon = '0'
+    let walletUsdc = '0'
+    try {
+      const balances = await getWalletBalances()
+      walletMon = balances.monFormatted
+      walletUsdc = balances.usdcFormatted
+    } catch {
+      // balance read failed
+    }
+
+    return {
+      status: this.status,
+      currentActiveId,
+      currentPrice,
+      positionMinBin: position?.minBin ?? null,
+      positionMaxBin: position?.maxBin ?? null,
+      positionBinIds: position?.binIds ?? [],
+      numBins: config.strategy.numBins,
+      lastRebalanceAt: getEngineState('last_rebalance_at'),
+      totalRebalances: stats.total,
+      todayRebalances: dailyCount,
+      walletMon,
+      walletUsdc,
+      errors: [...this.errors],
+      uptime: this.startedAt > 0 ? Date.now() - this.startedAt : 0,
+    }
+  }
+
+  private async pollCycle(): Promise<void> {
+    if (this.status !== 'running' || this.polling) return
+    this.polling = true
+
+    try {
+      const currentActiveId = await getActiveId()
+      const position = getCurrentPosition()
+
+      if (!position) {
+        console.log('[engine] No position, opening new one at bin', currentActiveId)
+        await this.openNewPosition(currentActiveId)
+        return
+      }
+
+      // Check if active bin is within position range
+      if (currentActiveId >= position.minBin && currentActiveId <= position.maxBin) {
+        return // In range, no action needed
+      }
+
+      // Out of range — check cooldown
+      if (Date.now() < this.cooldownUntil) {
+        console.log('[engine] Cooldown active, skipping rebalance')
+        return
+      }
+
+      // Check daily limit
+      const dailyCount = getDailyRebalanceCount()
+      if (dailyCount >= config.strategy.maxDailyRebalances) {
+        console.log('[engine] Daily rebalance limit reached:', dailyCount)
+        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
+    }
+  }
+
+  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)
+    if (positionSizeUsd < 1) {
+      this.addError('Insufficient balance to open position')
+      return
+    }
+
+    const { amountX, amountY } = calculatePositionAmounts(positionSizeUsd, price)
+    const result = await openPosition(activeId, config.strategy.numBins, amountX, amountY)
+
+    upsertPosition({
+      activeId,
+      minBin: result.minBin,
+      maxBin: result.maxBin,
+      numBins: config.strategy.numBins,
+      binIds: result.binIds,
+      amounts: result.binIds.map(() => '0'),
+      priceAtOpen: price,
+    })
+
+    console.log(
+      `[engine] Position opened at bin ${activeId}, range [${result.minBin}, ${result.maxBin}]`,
+    )
+  }
+
+  private async executeRebalance(
+    newActiveId: number,
+    position: NonNullable<ReturnType<typeof getCurrentPosition>>,
+  ): Promise<void> {
+    const startTime = Date.now()
+    const result: Partial<RebalanceResult> = {
+      prevActiveId: position.activeId,
+      newActiveId,
+      prevMinBin: position.minBin,
+      prevMaxBin: position.maxBin,
+    }
+
+    try {
+      // Step 1: Remove liquidity
+      console.log('[engine] Removing liquidity...')
+      const removeResult = await closePosition(position.binIds)
+      if (removeResult) {
+        result.removeTxHash = removeResult.txHash
+        result.amountXRemoved = removeResult.amountX.toString()
+        result.amountYRemoved = removeResult.amountY.toString()
+      }
+      deleteCurrentPosition()
+
+      // Step 2: Swap to rebalance if needed
+      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
+      }
+
+      // Step 3: Open new position
+      console.log('[engine] Opening new position at bin', newActiveId)
+      await this.openNewPosition(newActiveId)
+
+      const pos = getCurrentPosition()
+      result.newMinBin = pos?.minBin ?? newActiveId - 1
+      result.newMaxBin = pos?.maxBin ?? newActiveId + 1
+      result.success = true
+      result.durationMs = Date.now() - startTime
+
+      // Log to DB
+      logRebalance({
+        prevActiveId: result.prevActiveId!,
+        newActiveId: result.newActiveId!,
+        prevMinBin: result.prevMinBin,
+        prevMaxBin: result.prevMaxBin,
+        newMinBin: result.newMinBin!,
+        newMaxBin: result.newMaxBin!,
+        amountXRemoved: result.amountXRemoved,
+        amountYRemoved: result.amountYRemoved,
+        swapDirection: result.swapDirection,
+        removeTxHash: result.removeTxHash,
+        swapTxHash: result.swapTxHash,
+        addTxHash: result.addTxHash,
+        status: 'success',
+        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`)
+      await notifyRebalance(result as RebalanceResult)
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      result.success = false
+      result.error = msg
+      result.durationMs = Date.now() - startTime
+
+      logRebalance({
+        prevActiveId: result.prevActiveId!,
+        newActiveId: result.newActiveId!,
+        prevMinBin: result.prevMinBin,
+        prevMaxBin: result.prevMaxBin,
+        newMinBin: result.newMinBin ?? newActiveId - 1,
+        newMaxBin: result.newMaxBin ?? newActiveId + 1,
+        status: 'failed',
+        errorMessage: msg,
+        durationMs: result.durationMs,
+      })
+
+      this.addError(`Rebalance failed: ${msg}`)
+      await notifyError(`Rebalance failed: ${msg}`)
+    }
+  }
+
+  private addError(msg: string): void {
+    this.errors.push(`${new Date().toISOString()} ${msg}`)
+    if (this.errors.length > 20) this.errors.shift()
+  }
+}

+ 2 - 0
src/lib/engine/index.ts

@@ -0,0 +1,2 @@
+export { RebalancerEngine } from './engine'
+export type { EngineStatus, EngineState, RebalanceResult } from './types'

+ 39 - 0
src/lib/engine/types.ts

@@ -0,0 +1,39 @@
+export type EngineStatus = 'idle' | 'running' | 'paused' | 'error'
+
+export interface EngineState {
+  status: EngineStatus
+  currentActiveId: number | null
+  currentPrice: number | null
+  positionMinBin: number | null
+  positionMaxBin: number | null
+  positionBinIds: number[]
+  numBins: number
+  lastRebalanceAt: string | null
+  totalRebalances: number
+  todayRebalances: number
+  walletMon: string
+  walletUsdc: string
+  errors: string[]
+  uptime: number
+}
+
+export interface RebalanceResult {
+  success: boolean
+  prevActiveId: number
+  newActiveId: number
+  prevMinBin: number
+  prevMaxBin: number
+  newMinBin: number
+  newMaxBin: number
+  removeTxHash?: string
+  swapTxHash?: string
+  addTxHash?: string
+  swapDirection?: string
+  amountXRemoved?: string
+  amountYRemoved?: string
+  amountXAdded?: string
+  amountYAdded?: string
+  gasUsed?: string
+  durationMs: number
+  error?: string
+}

+ 96 - 0
src/lib/notifications/index.ts

@@ -0,0 +1,96 @@
+import { config } from '../config'
+import type { RebalanceResult } from '../engine/types'
+
+export async function sendNotification(
+  title: string,
+  body: string,
+  level: 'info' | 'warning' | 'error' = 'info',
+): Promise<void> {
+  if (!config.notifications.enabled || !config.notifications.endpoint) return
+
+  try {
+    const { provider, endpoint } = config.notifications
+
+    switch (provider) {
+      case 'bark':
+        await sendBark(endpoint, title, body, level)
+        break
+      case 'ntfy':
+        await sendNtfy(endpoint, title, body, level)
+        break
+      case 'pushover':
+        await sendPushover(endpoint, title, body, level)
+        break
+    }
+  } catch (err) {
+    console.error('[notify] Failed to send notification:', err)
+  }
+}
+
+async function sendBark(
+  endpoint: string,
+  title: string,
+  body: string,
+  level: string,
+): Promise<void> {
+  const icon = level === 'error' ? 'exclamationmark.triangle' : 'arrow.triangle.2.circlepath'
+  const url = `${endpoint}/${encodeURIComponent(title)}/${encodeURIComponent(body)}?group=lfj-rebalancer&icon=${icon}`
+  const res = await fetch(url)
+  if (!res.ok) console.error('[notify:bark] HTTP', res.status)
+}
+
+async function sendNtfy(
+  endpoint: string,
+  title: string,
+  body: string,
+  level: string,
+): Promise<void> {
+  const priority = level === 'error' ? '5' : level === 'warning' ? '4' : '3'
+  const res = await fetch(endpoint, {
+    method: 'POST',
+    headers: {
+      Title: title,
+      Priority: priority,
+      Tags: 'chart_with_upwards_trend',
+    },
+    body,
+  })
+  if (!res.ok) console.error('[notify:ntfy] HTTP', res.status)
+}
+
+async function sendPushover(
+  endpoint: string,
+  title: string,
+  body: string,
+  level: string,
+): Promise<void> {
+  const priority = level === 'error' ? 1 : 0
+  const [token, user] = endpoint.split(':')
+  const res = await fetch('https://api.pushover.net/1/messages.json', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ token, user, title, message: body, priority }),
+  })
+  if (!res.ok) console.error('[notify:pushover] HTTP', res.status)
+}
+
+export async function notifyRebalance(result: RebalanceResult): Promise<void> {
+  if (!config.notifications.alertOnRebalance) return
+
+  const title = result.success ? 'Rebalance Success' : 'Rebalance Failed'
+  const body = result.success
+    ? `Bin ${result.prevActiveId} → ${result.newActiveId} | Range [${result.newMinBin}, ${result.newMaxBin}] | ${result.durationMs}ms`
+    : `Failed: ${result.error}`
+
+  await sendNotification(title, body, result.success ? 'info' : 'error')
+}
+
+export async function notifyError(message: string): Promise<void> {
+  if (!config.notifications.alertOnError) return
+  await sendNotification('LFJ Bot Error', message, 'error')
+}
+
+export async function notifyLowBalance(mon: string, usdc: string): Promise<void> {
+  if (!config.notifications.alertOnLowBalance) return
+  await sendNotification('Low Balance', `MON: ${mon} | USDC: ${usdc}`, 'warning')
+}

+ 6 - 0
src/lib/utils.ts

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

Some files were not shown because too many files changed in this diff