diff --git a/.prettierignore b/.prettierignore index d724c986..893fa410 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ dist .next .turbo pnpm-lock.yaml +packages/contracts/lib/ diff --git a/docs/Dev_Drip_PRD.md b/docs/Dev_Drip_PRD.md index 0f66cceb..f57fc2a1 100644 --- a/docs/Dev_Drip_PRD.md +++ b/docs/Dev_Drip_PRD.md @@ -2,6 +2,8 @@ ## Ad-Subsidized AI Coding Tools via Opt-In Idle-Time Monetization +> **Note (post-pivot).** Dev Drip became Distro TV, a channel surface. Channels are the verticals: CH 01 NEWS, CH 02 MARKETS, and now **CH 03 ONCHAIN — LP GUARD** (a Uniswap v4 hook piloted from the terminal). See [product/onchain-lp-guard-prd.md](./product/onchain-lp-guard-prd.md) and [product/onchain-lp-guard-feature-spec.md](./product/onchain-lp-guard-feature-spec.md). + --- ## 1. Problem Statement diff --git a/docs/product/onchain-lp-guard-feature-spec.md b/docs/product/onchain-lp-guard-feature-spec.md new file mode 100644 index 00000000..475e0086 --- /dev/null +++ b/docs/product/onchain-lp-guard-feature-spec.md @@ -0,0 +1,50 @@ +# CH 03 ONCHAIN — LP GUARD: Feature Spec + +Companion to the [PRD](./onchain-lp-guard-prd.md). Engineering detail in [Onchain LP Guard](../../gitbook-docs/architecture/onchain-lp-guard.md) and [CLI: onchain](../../gitbook-docs/cli/onchain.md). All behavior here is **as-built and verified live** on X Layer testnet (chainId 1952). + +## User stories + +- As a terminal-native LP, I want my Uniswap v4 position **defended on-chain by a fee that reacts to volatility**, so I'm compensated for toxic flow without managing fee tiers myself. +- As a developer with an agent running, I want my **LP position rendered as an ambient channel** while I code, so I keep an eye on it without leaving the terminal. +- As an LP, I want a **terminal alert the instant price exits my range**, so I don't discover a dead position hours later. +- As an LP, I want to **act in one command** (`distro onchain hedge`) that signs and broadcasts, so reacting to a breach is friction-free. +- As a cautious user, I want the hook to be an **advisor, not a custodian** — it never holds or moves my funds. + +## Acceptance criteria + +- [x] **Hook deployed + verifiable on X Layer.** `DistroGuardHook` live at `0xD8eB6573A3192387dd71c2e646ad23E8DB36d0c0` on chainId 1952; pool `0xe6d570f5…` initialized as a dynamic-fee pool. +- [x] **Fee rises with volatility on-chain.** Demo swaps emitted `FeeApplied` rising `3000 → 3250` as `VolUpdated` `ewmaVolBps` climbed to 53; live snapshot reported `feeBps` 57. Fee = `clamp(3000 + 50×ewmaVolBps + 20×sizeUnit, 3000, 10000)` pips. +- [x] **Range breach → terminal alert.** The 1-min read-only evaluator detects tick outside `[lower, upper]` and pushes a `range_breach` alert through the real ticker alert pipeline; the daemon renders the alert (red) slot variant. +- [x] **One-click hedge broadcasts.** `distro onchain hedge` resolves the active position, fetches unsigned calldata, signs with the local key, and broadcast real swaps (e.g. tx `0xf14c0c9d…`) that moved the hook's on-chain volatility. + +## Slot / alert UX + +- **Normal slot** (`OnchainPayload`, `kind: "onchain"`) renders pool label, price, tick, range with in/out indicator, live `feeBps`, `volBps`, `feesEarnedUsd`, `ilPct`, `asOf`. +- **Alert slot** carries an `OnchainAlert` (`range_breach` | `near_breach` | `vol_spike`); the renderer wraps the box in red, mirroring the ticker alert path. +- Alerts are **debounced 60 min** per `(device, type)` so a flapping price doesn't spam slots. +- Alerts ride the **existing ticker alert pipeline** (LPUSH-to-device-queue, consume-once LPOP on selection, LPUSH-before-INSERT ordering). + +## Action semantics + +- **`hedge`** — implemented end-to-end. Server returns unsigned swap calldata targeting the swapRouter; CLI signs + broadcasts. +- **`exit`, `rebalance`** — CLI subcommands exist and are wired, but the server `actions/prepare` returns an error for them today (pending server impl). +- Actions are **client-signed**: the server never holds a key. The CLI uses a local testnet key at `~/.distro/onchain-key.json` (mode 600), created/imported via `distro onchain init`. +- The position is resolved from `--position ` or the first `active` position via `GET /me/onchain/positions`. + +## Edge cases + +- **No key** → CLI prints `run: distro onchain init`, no broadcast. +- **No active position** → CLI prints a hint to register one in the dashboard. +- **Unfunded key** → broadcast reverts on gas/balance; faucet the address (advisor model — no server fallback). +- **`XLAYER_RPC_URL` unset** → backend `ONCHAIN_ENABLED` is false (worker no-op, actions reject); CLI broadcast can't connect. +- **Device offline at breach** → alert sits on the device queue (60-min TTL), delivered on next sync. +- **Redis lpush fails in evaluator** → no `onchain_events` row → next tick re-evaluates without a debounce penalty. +- **Demo** → `POST /__test/fire-onchain` (404 in production) forces a `range_breach` deterministically. + +## Out of scope (pilot) + +- **Autonomy** — no keeper executing on the user's behalf; the human acts. (Advisor → autonomous is roadmap.) +- **Mainnet** — X Layer testnet only. +- **Multi-position** — actions resolve a single active position; portfolio-wide management is not modeled. +- **Live keypress capture** — the daemon does not capture `h`/`e`/`r`; those keys are wired but dormant. The one-click is the `distro onchain` subcommand. +- **`exit` / `rebalance` server impl** — only `hedge` is implemented server-side. diff --git a/docs/product/onchain-lp-guard-prd.md b/docs/product/onchain-lp-guard-prd.md new file mode 100644 index 00000000..5e364db9 --- /dev/null +++ b/docs/product/onchain-lp-guard-prd.md @@ -0,0 +1,68 @@ +# CH 03 ONCHAIN — LP GUARD: Product Requirements + +## 1. Problem + +Concentrated-liquidity LPs on Uniswap (v3/v4) face three structural costs that compound while they are away from the screen: + +- **Toxic flow.** Informed swappers pick off LPs during volatility. A static fee tier can't widen when the pool turns adversarial, so the LP subsidizes arbitrageurs exactly when it hurts most. +- **Impermanent loss.** Price drift against the position erodes value relative to holding. +- **Range babysitting.** A concentrated position only earns fees while the price sits inside its tick range. The moment price exits, the position stops earning and starts behaving like a single-sided bag — and the LP has no idea until they next check a dashboard. + +Terminal-native DeFi LPs and builders live in their editor and terminal, not in a DEX UI. They want their position defended automatically and to be told — where they already are — the instant something needs a decision. + +## 2. Target user + +Terminal-native DeFi LPs and builders: people running Uniswap v4 positions who spend their day in a coding terminal, often with an AI agent working in the background. They are comfortable with a CLI, a private key, and reading on-chain state — but they don't want to babysit a position or sit in a DEX dashboard. + +## 3. Value proposition — LP GUARD + +**A Uniswap v4 hook that defends LPs with a volatility-reactive fee, piloted from the terminal you code in.** + +- The **hook** raises the swap fee on-chain as the pool gets volatile (and adds a premium for outsized swaps), so the LP is compensated for toxic flow at the moment it arrives — not on a delay, not via a static tier. +- The **terminal** renders the live position — price, range, in/out, current fee, volatility — as an ambient channel while the agent works. +- A **range breach fires a terminal alert**, surfaced where the user already is. +- The user **acts with one CLI command** (`distro onchain hedge`), signing and broadcasting from a local key. + +**Advisor model.** The hook sets the fee and emits state; the human decides and acts. The hook never custodies or moves LP funds. This is deliberate — it keeps the trust surface minimal and the loop legible. + +## 4. Positioning within Distro TV + +Distro TV is a **channel surface** that runs in the developer's terminal while AI tools work. The product is the surface; channels are the verticals. CH 01 NEWS and CH 02 MARKETS were the launch channels. **CH 03 ONCHAIN — LP GUARD is the first channel that is also an on-chain product** — it doesn't just display a feed, it ships a Uniswap v4 hook and a control surface for acting on it. + +It validates the channel-surface thesis: a new vertical slots into the same ambient terminal surface (same daemon, same selection path, same alert pipeline) without disturbing the others. ONCHAIN runs independently of the news/markets data pipeline — its own contracts, viem read client, and worker tick. + +## 5. UX flows + +### Monitor + +User selects `onchain_only` (mode `onchain_only`) via `distro channel onchain` or the prefs picker. The terminal renders the LP GUARD slot: pool, price, tick, range with in/out indicator, live fee (bps), volatility, fees earned, IL. + +### Alert + +A background evaluator checks the live tick every minute. When price exits the position's range, a `range_breach` alert is pushed to the device and the next slot renders the alert variant (red box). Debounced 60 min per breach type so a flapping price doesn't spam. + +### One-click act + +The user runs `distro onchain hedge`. The CLI resolves the active position, fetches unsigned swap calldata from the API, signs with the local testnet key, and broadcasts to X Layer. The broadcast moves real flow through the pool, which the hook folds back into its volatility signal — the terminal then re-renders the updated state. (Keystroke capture is intentionally off; the one-click is the subcommand, run directly or inline as `!distro onchain hedge` inside Claude Code.) + +## 6. Hackathon framing — Build X / Hook the Future + +CH 03 was built for the **Build X / Hook the Future** hackathon: + +- **X Layer** — deployed and verified live on X Layer testnet (chainId 1952). +- **Uniswap v4** — a real v4 hook (`DistroGuardHook`) with a state-driven dynamic fee, the v4-only capability that makes the product possible. +- **Flap** — part of the hackathon's sponsor/ecosystem framing. + +Judged on **Innovation**, **Market**, and **Completion**. The pitch leans on all three: a novel LP-defense primitive (innovation), a real and underserved LP pain (market), and a fully working, on-chain-verified loop from hook to terminal to broadcast (completion). + +## 7. Roadmap + +- **Advisor → autonomous.** Today the human acts. A future mode could let a funded keeper execute the hedge on breach, with explicit user-set bounds. (Out of scope for the pilot — see the feature spec.) +- **`exit` and `rebalance` actions.** `hedge` is implemented end-to-end; `exit` and `rebalance` are wired in the CLI but await the server-side `actions/prepare` implementation. +- **Mainnet.** The pilot is X Layer testnet only. Mainnet requires a real fee/volatility calibration pass and a hardened key story (no hot key in a dotfile). + +## 8. Related docs + +- [Onchain LP Guard — engineering](../../gitbook-docs/architecture/onchain-lp-guard.md) +- [CLI: onchain](../../gitbook-docs/cli/onchain.md) +- [Feature spec](./onchain-lp-guard-feature-spec.md) diff --git a/eslint.config.ts b/eslint.config.ts index fad52550..b8710e09 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -19,6 +19,12 @@ export default tseslint.config( }, }, { - ignores: ["**/dist/**", "**/node_modules/**", "**/*.config.*", "packages/dashboard/**"], + ignores: [ + "**/dist/**", + "**/node_modules/**", + "**/*.config.*", + "packages/dashboard/**", + "packages/contracts/lib/**", + ], } ) diff --git a/frontend/app/dashboard/onchain/actions.ts b/frontend/app/dashboard/onchain/actions.ts new file mode 100644 index 00000000..8e358a5e --- /dev/null +++ b/frontend/app/dashboard/onchain/actions.ts @@ -0,0 +1,75 @@ +"use server" + +import { redirect } from "next/navigation" +import { + createOnchainPosition, + deleteOnchainPosition as apiDeleteOnchainPosition, + getPoolSnapshot, + type CreateOnchainPositionBody, + type OnchainPosition, + type PoolSnapshot, +} from "@/lib/dashboard-api" +import { ApiError, UnauthenticatedError } from "@/lib/api" + +export interface CreatePositionResult { + ok: boolean + position?: OnchainPosition + error?: string +} + +// register form submit calls this, then the client appends the returned row +export async function registerPosition( + body: CreateOnchainPositionBody +): Promise { + try { + const position = await createOnchainPosition(body) + return { ok: true, position } + } catch (err) { + if (err instanceof UnauthenticatedError) { + redirect("/auth/refresh?next=/dashboard/onchain") + } + if (err instanceof ApiError) { + const b = err.body as { error?: string } | null + return { ok: false, error: b?.error ?? `api_error_${err.status}` } + } + return { ok: false, error: "network_error" } + } +} + +export interface DeletePositionResult { + ok: boolean + error?: string +} + +// row stop-watching button calls this after optimistic remove +export async function stopWatchingPosition(id: string): Promise { + try { + await apiDeleteOnchainPosition(id) + return { ok: true } + } catch (err) { + if (err instanceof UnauthenticatedError) { + redirect("/auth/refresh?next=/dashboard/onchain") + } + if (err instanceof ApiError) { + const b = err.body as { error?: string } | null + return { ok: false, error: b?.error ?? `api_error_${err.status}` } + } + return { ok: false, error: "network_error" } + } +} + +export interface RefreshSnapshotResult { + ok: boolean + snapshot?: PoolSnapshot + error?: string +} + +// live pool panel re-fetch (public endpoint) for optional polling +export async function refreshPoolSnapshot(poolId: string): Promise { + try { + const snapshot = await getPoolSnapshot(poolId) + return { ok: true, snapshot } + } catch { + return { ok: false, error: "snapshot_unavailable" } + } +} diff --git a/frontend/app/dashboard/onchain/onchain-client.tsx b/frontend/app/dashboard/onchain/onchain-client.tsx new file mode 100644 index 00000000..cb1661b8 --- /dev/null +++ b/frontend/app/dashboard/onchain/onchain-client.tsx @@ -0,0 +1,371 @@ +"use client" + +import { useState, useTransition, useId } from "react" +import type { OnchainPosition, PoolSnapshot } from "@/lib/dashboard-api" +import { EmptyState } from "@/components/v5/empty-state" +import { SectionRule } from "@/components/v5/section-rule" +import { SharpInput } from "@/components/v5/sharp-input" +import { SharpButton } from "@/components/v5/sharp-button" +import { registerPosition, stopWatchingPosition } from "./actions" + +const POOL_ID_RE = /^0x[0-9a-fA-F]{64}$/ +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/ +const XLAYER_TESTNET = 1952 + +function shortAddr(a: string): string { + return a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a +} + +// feeBps / volBps are basis points (1 bp = 0.01%) +function bpsToPct(bps: number): string { + return `${(bps / 100).toFixed(2)}%` +} + +interface Props { + initial: OnchainPosition[] + snapshot: PoolSnapshot | null + demoPoolId: string +} + +export function OnchainClient({ initial, snapshot, demoPoolId }: Props) { + const [positions, setPositions] = useState(initial) + const [pending, start] = useTransition() + const [error, setError] = useState(null) + + // register form state — poolId prefilled to the demo pool + const [label, setLabel] = useState("") + const [poolId, setPoolId] = useState(demoPoolId) + const [tickLower, setTickLower] = useState("") + const [tickUpper, setTickUpper] = useState("") + const [walletAddress, setWalletAddress] = useState("") + const formId = useId() + + const liveTick = snapshot?.tick ?? null + + function inRange(p: OnchainPosition): boolean | null { + if (liveTick === null) return null + return liveTick >= p.tickLower && liveTick < p.tickUpper + } + + function add(e: React.FormEvent) { + e.preventDefault() + const lower = Number(tickLower) + const upper = Number(tickUpper) + + if (!POOL_ID_RE.test(poolId.trim())) { + setError("pool id must be a 0x… 64-hex string") + return + } + if (!ADDRESS_RE.test(walletAddress.trim())) { + setError("wallet must be a 0x… 40-hex address") + return + } + if ( + !Number.isInteger(lower) || + !Number.isInteger(upper) || + tickLower === "" || + tickUpper === "" + ) { + setError("ticks must be integers") + return + } + if (lower >= upper) { + setError("tickLower must be below tickUpper") + return + } + + start(async () => { + const result = await registerPosition({ + chainId: XLAYER_TESTNET, + poolId: poolId.trim(), + tickLower: lower, + tickUpper: upper, + walletAddress: walletAddress.trim(), + label: label.trim() || undefined, + }) + if (result.ok && result.position) { + setPositions((cur) => [result.position as OnchainPosition, ...cur]) + setLabel("") + setTickLower("") + setTickUpper("") + setWalletAddress("") + setError(null) + } else { + setError(result.error ?? "register failed") + } + }) + } + + function remove(id: string) { + const prev = positions + setPositions((cur) => cur.filter((p) => p.id !== id)) + setError(null) + start(async () => { + const result = await stopWatchingPosition(id) + if (!result.ok) { + setPositions(prev) + setError(result.error ?? "couldn't stop watching — try again") + } + }) + } + + return ( +
+ {/* ── live pool panel — dynamic fee is the hero ── */} +
+
+ + Live Pool + + {snapshot ? ( + + + {snapshot.poolLabel} + + ) : ( + + snapshot unavailable + + )} +
+ + {snapshot ? ( + <> + {/* hero: dynamic fee */} +
+ + {bpsToPct(snapshot.feeBps)} + + dynamic fee +
+

+ rises with volatility — {bpsToPct(snapshot.volBps)} vol right now +

+ + {/* secondary stats */} +
+ + + + +
+ +

+ as of {new Date(snapshot.asOf).toLocaleString()} +

+ + ) : ( +

+ couldn't read the on-chain snapshot. positions still load below — try a refresh. +

+ )} +
+ + {/* ── positions list ── */} +
+
+

+ Watched Positions +

+ {positions.length > 0 && ( + + {positions.length} + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {positions.length === 0 ? ( + + ) : ( +
+ {positions.map((p) => { + const ir = inRange(p) + return ( +
+ {/* label */} + + {p.label || "untitled"} + + + {/* range */} + + {p.tickLower} → {p.tickUpper} + + + {/* wallet */} + + {shortAddr(p.walletAddress)} + + +
+ + {/* in/out of range vs live tick */} + {ir === null ? ( + + no live tick + + ) : ( + + {ir ? "in range" : "out of range"} + + )} + + {/* stop watching */} + +
+ ) + })} +
+ )} +
+ + + + {/* ── register form ── */} +
+

+ Register Position +

+
+
+ + setLabel(e.target.value)} + disabled={pending} + className="w-full text-[12px] font-[var(--font-data)]" + /> + + + setWalletAddress(e.target.value)} + disabled={pending} + className="w-full text-[12px] font-[var(--font-data)]" + /> + +
+ + + setPoolId(e.target.value)} + disabled={pending} + className="w-full text-[12px] font-[var(--font-data)]" + /> + + +
+ + setTickLower(e.target.value)} + disabled={pending} + className="w-full text-[12px] font-[var(--font-data)]" + /> + + + setTickUpper(e.target.value)} + disabled={pending} + className="w-full text-[12px] font-[var(--font-data)]" + /> + +
+ +
+ + {pending ? "registering…" : "register position"} + + + x layer testnet · chainId {XLAYER_TESTNET} + +
+
+
+
+ ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ) +} + +function Field({ + label, + htmlFor, + children, +}: { + label: string + htmlFor: string + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/frontend/app/dashboard/onchain/page.tsx b/frontend/app/dashboard/onchain/page.tsx new file mode 100644 index 00000000..04b34414 --- /dev/null +++ b/frontend/app/dashboard/onchain/page.tsx @@ -0,0 +1,45 @@ +import { BlurFade } from "@distrotv/design-system/components/blur-fade" +import { apiFetchOrRefresh } from "@/lib/api" +import type { OnchainPosition, PoolSnapshot } from "@/lib/dashboard-api" +import { OnchainClient } from "./onchain-client" + +export const dynamic = "force-dynamic" + +// demo pool — mWETH/mUSDC on base sepolia. prefilled in the register form too. +const DEMO_POOL_ID = "0xe6d570f5d75318142a44ff50c345bbdca0e57786430d14adb7786d39dec6a2b7" + +export default async function OnchainPage() { + const [{ positions }, snapshot] = await Promise.all([ + apiFetchOrRefresh<{ positions: OnchainPosition[] }>( + "/me/onchain/positions", + "/dashboard/onchain" + ), + // public endpoint — tolerate failure so the page still renders the rest + apiFetchOrRefresh(`/onchain/pools/${DEMO_POOL_ID}`, "/dashboard/onchain").catch( + () => null + ), + ]) + + return ( +
+ +
+

+ LP Guard +

+

+ on-chain positions +

+

+ watch your uniswap v4 liquidity. the dynamic fee climbs with volatility — your terminal + surfaces a guard slot the moment price drifts toward a range edge. +

+
+
+ + + + +
+ ) +} diff --git a/frontend/components/dashboard/app-shell.tsx b/frontend/components/dashboard/app-shell.tsx index 41b02238..df3ff0ea 100644 --- a/frontend/components/dashboard/app-shell.tsx +++ b/frontend/components/dashboard/app-shell.tsx @@ -21,6 +21,7 @@ export function AppShell({ user, initialMode, children, configReadout }: AppShel + @@ -46,6 +47,7 @@ export function AppShell({ user, initialMode, children, configReadout }: AppShel + diff --git a/frontend/components/dashboard/preferences/preferences-form.tsx b/frontend/components/dashboard/preferences/preferences-form.tsx index fcd9024f..cdd9d393 100644 --- a/frontend/components/dashboard/preferences/preferences-form.tsx +++ b/frontend/components/dashboard/preferences/preferences-form.tsx @@ -34,6 +34,7 @@ const MODE_DESCRIPTIONS: Record = { [ChannelMode.Balanced]: "1:1 — news and ticker alternate evenly", [ChannelMode.TickerHeavy]: "1:3 — three ticker slots for every news slot", [ChannelMode.TickerOnly]: "ticker only — no news slots in your rotation", + [ChannelMode.OnchainOnly]: "lp guard — onchain pool monitoring channel", } export function PreferencesForm({ initial, initialChannels, initialAlerts }: PreferencesFormProps) { diff --git a/frontend/lib/dashboard-api.ts b/frontend/lib/dashboard-api.ts index 81b2923f..a325c939 100644 --- a/frontend/lib/dashboard-api.ts +++ b/frontend/lib/dashboard-api.ts @@ -166,6 +166,61 @@ export async function getNewsStats(): Promise { return apiFetch("/me/news-stats") } +// ── onchain lp guard ────────────────────────────────────────────────────────── + +export interface OnchainPosition { + id: string + chainId: number + poolId: string + tickLower: number + tickUpper: number + walletAddress: string + label: string | null + status: string + createdAt: string +} + +export interface CreateOnchainPositionBody { + chainId: number + poolId: string + tickLower: number + tickUpper: number + walletAddress: string + label?: string +} + +export interface PoolSnapshot { + poolId: string + poolLabel: string + tick: number + price: number + volBps: number + feeBps: number + asOf: string +} + +export async function getOnchainPositions(): Promise<{ positions: OnchainPosition[] }> { + return apiFetch<{ positions: OnchainPosition[] }>("/me/onchain/positions") +} + +export async function createOnchainPosition( + body: CreateOnchainPositionBody +): Promise { + return apiFetch("/me/onchain/positions", { + method: "POST", + body: JSON.stringify(body), + }) +} + +export async function deleteOnchainPosition(id: string): Promise { + await apiFetch(`/me/onchain/positions/${id}`, { method: "DELETE" }) +} + +// public — no auth required +export async function getPoolSnapshot(poolId: string): Promise { + return apiFetch(`/onchain/pools/${poolId}`) +} + // ── helpers ───────────────────────────────────────────────────────────────── function buildQuery(params: Record): string { diff --git a/gitbook-docs/SUMMARY.md b/gitbook-docs/SUMMARY.md index baa580d6..cfe79c23 100644 --- a/gitbook-docs/SUMMARY.md +++ b/gitbook-docs/SUMMARY.md @@ -12,6 +12,7 @@ - [Ticker Pipeline](architecture/ticker-pipeline.md) - [Slot Rendering](architecture/slot-rendering.md) - [Alerts](architecture/alerts.md) +- [Onchain LP Guard](architecture/onchain-lp-guard.md) - [Quiet Hours](architecture/quiet-hours.md) - [Ratio Selection](architecture/ratio-selection.md) - [Backend API](backend/api.md) @@ -24,6 +25,7 @@ - [CLI Releases](cli/releases.md) - [Daemon + Hook IPC](cli/daemon-and-hooks.md) - [News and Reading (CLI)](cli/news-and-reading.md) +- [CLI: onchain](cli/onchain.md) - [Slot Cache](cli/ad-cache.md) - [Local Ledger](cli/local-ledger.md) - [Dashboard Current State](dashboard/current-state.md) diff --git a/gitbook-docs/architecture/channel-modes.md b/gitbook-docs/architecture/channel-modes.md index 3ae55c7f..c7ed2f12 100644 --- a/gitbook-docs/architecture/channel-modes.md +++ b/gitbook-docs/architecture/channel-modes.md @@ -7,6 +7,7 @@ Distro TV is a content-serving terminal slot. Users pick what content fills the - 🎭 **balanced / mix** — alternates news and ticker 1:1 (default) - **news heavy** — 3 news for every 1 ticker - **ticker heavy** — 1 news for every 3 tickers +- ⛓ **onchain only** (`onchain_only`) — CH 03 ONCHAIN LP GUARD; renders a Uniswap v4 LP position + range-breach alerts. Independent of the news/ticker pipeline; see [Onchain LP Guard](onchain-lp-guard.md). Mode is set per-user in `preferences.channel_mode` and synced across CLI ↔ dashboard. @@ -22,6 +23,7 @@ The CLI fetches from `GET /me/content/next?n=N&deviceId=...`. The server reads ` - `ticker_only` → `n` calls to `nextTickerForDevice(...)` - `news_only` → `nextPicksForDevice(...)` (news selection) +- `onchain_only` → `nextOnchainForDevice(...)` — LPOPs a pending onchain alert, else builds an `OnchainPayload` from a live `readVol` (see [Onchain LP Guard](onchain-lp-guard.md)) - `balanced` / `news_heavy` / `ticker_heavy` → ratio-based interleaving (see [Ratio Selection](ratio-selection.md)) See [Slot Content](slot-content.md) for the discriminated-union shape. diff --git a/gitbook-docs/architecture/monorepo.md b/gitbook-docs/architecture/monorepo.md index eeb8f2b0..867ac8b3 100644 --- a/gitbook-docs/architecture/monorepo.md +++ b/gitbook-docs/architecture/monorepo.md @@ -7,6 +7,7 @@ frontend/ packages/ api/ cli/ + contracts/ (Foundry — Uniswap v4 hook for CH 03 ONCHAIN) dashboard/ (app shell — merged into frontend/ for most product surfaces) shared/ docs/ @@ -60,6 +61,14 @@ These dispatch through Turbo. Package-level behavior lives inside each workspace - distributed via curl + GitHub Releases (`cli-v*` tag → `release-cli.yml`) - daemon on Unix socket: slot cache, key capture, local SQLite ledger +## `packages/contracts` + +- Foundry project for `CH 03 ONCHAIN — LP GUARD` +- `DistroGuardHook.sol` — Uniswap v4 hook (volatility/size-aware dynamic fee), built on v4-core 1.0.2, extends `BaseTestHooks` +- vendored `HookMiner` (`script/HookMiner.sol`); v4-core / v4-periphery / forge-std under `lib/` +- `script/Deploy.s.sol` self-deploys a PoolManager + v4-core test routers, mines + deploys the hook, inits the dynamic-fee pool, writes `export/addresses..json` + the ABI +- live on X Layer testnet (chainId 1952); see [Onchain LP Guard](onchain-lp-guard.md) + ## `packages/dashboard` - separate Next.js app shell diff --git a/gitbook-docs/architecture/onchain-lp-guard.md b/gitbook-docs/architecture/onchain-lp-guard.md new file mode 100644 index 00000000..115d102e --- /dev/null +++ b/gitbook-docs/architecture/onchain-lp-guard.md @@ -0,0 +1,142 @@ +# Onchain LP Guard + +`CH 03 ONCHAIN — LP GUARD` is the third Distro TV channel (mode `onchain_only`). While you code, the terminal renders your Uniswap v4 LP position; a v4 hook protects the LP on-chain with a volatility- and size-aware dynamic fee; a range breach fires a terminal alert; you act with a one-click CLI subcommand. It is **independent of the news/markets pipeline** — its own contracts, viem read client, worker tick, and selection path. + +**Advisor model.** The hook sets the fee and emits state. The human acts. No autonomy, no hook-owned liquidity — the hook never holds or moves LP funds; it only reads pool flow and overrides the swap fee. + +Live on **X Layer testnet, chainId 1952** (Alchemy RPC). The canonical CREATE2 proxy `0x4e59b448…` is present, so the deterministic hook-address mine works. No official Uniswap v4 deployment was relied on — the deploy script self-deploys a PoolManager + v4-core test routers. + +## The hook + +`DistroGuardHook` — `packages/contracts/src/DistroGuardHook.sol`. Built on **v4-core 1.0.2**. v4-periphery 1.0.4 ships no `BaseHook`/`HookMiner`, so the hook extends v4-core's `src/test/BaseTestHooks.sol` and `HookMiner` is vendored at `packages/contracts/script/HookMiner.sol`. v4-core, v4-periphery, and forge-std are vendored under `packages/contracts/lib/`. + +**Why a hook is unavoidable.** v3 exposed only static fee tiers. Per-swap, state-driven fees are a v4-only capability — the dynamic-fee flag plus the `beforeSwap` fee override is the mechanism that does not exist in v3. + +### Hook points + +Permissions: `afterInitialize` + `beforeSwap` + `afterSwap`. The hook address is CREATE2-mined so its low 14 bits encode exactly those three flags (Uniswap derives hook permissions from the deployed address, hence the mine). + +- **`afterInitialize`** — asserts `key.fee == LPFeeLibrary.DYNAMIC_FEE_FLAG` (the pool must be a dynamic-fee pool), seeds `VolState` with the initial tick. +- **`beforeSwap`** — computes the fee and returns `fee | OVERRIDE_FEE_FLAG`, overriding the pool's LP fee for this swap. Emits `FeeApplied(poolId, feePips)`. +- **`afterSwap`** — reads the post-swap tick via `StateLibrary.getSlot0`, folds `|tickΔ|` into the per-pool EWMA, stores `lastTick`, emits `VolUpdated(poolId, tick, ewmaVolBps)`. + +All three callbacks are guarded by `onlyPoolManager` (`revert NotPoolManager` otherwise) so only the PoolManager can mutate the EWMA — an arbitrary caller cannot skew the fee. A public `currentTick(PoolId)` view returns the live pool tick (`getSlot0`) so off-chain readers get the current price, not the last-swap cache. + +### Fee formula + +Fees are in **pips** (1e-6), so the clamp band is **0.30%–1.00%**: + +``` +fee = clamp(BASE_FEE + VOL_K × ewmaVolBps + SIZE_K × sizeUnit, BASE_FEE, MAX_FEE) + +BASE_FEE = 3000 (0.30%) +MAX_FEE = 10000 (1.00%) +VOL_K = 50 +SIZE_K = 20 +sizeUnit = absAmountSpecified > 1e21 ? absAmountSpecified / 1e21 : 0 +``` + +Volatility pushes the fee up; large swaps (`> 1e21` absolute amount) add a size premium. Both terms are saturated at `MAX_FEE`. + +### EWMA volatility + +State is `mapping(PoolId => VolState{ int24 lastTick; uint32 ewmaVolBps; bool seeded })`, exposed by the public getter `volOf`. Each `afterSwap`: + +``` +delta = tick - lastTick +absBps = |delta| +ewmaVolBps = (ewmaVolBps × 3 + absBps) / 4 // alpha = 1/4 +lastTick = tick +``` + +The EWMA is the running signal the off-chain side reads for display and breach context. + +## Self-deploy + addresses + +`packages/contracts` is a Foundry project. `script/Deploy.s.sol`: + +1. Self-deploys `PoolManager` + `PoolModifyLiquidityTest` + `PoolSwapTest` (v4-core test routers) + two `MockERC20`s (`src/mocks/MockERC20.sol`). +2. Mines a salt with the vendored `HookMiner` so the hook address encodes the permission flags, then CREATE2-deploys `DistroGuardHook`. +3. Initializes the dynamic-fee pool (`LPFeeLibrary.DYNAMIC_FEE_FLAG`, tickSpacing 60), seeds liquidity at ticks **-600..600**, and runs demo swaps. +4. Writes `export/addresses..json` and exports the ABI to `export/abis/DistroGuardHook.json`. + +### Live addresses (X Layer testnet, chainId 1952) + +| Entity | Address | +| --------------------------- | -------------------------------------------------------------------- | +| hook (`DistroGuardHook`) | `0xD8eB6573A3192387dd71c2e646ad23E8DB36d0c0` | +| PoolManager | `0x822DdD95fC087c840e646F82432BE9e8276AF648` | +| token0 (mWETH) | `0xE51CD983c36CaB22cc3756bb8537489157ce758a` | +| token1 (mUSDC) | `0xf090d274518Fcd9202694a3306997d1672C54d7C` | +| swapRouter (`PoolSwapTest`) | `0x22CE7B3031fEAF5e4af2c056EAA8327965165c69` | +| poolId | `0x126278a4094f9fa5d6a1494b1d8e60293837f02a2cf1eaf821c8f47dc725afbc` | +| tickSpacing | 60 | + +## Backend + +### Data model (migration `0023_*`, `0024_*`) + +- **`onchain_pools`** — `poolId` PK, `chainId`, `hookAddress`, `label`, `token0`/`token1`, `decimals`, `tickSpacing`. The registry the snapshot route reads. +- **`onchain_positions`** — `id`, `userId` FK, `chainId`, `poolId`, `positionTokenId?`, `tickLower`/`tickUpper`, `walletAddress`, `label`, `status`. A user's tracked LP range. +- **`onchain_events`** — fire log for debounce (per-`(device, type)`, 60-min window), mirroring the ticker `alert_events` role. +- Migration `0024_*` extends the `preferences_channel_mode_check` constraint to allow `onchain_only`. + +See [Data Model](../backend/data-model.md). + +### Read-only viem client + +`lib/onchain-chain.ts` is a viem **read** client — **no keeper, no server signing key**. + +- `readVol(hookAddr, poolId)` reads the hook's `volOf` getter and returns `{ tick: lastTick, volBps: ewmaVolBps }`. +- `priceFromTick` converts a tick to a display price. + +Config in `config/onchain.ts`: `XLAYER_RPC_URL`, `XLAYER_CHAIN_ID`, `ONCHAIN_ENABLED`. When `XLAYER_RPC_URL` is unset, `ONCHAIN_ENABLED` resolves false and the worker + actions are inert. + +### Worker tick (read-only) + +`runOnchainEvaluation` runs on a **1-min cron** (`*/1 * * * *`), guarded by `ONCHAIN_ENABLED`, registered in `lib/background-jobs.ts`. It is **read-only** — it never signs or broadcasts. Each tick, for every active position: + +1. Reads the live tick. +2. Detects a range breach (tick outside `[tickLower, tickUpper]`). +3. On breach, **LPUSHes a `range_breach` alert to `pendingAlertsKey(deviceId)`** — reusing the ticker alert pipeline, with the same **LPUSH-before-INSERT ordering** (a failing lpush must not consume the debounce window; see [Alerts](alerts.md)). +4. Debounces 60-min per `(device, type)` via `onchain_events`. + +A failure inside the evaluator is logged and swallowed — the next tick proceeds. + +### Snapshot + selection + +- **`GET /onchain/pools/:poolId`** (public) — returns a snapshot: `price`, `tick`, live `feeBps`, `volBps`. `feeBpsFromVol` mirrors the on-chain fee math off-chain for display (so the dashboard/terminal show the same fee the hook would charge without simulating a swap). +- **`/me/content/next`** with `channelMode === 'onchain_only'` dispatches to `nextOnchainForDevice`: it first `LPOP`s a pending alert from the existing alert pipeline key (consume-once, same as the ticker promotion path); if none, it builds an `OnchainPayload` from a live `readVol`. See [Channel Modes](channel-modes.md). + +### Action flow — `actions/prepare` → CLI-sign → broadcast + +The advisor loop's "act" step is split server/client so **the server never holds a key**: + +1. CLI calls **`POST /me/onchain/actions/prepare`** (auth). The server resolves the active position and returns **unsigned swap calldata** targeting the swapRouter. Only **`hedge`** is implemented; `exit` and `rebalance` return an error (pending server impl). +2. The CLI signs with the **local testnet key** (`~/.distro/onchain-key.json`) and broadcasts to X Layer. See [CLI: onchain](../cli/onchain.md). + +The broadcast moves real swap flow through the pool, which the hook's `afterSwap` folds back into `ewmaVolBps` — closing the loop the terminal then re-renders. + +## Verified live + +- Deploy succeeded on X Layer: hook live, demo swaps showed `FeeApplied` rising `3000 → 3250` as `VolUpdated` `ewmaVolBps` rose to **53**. +- `readVol` / snapshot return live data (observed `feeBps` 57). +- The range-breach evaluator fires alerts through the real ticker pipeline. +- `distro onchain hedge` broadcast real swaps (e.g. tx `0xf14c0c9d…`) that moved the hook's on-chain volatility. + +## Failure modes + +| failure | behavior | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `XLAYER_RPC_URL` unset | `ONCHAIN_ENABLED` resolves false → worker tick is a no-op; `actions/prepare` and CLI broadcast reject | +| hook address mismatch on mine | CREATE2 salt mine must produce an address whose low 14 bits match the permission flags; a mismatch fails deploy, not init | +| no v4-periphery `BaseHook`/`HookMiner` | hook extends v4-core `BaseTestHooks`; `HookMiner` vendored at `script/HookMiner.sol`; deploy self-deploys test routers | +| CLI key unfunded | broadcast reverts on insufficient gas / balance — testnet faucet the local key; no server-side fallback (advisor model) | +| `exit` / `rebalance` requested | `actions/prepare` returns an error — only `hedge` is implemented server-side | +| range breach while device offline | `LPUSH` succeeds; daemon picks the alert off the queue on next sync (60-min list TTL), debounced per `(device, type)` | +| Redis lpush fails in evaluator | no `onchain_events` row written → next tick re-evaluates with no debounce penalty (LPUSH-before-INSERT invariant) | +| `drizzle-kit migrate` flaky locally | apply `0023`/`0024` via `psql` or the Railway migration runner instead of `drizzle-kit migrate` | + +## Demo trigger + +`POST /__test/fire-onchain` (production-gated; 404 in `NODE_ENV=production`) forces a `range_breach` alert through the real pipeline for deterministic demos, mirroring what the evaluator does — including the LPUSH-before-INSERT ordering. diff --git a/gitbook-docs/architecture/overview.md b/gitbook-docs/architecture/overview.md index 7c3a2ec0..d3f52ebf 100644 --- a/gitbook-docs/architecture/overview.md +++ b/gitbook-docs/architecture/overview.md @@ -29,7 +29,9 @@ flowchart LR - `packages/shared` Shared enums, slot payload types, channel-mode constants, and the `env-bundle` for cross-package URL resolution. - `packages/cli` - Commander-based CLI. Binary: `distro` (alias: `dtv`). Distributed via curl + GitHub Releases. + Commander-based CLI. Binary: `distro` (alias: `dtv`). Distributed via curl + GitHub Releases. Also hosts the `distro onchain` control surface for CH 03 (local testnet signing key + one-click LP actions). +- `packages/contracts` + Foundry project for `CH 03 ONCHAIN — LP GUARD`: the Uniswap v4 hook (`DistroGuardHook`) with a volatility/size-aware dynamic fee, plus a self-deploy script. Live on X Layer testnet (chainId 1952). See [Onchain LP Guard](onchain-lp-guard.md). - `packages/dashboard` Merged into `frontend/` — there is no separate dashboard package. The dashboard lives at `frontend/app/dashboard/`. @@ -102,3 +104,4 @@ claude hooks fire (PreToolUse / UserPromptSubmit) - `/health` returns `200` when DB is healthy, even if Redis is degraded - news fetcher worker runs on `*/5 * * * *` inside `packages/api/src/worker.ts` - ticker fetcher worker runs on `*/1 * * * *` alongside the news cron +- onchain range-breach evaluator (`runOnchainEvaluation`) runs on `*/1 * * * *` (gated by `ONCHAIN_ENABLED`, registered in `lib/background-jobs.ts`) — **read-only**: reads the live tick per active position via a viem read client and pushes range-breach alerts through the existing alert pipeline; no keeper, no server signing key. See [Onchain LP Guard](onchain-lp-guard.md). diff --git a/gitbook-docs/backend/api.md b/gitbook-docs/backend/api.md index 866e4b2f..a759186d 100644 --- a/gitbook-docs/backend/api.md +++ b/gitbook-docs/backend/api.md @@ -1051,6 +1051,20 @@ Terminal per-item errors → CLI marks `synced_at = -1` (tombstone, stops retryi - `DELETE /me/reading/:id` — 204 on success, 404 if not owned. - `GET /me/news-stats` — returns `{ thisWeek, lastWeek }`. 60s in-memory cache. +## Onchain endpoints (CH 03 — LP GUARD) + +The onchain channel's routes. The server is **read-only on-chain — it never holds a signing key**. Actions return unsigned calldata that the CLI signs + broadcasts. See [Onchain LP Guard](../architecture/onchain-lp-guard.md). + +- `GET /me/onchain/positions` — list the authenticated user's tracked LP positions. +- `POST /me/onchain/positions` — register a position (`poolId`, `tickLower`/`tickUpper`, `walletAddress`, `label`, …). +- `DELETE /me/onchain/positions/:id` — remove a position (scoped to the user). +- `GET /onchain/pools/:poolId` — **public** pool snapshot: `price`, `tick`, live `feeBps`, `volBps`. `feeBpsFromVol` mirrors the on-chain hook fee math off-chain for display (no swap simulation). +- `POST /me/onchain/actions/prepare` — returns **unsigned** swap calldata targeting the swapRouter for the resolved position. **Only `hedge` is implemented**; `exit` / `rebalance` return an error. + +Selection: `GET /me/content/next` with `channelMode === 'onchain_only'` dispatches to `nextOnchainForDevice` — LPOPs a pending alert from the existing alert pipeline key, else builds an `OnchainPayload` from a live `readVol`. + +Demo trigger: `POST /__test/fire-onchain` (production-gated; 404 in `NODE_ENV=production`) forces a `range_breach` alert through the real pipeline. + ## /ingest extension Body now also accepts: diff --git a/gitbook-docs/backend/data-model.md b/gitbook-docs/backend/data-model.md index 4ba92e58..0f153d73 100644 --- a/gitbook-docs/backend/data-model.md +++ b/gitbook-docs/backend/data-model.md @@ -308,9 +308,43 @@ Indexes: `(user_id, saved_at)`, unique `(user_id, news_id)` (idempotent saves). Two added columns: -- `channel_mode text NOT NULL DEFAULT 'balanced'` — `news_only | news_heavy | balanced | ticker_heavy | ticker_only`. Migrated from the legacy `earn / learn / mix` enum in M5. +- `channel_mode text NOT NULL DEFAULT 'balanced'` — `news_only | news_heavy | balanced | ticker_heavy | ticker_only | onchain_only`. Migrated from the legacy `earn / learn / mix` enum in M5. `onchain_only` added by migration `0024_*` (extends the `preferences_channel_mode_check` constraint) for CH 03. - `news_topics text[] NOT NULL DEFAULT '{}'` — future-proofed for v1.1 topic filters +## Onchain (CH 03 — LP GUARD) + +Three tables for the onchain channel (migration `0023_*`). Independent of the ticker/news tables. See [Onchain LP Guard](../architecture/onchain-lp-guard.md). + +### `onchain_pools` + +Pool registry the public snapshot route reads. + +- `pool_id` PK +- `chain_id` +- `hook_address` +- `label` +- `token0`, `token1` +- `decimals` +- `tick_spacing` + +### `onchain_positions` + +A user's tracked LP range. + +- `id` PK +- `user_id` FK → users +- `chain_id` +- `pool_id` +- `position_token_id` (nullable) +- `tick_lower`, `tick_upper` +- `wallet_address` +- `label` +- `status` — `active` rows are the ones actions/eval resolve + +### `onchain_events` + +Fire log for alert debounce — per `(device, type)`, 60-min window. Mirrors the ticker `alert_events` role for the reused alert pipeline. + ## Important Notes - the waitlist table used by `frontend/app/api/waitlist/route.ts` is not part of this Drizzle schema tree diff --git a/gitbook-docs/cli/onchain.md b/gitbook-docs/cli/onchain.md new file mode 100644 index 00000000..2c9b1d39 --- /dev/null +++ b/gitbook-docs/cli/onchain.md @@ -0,0 +1,71 @@ +# CLI: onchain + +`distro onchain` is the terminal control surface for `CH 03 ONCHAIN — LP GUARD`. It manages a local testnet signing key and runs the one-click LP actions that close the advisor loop (monitor → alert → act). See [Onchain LP Guard](../architecture/onchain-lp-guard.md) for the full feature. + +Commands live in `packages/cli/src/commands/onchain.ts`; signing + broadcast in `lib/onchain/clients.ts` + `lib/onchain/actions.ts` (`runAction`). + +## Selecting the channel + +`onchain_only` is a selectable channel mode in the preferences picker (`lib/prompts/preferences.ts`): + +```sh +distro channel onchain # or pick "onchain only" in the prefs picker +``` + +Once selected, `/me/content/next` returns `OnchainPayload` slots and the daemon renders the LP GUARD box (`lib/render-onchain.ts`, routed by `render-line.ts` on `kind === "onchain"`). The alert variant wraps the box in red, mirroring the ticker alert renderer. + +## `distro onchain init [--import ]` + +Creates (or imports) a **local testnet signing key** at `~/.distro/onchain-key.json`, mode `600`. **Testnet only** — this is a hot key for X Layer testnet swaps, never a mainnet wallet. + +```sh +distro onchain init # generate a fresh key +distro onchain init --import 0x... # import an existing 0x private key +``` + +After init, fund the address on X Layer testnet, then select the channel (`distro channel onchain`). Re-running `init` without `--import` is a no-op if a key already exists (prints the address). + +## `distro onchain status` + +Prints the active signer address, or a hint to run `init` if no key exists. + +```sh +distro onchain status +``` + +## `distro onchain hedge | exit | rebalance [--position ]` + +The **one-click action**. It: + +1. Checks a local key exists (else prints `run: distro onchain init`). +2. Resolves the target position — `--position `, else the first `active` position from `GET /me/onchain/positions`. +3. Fetches **unsigned** swap calldata from `POST /me/onchain/actions/prepare`. +4. Signs with the local key and **broadcasts to X Layer**. +5. Prints the tx hash. + +```sh +distro onchain hedge # acts on your first active position +distro onchain hedge --position # target a specific position +``` + +> **`hedge` works end-to-end. `exit` and `rebalance` are wired client-side but the server `actions/prepare` only implements `hedge`** — calling them today returns an error from the server. + +## Design note: the daemon does not capture keystrokes + +The ambient surface **never steals host TUI input**. The daemon publishes a pinned status line; it does not read keys. The `h` / `e` / `r` keys are wired in `lib/daemon/input.ts` and the orchestrator but are **dormant** (key capture is disabled). + +So the terminal "one-click" is the **`distro onchain` subcommand**, not a live keypress — run it directly, or inline inside Claude Code with `!distro onchain hedge`. The keystroke path is reserved for if/when an interactive mode is enabled. + +## `XLAYER_RPC_URL` + +`runAction` needs `XLAYER_RPC_URL` available in the environment to broadcast. Without it the broadcast cannot reach X Layer. + +## Failure modes + +| Symptom | Cause | Fix | +| ------------------------------------------- | ---------------------------------------------- | --------------------------------------------------- | +| `no signing key — run: distro onchain init` | no key at `~/.distro/onchain-key.json` | `distro onchain init` | +| `no active position` | no `active` row from `/me/onchain/positions` | register a position in the dashboard first | +| `exit failed` / `rebalance failed` | server `actions/prepare` only implements hedge | use `hedge`; `exit`/`rebalance` pending server impl | +| broadcast reverts | local key unfunded on X Layer testnet | faucet the address from `distro onchain status` | +| broadcast cannot connect | `XLAYER_RPC_URL` unset | export `XLAYER_RPC_URL` before running the action | diff --git a/packages/api/package.json b/packages/api/package.json index ef061e1a..704e21a9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -38,6 +38,7 @@ "pino": "^9.6.0", "pino-http": "^10.4.0", "postgres": "^3.4.9", + "viem": "^2.51.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index b5ae005a..106b1849 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -28,9 +28,12 @@ import { meActivitySummaryRouter } from "./routes/me-activity-summary.js" import { meNowPlayingRouter } from "./routes/me-now-playing.js" import { cliVersionRouter } from "./routes/cli-version.js" import { meRecentNewsRouter } from "./routes/me-recent-news.js" +import { meOnchainRouter } from "./routes/me-onchain.js" +import { onchainPublicRouter } from "./routes/onchain-public.js" import { ingestRouter } from "./routes/ingest.js" import { adminRouter } from "./routes/admin.js" import { testHelpersRouter } from "./routes/__test-helpers.js" +import { testOnchainRouter } from "./routes/__test-onchain.js" import { requireAuth } from "./middleware/auth.js" import { globalLimiter, userLimiter } from "./middleware/rate-limit.js" import { getDb } from "./db/index.js" @@ -66,12 +69,14 @@ app.use("/health", healthRouter) // at the mount site; the route handler also short-circuits in production. if (env.nodeEnv !== "production") { app.use("/__test", testHelpersRouter) + app.use("/__test", testOnchainRouter) } app.use(globalLimiter) app.use("/channels", channelsPublicRouter) app.use("/tickers", tickersRouter) +app.use("/onchain", onchainPublicRouter) app.use("/cli", cliVersionRouter) app.use("/auth", authRouter) @@ -122,6 +127,7 @@ app.use("/me/alerts", requireAuth, userLimiter, meAlertsRouter) app.use("/me/activity-summary", requireAuth, userLimiter, meActivitySummaryRouter) app.use("/me/devices/:id/now", requireAuth, userLimiter, meNowPlayingRouter) app.use("/me/recent-news", requireAuth, userLimiter, meRecentNewsRouter) +app.use("/me/onchain", requireAuth, userLimiter, meOnchainRouter) app.use("/ingest", requireAuth, userLimiter, ingestRouter) app.use("/admin", adminRouter) diff --git a/packages/api/src/config/onchain.ts b/packages/api/src/config/onchain.ts new file mode 100644 index 00000000..9ed7b8b3 --- /dev/null +++ b/packages/api/src/config/onchain.ts @@ -0,0 +1,3 @@ +export const XLAYER_RPC_URL = process.env["XLAYER_RPC_URL"] ?? "" +export const XLAYER_CHAIN_ID = Number(process.env["XLAYER_CHAIN_ID"] ?? 1952) +export const ONCHAIN_ENABLED = XLAYER_RPC_URL.length > 0 diff --git a/packages/api/src/db/migrations/0023_common_zodiak.sql b/packages/api/src/db/migrations/0023_common_zodiak.sql new file mode 100644 index 00000000..31b7cffb --- /dev/null +++ b/packages/api/src/db/migrations/0023_common_zodiak.sql @@ -0,0 +1,42 @@ +CREATE TABLE "onchain_pools" ( + "pool_id" text PRIMARY KEY NOT NULL, + "chain_id" integer NOT NULL, + "hook_address" text NOT NULL, + "label" text NOT NULL, + "token0" text NOT NULL, + "token1" text NOT NULL, + "token0_decimals" integer DEFAULT 18 NOT NULL, + "token1_decimals" integer DEFAULT 18 NOT NULL, + "tick_spacing" integer NOT NULL, + "meta" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "onchain_positions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "chain_id" integer NOT NULL, + "pool_id" text NOT NULL, + "position_token_id" text, + "tick_lower" integer NOT NULL, + "tick_upper" integer NOT NULL, + "wallet_address" text NOT NULL, + "label" text, + "status" text DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "onchain_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "device_id" uuid, + "position_id" uuid, + "type" text NOT NULL, + "payload" jsonb, + "fired_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "onchain_positions" ADD CONSTRAINT "onchain_positions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "onchain_events" ADD CONSTRAINT "onchain_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "onchain_positions_user_idx" ON "onchain_positions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "onchain_events_device_type_idx" ON "onchain_events" USING btree ("device_id","type","fired_at"); \ No newline at end of file diff --git a/packages/api/src/db/migrations/0024_onchain_channel_mode.sql b/packages/api/src/db/migrations/0024_onchain_channel_mode.sql new file mode 100644 index 00000000..dc5b0abf --- /dev/null +++ b/packages/api/src/db/migrations/0024_onchain_channel_mode.sql @@ -0,0 +1,4 @@ +-- add onchain_only to channel_mode check constraint +ALTER TABLE preferences DROP CONSTRAINT IF EXISTS preferences_channel_mode_check; +ALTER TABLE preferences ADD CONSTRAINT preferences_channel_mode_check + CHECK (channel_mode IN ('news_only', 'news_heavy', 'balanced', 'ticker_heavy', 'ticker_only', 'onchain_only')); diff --git a/packages/api/src/db/migrations/meta/0023_snapshot.json b/packages/api/src/db/migrations/meta/0023_snapshot.json new file mode 100644 index 00000000..ccafb84d --- /dev/null +++ b/packages/api/src/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,1717 @@ +{ + "id": "f8aba32f-d46f-4850-9d61-1ff8ef90bd01", + "prevId": "2f90169b-df77-40de-a386-b1c80a4768a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "repos_count": { + "name": "repos_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "signed_up_at": { + "name": "signed_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id_hash": { + "name": "machine_id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ide_type": { + "name": "ide_type", + "type": "ide_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "device_secret_hash": { + "name": "device_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_machine_idx": { + "name": "devices_user_machine_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "machine_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_categories": { + "name": "blocked_categories", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled_surfaces": { + "name": "enabled_surfaces", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 9999 + }, + "max_per_day": { + "name": "max_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 99999 + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tz_offset_minutes": { + "name": "tz_offset_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idle_sensitivity_ms": { + "name": "idle_sensitivity_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "session_warmup_ms": { + "name": "session_warmup_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "night_mode": { + "name": "night_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "channel_mode": { + "name": "channel_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'balanced'" + }, + "news_topics": { + "name": "news_topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferences_user_id_unique": { + "name": "preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slot_impressions": { + "name": "slot_impressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_url": { + "name": "opened_url", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'news'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slot_impressions_user_id_idx": { + "name": "slot_impressions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slot_impressions_user_created_at_idx": { + "name": "slot_impressions_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slot_impressions_user_id_users_id_fk": { + "name": "slot_impressions_user_id_users_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slot_impressions_device_id_devices_id_fk": { + "name": "slot_impressions_device_id_devices_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_list_items": { + "name": "reading_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_user_saved_idx": { + "name": "reading_user_saved_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "saved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_user_news_unique": { + "name": "reading_user_news_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_list_items_user_id_users_id_fk": { + "name": "reading_list_items_user_id_users_id_fk", + "tableFrom": "reading_list_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_on": { + "name": "default_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_key_uq": { + "name": "channels_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_subscriptions": { + "name": "channel_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_subscriptions_user_idx": { + "name": "channel_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_subscriptions_user_id_users_id_fk": { + "name": "channel_subscriptions_user_id_users_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_subscriptions_channel_id_channels_id_fk": { + "name": "channel_subscriptions_channel_id_channels_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_subscriptions_user_id_channel_id_pk": { + "name": "channel_subscriptions_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_sources": { + "name": "news_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "half_life_hours": { + "name": "half_life_hours", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "fetch_interval_min": { + "name": "fetch_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "healthy": { + "name": "healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_sources_key_uq": { + "name": "news_sources_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_sources_channel_id_channels_id_fk": { + "name": "news_sources_channel_id_channels_id_fk", + "tableFrom": "news_sources", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_items": { + "name": "news_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comments_url": { + "name": "comments_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comments_count": { + "name": "comments_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_items_channel_published_idx": { + "name": "news_items_channel_published_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_items_published_idx": { + "name": "news_items_published_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_items_channel_id_channels_id_fk": { + "name": "news_items_channel_id_channels_id_fk", + "tableFrom": "news_items", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "news_items_source_id_news_sources_id_fk": { + "name": "news_items_source_id_news_sources_id_fk", + "tableFrom": "news_items", + "tableTo": "news_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlists": { + "name": "watchlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlists_user_name_uq": { + "name": "watchlists_user_name_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlists_user_idx": { + "name": "watchlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlists_user_id_users_id_fk": { + "name": "watchlists_user_id_users_id_fk", + "tableFrom": "watchlists", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_tickers": { + "name": "watchlist_tickers", + "schema": "", + "columns": { + "watchlist_id": { + "name": "watchlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_class": { + "name": "asset_class", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_tickers_symbol_idx": { + "name": "watchlist_tickers_symbol_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_tickers_watchlist_id_watchlists_id_fk": { + "name": "watchlist_tickers_watchlist_id_watchlists_id_fk", + "tableFrom": "watchlist_tickers", + "tableTo": "watchlists", + "columnsFrom": ["watchlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_tickers_watchlist_id_symbol_pk": { + "name": "watchlist_tickers_watchlist_id_symbol_pk", + "columns": ["watchlist_id", "symbol"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alerts_user_symbol_uq": { + "name": "alerts_user_symbol_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_global_uq": { + "name": "alerts_user_global_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_idx": { + "name": "alerts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_user_id_users_id_fk": { + "name": "alerts_user_id_users_id_fk", + "tableFrom": "alerts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_events": { + "name": "alert_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_pct": { + "name": "change_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alert_events_device_symbol_fired_idx": { + "name": "alert_events_device_symbol_fired_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_events_user_fired_idx": { + "name": "alert_events_user_fired_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alert_events_user_id_users_id_fk": { + "name": "alert_events_user_id_users_id_fk", + "tableFrom": "alert_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_events_device_id_devices_id_fk": { + "name": "alert_events_device_id_devices_id_fk", + "tableFrom": "alert_events", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onchain_pools": { + "name": "onchain_pools", + "schema": "", + "columns": { + "pool_id": { + "name": "pool_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hook_address": { + "name": "hook_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token0": { + "name": "token0", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token1": { + "name": "token1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token0_decimals": { + "name": "token0_decimals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 18 + }, + "token1_decimals": { + "name": "token1_decimals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 18 + }, + "tick_spacing": { + "name": "tick_spacing", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onchain_positions": { + "name": "onchain_positions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_id": { + "name": "pool_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_token_id": { + "name": "position_token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tick_lower": { + "name": "tick_lower", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tick_upper": { + "name": "tick_upper", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onchain_positions_user_idx": { + "name": "onchain_positions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onchain_positions_user_id_users_id_fk": { + "name": "onchain_positions_user_id_users_id_fk", + "tableFrom": "onchain_positions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onchain_events": { + "name": "onchain_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "position_id": { + "name": "position_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onchain_events_device_type_idx": { + "name": "onchain_events_device_type_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onchain_events_user_id_users_id_fk": { + "name": "onchain_events_user_id_users_id_fk", + "tableFrom": "onchain_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ide_type": { + "name": "ide_type", + "schema": "public", + "values": ["terminal", "vscode", "cursor"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/src/db/migrations/meta/_journal.json b/packages/api/src/db/migrations/meta/_journal.json index bb7b9956..6389fb2c 100644 --- a/packages/api/src/db/migrations/meta/_journal.json +++ b/packages/api/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1779693901246, "tag": "0022_charming_wilson_fisk", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1779727932564, + "tag": "0023_common_zodiak", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema/index.ts b/packages/api/src/db/schema/index.ts index d83f6d7e..a51e5d43 100644 --- a/packages/api/src/db/schema/index.ts +++ b/packages/api/src/db/schema/index.ts @@ -14,3 +14,6 @@ export * from "./watchlist_tickers.js" export * from "./alerts.js" export * from "./alert_events.js" // ticker_symbol_map schema removed (no internal symbol↔provider map needed) +export * from "./onchain_pools.js" +export * from "./onchain_positions.js" +export * from "./onchain_events.js" diff --git a/packages/api/src/db/schema/onchain_events.ts b/packages/api/src/db/schema/onchain_events.ts new file mode 100644 index 00000000..9abaf88f --- /dev/null +++ b/packages/api/src/db/schema/onchain_events.ts @@ -0,0 +1,18 @@ +import { pgTable, uuid, text, jsonb, timestamp, index } from "drizzle-orm/pg-core" +import { users } from "./users.js" + +export const onchainEvents = pgTable( + "onchain_events", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceId: uuid("device_id"), + positionId: uuid("position_id"), + type: text("type").notNull(), + payload: jsonb("payload"), + firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("onchain_events_device_type_idx").on(t.deviceId, t.type, t.firedAt)] +) diff --git a/packages/api/src/db/schema/onchain_pools.ts b/packages/api/src/db/schema/onchain_pools.ts new file mode 100644 index 00000000..81cd6737 --- /dev/null +++ b/packages/api/src/db/schema/onchain_pools.ts @@ -0,0 +1,15 @@ +import { pgTable, text, integer, timestamp, jsonb } from "drizzle-orm/pg-core" + +export const onchainPools = pgTable("onchain_pools", { + poolId: text("pool_id").primaryKey(), + chainId: integer("chain_id").notNull(), + hookAddress: text("hook_address").notNull(), + label: text("label").notNull(), + token0: text("token0").notNull(), + token1: text("token1").notNull(), + token0Decimals: integer("token0_decimals").notNull().default(18), + token1Decimals: integer("token1_decimals").notNull().default(18), + tickSpacing: integer("tick_spacing").notNull(), + meta: jsonb("meta"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}) diff --git a/packages/api/src/db/schema/onchain_positions.ts b/packages/api/src/db/schema/onchain_positions.ts new file mode 100644 index 00000000..93e8c37f --- /dev/null +++ b/packages/api/src/db/schema/onchain_positions.ts @@ -0,0 +1,22 @@ +import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core" +import { users } from "./users.js" + +export const onchainPositions = pgTable( + "onchain_positions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + chainId: integer("chain_id").notNull(), + poolId: text("pool_id").notNull(), + positionTokenId: text("position_token_id"), + tickLower: integer("tick_lower").notNull(), + tickUpper: integer("tick_upper").notNull(), + walletAddress: text("wallet_address").notNull(), + label: text("label"), + status: text("status").notNull().default("active"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index("onchain_positions_user_idx").on(t.userId)] +) diff --git a/packages/api/src/lib/background-jobs.ts b/packages/api/src/lib/background-jobs.ts index 87bce3e7..5ffc4a81 100644 --- a/packages/api/src/lib/background-jobs.ts +++ b/packages/api/src/lib/background-jobs.ts @@ -2,6 +2,8 @@ import cron from "node-cron" import { logger } from "./logger.js" import { runFetchTick } from "../services/news-fetchers/coordinator.js" import { runAlertEvaluation } from "../services/alert-evaluator.service.js" +import { runOnchainEvaluation } from "../services/onchain-evaluator.service.js" +import { ONCHAIN_ENABLED } from "../config/onchain.js" // schedules the periodic jobs: news fetch (populates news_items) every 5 min // with an immediate tick on start, and alert evaluation every 5 min. used by @@ -29,5 +31,17 @@ export function startBackgroundJobs(): void { } }) - logger.info("background jobs scheduled — news fetch every 5 min, alert eval every 5 min") + if (ONCHAIN_ENABLED) { + cron.schedule("*/1 * * * *", async () => { + try { + await runOnchainEvaluation() + } catch (err) { + logger.error({ err }, "onchain eval tick failed") + } + }) + } + + logger.info( + `background jobs scheduled — news fetch every 5 min, alert eval every 5 min${ONCHAIN_ENABLED ? ", onchain eval every 1 min" : ""}` + ) } diff --git a/packages/api/src/lib/onchain-chain.ts b/packages/api/src/lib/onchain-chain.ts new file mode 100644 index 00000000..ae9d7cc3 --- /dev/null +++ b/packages/api/src/lib/onchain-chain.ts @@ -0,0 +1,65 @@ +import { createPublicClient, http, getAddress } from "viem" +import { XLAYER_RPC_URL, XLAYER_CHAIN_ID } from "../config/onchain.js" + +const xlayer = { + id: XLAYER_CHAIN_ID, + name: "X Layer Testnet", + nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 }, + rpcUrls: { default: { http: [XLAYER_RPC_URL] } }, +} as const + +export const publicClient = createPublicClient({ + transport: http(XLAYER_RPC_URL), + chain: xlayer as never, +}) + +const hookAbi = [ + { + type: "function", + name: "volOf", + stateMutability: "view", + inputs: [{ name: "", type: "bytes32" }], + outputs: [ + { name: "lastTick", type: "int24" }, + { name: "ewmaVolBps", type: "uint32" }, + { name: "seeded", type: "bool" }, + ], + }, + { + type: "function", + name: "currentTick", + stateMutability: "view", + inputs: [{ name: "id", type: "bytes32" }], + outputs: [{ name: "tick", type: "int24" }], + }, +] as const + +export async function readVol( + hookAddress: string, + poolId: string +): Promise<{ tick: number; volBps: number }> { + const address = getAddress(hookAddress) + const id = poolId as `0x${string}` + // volBps comes from the ewma accumulator; tick must be the LIVE pool tick + // (currentTick view), not the cached volOf.lastTick. + const [vol, tick] = await Promise.all([ + publicClient.readContract({ + address, + abi: hookAbi, + functionName: "volOf", + args: [id], + }) as Promise, + publicClient.readContract({ + address, + abi: hookAbi, + functionName: "currentTick", + args: [id], + }) as Promise, + ]) + return { tick: Number(tick), volBps: Number(vol[1]) } +} + +// price of token0 in token1 = 1.0001^tick * 10^(dec0 - dec1) +export function priceFromTick(tick: number, dec0: number, dec1: number): number { + return Math.pow(1.0001, tick) * Math.pow(10, dec0 - dec1) +} diff --git a/packages/api/src/lib/onchain-keys.ts b/packages/api/src/lib/onchain-keys.ts new file mode 100644 index 00000000..45d5cd07 --- /dev/null +++ b/packages/api/src/lib/onchain-keys.ts @@ -0,0 +1,3 @@ +// reuse the existing alert pipeline list key so the daemon's LPOP path delivers onchain alerts too +export { pendingAlertsKey } from "./alert-keys.js" +export const onchainSnapshotKey = (poolId: string): string => `onchain:snap:${poolId}` diff --git a/packages/api/src/routes/__test-onchain.ts b/packages/api/src/routes/__test-onchain.ts new file mode 100644 index 00000000..3bbccbe6 --- /dev/null +++ b/packages/api/src/routes/__test-onchain.ts @@ -0,0 +1,30 @@ +import { Router } from "express" +import { getRedis } from "../lib/redis.js" +import { pendingAlertsKey } from "../lib/onchain-keys.js" + +export const testOnchainRouter: ReturnType = Router() + +testOnchainRouter.post("/fire-onchain", async (req, res) => { + if (process.env["NODE_ENV"] === "production") { + res.status(404).end() + return + } + const { deviceId, positionId, message } = req.body as { + deviceId?: unknown + positionId?: unknown + message?: unknown + } + if (typeof deviceId !== "string" || typeof positionId !== "string") { + res.status(400).json({ error: "deviceId and positionId required" }) + return + } + const alert = { + positionId, + type: "range_breach" as const, + message: typeof message === "string" ? message : "demo breach", + firedAt: new Date().toISOString(), + } + await getRedis().lpush(pendingAlertsKey(deviceId), alert) + await getRedis().expire(pendingAlertsKey(deviceId), 3600) + res.json({ ok: true }) +}) diff --git a/packages/api/src/routes/me-content.ts b/packages/api/src/routes/me-content.ts index c560bddb..24b35e0f 100644 --- a/packages/api/src/routes/me-content.ts +++ b/packages/api/src/routes/me-content.ts @@ -6,6 +6,7 @@ import { getDb } from "../db/index.js" import { preferences } from "../db/schema/preferences.js" import { nextPicksForDevice } from "../services/news-selection.service.js" import { nextTickerForDevice } from "../services/ticker-selection.service.js" +import { nextOnchainForDevice } from "../services/onchain-selection.service.js" import { touchDeviceHeartbeat } from "../services/device-heartbeat.service.js" export const meContentRouter: ReturnType = Router() @@ -38,6 +39,11 @@ meContentRouter.get("/next", async (req, res, next) => { let items: SlotPayload[] if (mode === ChannelMode.NewsOnly) { items = await nextPicksForDevice({ userId, deviceId, n }) + } else if (mode === ChannelMode.OnchainOnly) { + // one onchain position renders as a single slot; looping n times just + // re-returned the same first active position (n dup slots + n extra RPC reads). + const p = await nextOnchainForDevice({ userId, deviceId }) + items = p ? [p] : [] } else if (mode === ChannelMode.TickerOnly) { items = await onlyTicker(userId, deviceId, n) } else { diff --git a/packages/api/src/routes/me-onchain.ts b/packages/api/src/routes/me-onchain.ts new file mode 100644 index 00000000..ca57ab97 --- /dev/null +++ b/packages/api/src/routes/me-onchain.ts @@ -0,0 +1,50 @@ +import { Router } from "express" +import { registerPositionSchema, prepareActionSchema } from "../validators/onchain.validators.js" +import { + registerPosition, + listPositions, + deletePosition, +} from "../services/onchain-positions.service.js" +import { prepareAction } from "../services/onchain-actions.service.js" + +export const meOnchainRouter: ReturnType = Router() + +meOnchainRouter.post("/positions", async (req, res, next) => { + try { + const userId = res.locals["userId"] as string + const input = registerPositionSchema.parse(req.body) + const row = await registerPosition(userId, input) + res.status(201).json(row) + } catch (err) { + next(err) + } +}) + +meOnchainRouter.get("/positions", async (_req, res, next) => { + try { + const userId = res.locals["userId"] as string + res.json({ positions: await listPositions(userId) }) + } catch (err) { + next(err) + } +}) + +meOnchainRouter.delete("/positions/:id", async (req, res, next) => { + try { + const userId = res.locals["userId"] as string + await deletePosition(userId, req.params["id"] as string) + res.status(204).end() + } catch (err) { + next(err) + } +}) + +meOnchainRouter.post("/actions/prepare", async (req, res, next) => { + try { + const userId = res.locals["userId"] as string + const input = prepareActionSchema.parse(req.body) + res.json(await prepareAction(userId, input)) + } catch (err) { + next(err) + } +}) diff --git a/packages/api/src/routes/onchain-public.ts b/packages/api/src/routes/onchain-public.ts new file mode 100644 index 00000000..ed8bad3e --- /dev/null +++ b/packages/api/src/routes/onchain-public.ts @@ -0,0 +1,17 @@ +import { Router } from "express" +import { poolSnapshot } from "../services/onchain-snapshot.service.js" + +export const onchainPublicRouter: ReturnType = Router() + +onchainPublicRouter.get("/pools/:poolId", async (req, res, next) => { + try { + const snap = await poolSnapshot(req.params["poolId"] as string) + if (!snap) { + res.status(404).json({ error: "pool_not_found" }) + return + } + res.json(snap) + } catch (err) { + next(err) + } +}) diff --git a/packages/api/src/services/onchain-actions.service.ts b/packages/api/src/services/onchain-actions.service.ts new file mode 100644 index 00000000..ebd82be4 --- /dev/null +++ b/packages/api/src/services/onchain-actions.service.ts @@ -0,0 +1,96 @@ +import { encodeFunctionData, getAddress, type Abi } from "viem" +import { eq } from "drizzle-orm" +import { getDb } from "../db/index.js" +import { onchainPositions } from "../db/schema/onchain_positions.js" +import { onchainPools } from "../db/schema/onchain_pools.js" + +const SWAP_ROUTER = process.env["XLAYER_SWAP_ROUTER"] ?? "" + +// PoolSwapTest.swap(PoolKey, SwapParams, TestSettings, bytes hookData) +const swapRouterAbi: Abi = [ + { + type: "function", + name: "swap", + stateMutability: "payable", + inputs: [ + { + name: "key", + type: "tuple", + components: [ + { name: "currency0", type: "address" }, + { name: "currency1", type: "address" }, + { name: "fee", type: "uint24" }, + { name: "tickSpacing", type: "int24" }, + { name: "hooks", type: "address" }, + ], + }, + { + name: "params", + type: "tuple", + components: [ + { name: "zeroForOne", type: "bool" }, + { name: "amountSpecified", type: "int256" }, + { name: "sqrtPriceLimitX96", type: "uint160" }, + ], + }, + { + name: "testSettings", + type: "tuple", + components: [ + { name: "takeClaims", type: "bool" }, + { name: "settleUsingBurn", type: "bool" }, + ], + }, + { name: "hookData", type: "bytes" }, + ], + outputs: [{ name: "delta", type: "int256" }], + }, +] + +export async function prepareAction( + userId: string, + input: { positionId: string; action: "hedge" | "exit" | "rebalance"; amount?: string } +) { + const db = getDb() + const [pos] = await db + .select() + .from(onchainPositions) + .where(eq(onchainPositions.id, input.positionId)) + .limit(1) + if (!pos || pos.userId !== userId) throw new Error("position_not_found") + + const [pool] = await db + .select() + .from(onchainPools) + .where(eq(onchainPools.poolId, pos.poolId)) + .limit(1) + if (!pool) throw new Error("pool_not_found") + + if (input.action !== "hedge") throw new Error("action_not_implemented") // exit/rebalance deferred + + const amount = BigInt(input.amount ?? "1000000000000000") // 1e15 default (small hedge) + const data = encodeFunctionData({ + abi: swapRouterAbi, + functionName: "swap", + args: [ + { + currency0: getAddress(pool.token0), + currency1: getAddress(pool.token1), + fee: 0x800000, + tickSpacing: pool.tickSpacing, + hooks: getAddress(pool.hookAddress), + }, + { + zeroForOne: true, + amountSpecified: -amount, + sqrtPriceLimitX96: 4295128740n, + }, + { + takeClaims: false, + settleUsingBurn: false, + }, + "0x", + ], + }) + return { chainId: pool.chainId, to: getAddress(SWAP_ROUTER), data, value: "0" } +} diff --git a/packages/api/src/services/onchain-evaluator.service.ts b/packages/api/src/services/onchain-evaluator.service.ts new file mode 100644 index 00000000..96e1124a --- /dev/null +++ b/packages/api/src/services/onchain-evaluator.service.ts @@ -0,0 +1,75 @@ +import { eq, and, gt, sql } from "drizzle-orm" +import { getDb } from "../db/index.js" +import { onchainPositions } from "../db/schema/onchain_positions.js" +import { onchainPools } from "../db/schema/onchain_pools.js" +import { onchainEvents } from "../db/schema/onchain_events.js" +import { devices } from "../db/schema/devices.js" +import { getRedis } from "../lib/redis.js" +import { pendingAlertsKey } from "../lib/onchain-keys.js" +import { readVol } from "../lib/onchain-chain.js" +import { logger } from "../lib/logger.js" + +const PENDING_TTL_SEC = 60 * 60 + +export async function runOnchainEvaluation(): Promise { + const db = getDb() + const positions = await db + .select() + .from(onchainPositions) + .where(eq(onchainPositions.status, "active")) + if (positions.length === 0) return + const redis = getRedis() + + for (const pos of positions) { + try { + const [pool] = await db + .select() + .from(onchainPools) + .where(eq(onchainPools.poolId, pos.poolId)) + .limit(1) + if (!pool) continue + const { tick } = await readVol(pool.hookAddress, pos.poolId) + const breached = tick < pos.tickLower || tick > pos.tickUpper + if (!breached) continue + + const userDevices = await db + .select({ id: devices.id }) + .from(devices) + .where(eq(devices.userId, pos.userId)) + for (const d of userDevices) { + const recent = await db + .select({ id: onchainEvents.id }) + .from(onchainEvents) + .where( + and( + eq(onchainEvents.deviceId, d.id), + eq(onchainEvents.type, "range_breach"), + gt(onchainEvents.firedAt, sql`now() - interval '60 minutes'`) + ) + ) + .limit(1) + if (recent.length > 0) continue + + const alert = { + positionId: pos.id, + type: "range_breach" as const, + message: `price out of range (${pool.label})`, + firedAt: new Date().toISOString(), + } + // lpush + expire FIRST so a redis failure doesn't consume the debounce window + await redis.lpush(pendingAlertsKey(d.id), alert) + await redis.expire(pendingAlertsKey(d.id), PENDING_TTL_SEC) + await db.insert(onchainEvents).values({ + userId: pos.userId, + deviceId: d.id, + positionId: pos.id, + type: "range_breach", + payload: { tick, lower: pos.tickLower, upper: pos.tickUpper }, + }) + logger.info({ positionId: pos.id, deviceId: d.id, tick }, "onchain range_breach fired") + } + } catch (err) { + logger.error({ err: String(err), positionId: pos.id }, "onchain eval failed — continuing") + } + } +} diff --git a/packages/api/src/services/onchain-positions.service.ts b/packages/api/src/services/onchain-positions.service.ts new file mode 100644 index 00000000..dfbf59ad --- /dev/null +++ b/packages/api/src/services/onchain-positions.service.ts @@ -0,0 +1,40 @@ +import { eq, and, asc } from "drizzle-orm" +import { getDb } from "../db/index.js" +import { onchainPositions } from "../db/schema/onchain_positions.js" + +export async function registerPosition( + userId: string, + input: { + chainId: number + poolId: string + positionTokenId?: string + tickLower: number + tickUpper: number + walletAddress: string + label?: string + } +) { + const db = getDb() + const [row] = await db + .insert(onchainPositions) + .values({ userId, ...input }) + .returning() + return row +} + +export async function listPositions(userId: string) { + const db = getDb() + return db + .select() + .from(onchainPositions) + .where(and(eq(onchainPositions.userId, userId), eq(onchainPositions.status, "active"))) + .orderBy(asc(onchainPositions.createdAt)) +} + +export async function deletePosition(userId: string, id: string) { + const db = getDb() + await db + .update(onchainPositions) + .set({ status: "closed" }) + .where(and(eq(onchainPositions.id, id), eq(onchainPositions.userId, userId))) +} diff --git a/packages/api/src/services/onchain-selection.service.ts b/packages/api/src/services/onchain-selection.service.ts new file mode 100644 index 00000000..67b94f55 --- /dev/null +++ b/packages/api/src/services/onchain-selection.service.ts @@ -0,0 +1,75 @@ +import { eq, and, asc } from "drizzle-orm" +import type { OnchainPayload, OnchainAlert } from "@distrotv/shared" +import { getDb } from "../db/index.js" +import { onchainPositions } from "../db/schema/onchain_positions.js" +import { onchainPools } from "../db/schema/onchain_pools.js" +import { getRedis } from "../lib/redis.js" +import { pendingAlertsKey } from "../lib/onchain-keys.js" +import { readVol, priceFromTick } from "../lib/onchain-chain.js" +import { feeBpsFromVol } from "./onchain-snapshot.service.js" + +interface PendingOnchainAlert extends OnchainAlert { + positionId: string +} + +export async function nextOnchainForDevice(args: { + userId: string + deviceId: string +}): Promise { + const db = getDb() + const redis = getRedis() + + const pending = await redis.lpop(pendingAlertsKey(args.deviceId)) + if (pending) { + const payload = await buildPayload(args.userId, pending.positionId) + if (payload) + return { + ...payload, + alert: { type: pending.type, message: pending.message, firedAt: pending.firedAt }, + } + } + + const [pos] = await db + .select() + .from(onchainPositions) + .where(and(eq(onchainPositions.userId, args.userId), eq(onchainPositions.status, "active"))) + .orderBy(asc(onchainPositions.createdAt)) + .limit(1) + if (!pos) return null + return buildPayload(args.userId, pos.id) +} + +async function buildPayload(userId: string, positionId: string): Promise { + const db = getDb() + const [pos] = await db + .select() + .from(onchainPositions) + .where(eq(onchainPositions.id, positionId)) + .limit(1) + if (!pos || pos.userId !== userId) return null + const [pool] = await db + .select() + .from(onchainPools) + .where(eq(onchainPools.poolId, pos.poolId)) + .limit(1) + if (!pool) return null + const { tick, volBps } = await readVol(pool.hookAddress, pos.poolId) + const price = priceFromTick(tick, pool.token0Decimals, pool.token1Decimals) + return { + kind: "onchain", + poolId: pos.poolId, + poolLabel: pool.label, + price, + tick, + rangeLower: pos.tickLower, + rangeUpper: pos.tickUpper, + inRange: tick >= pos.tickLower && tick <= pos.tickUpper, + feeBps: feeBpsFromVol(volBps), + volBps, + feesEarnedUsd: 0, + ilPct: 0, + layout: "single", + asOf: new Date().toISOString(), + positionId: pos.id, + } +} diff --git a/packages/api/src/services/onchain-snapshot.service.ts b/packages/api/src/services/onchain-snapshot.service.ts new file mode 100644 index 00000000..6024591a --- /dev/null +++ b/packages/api/src/services/onchain-snapshot.service.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm" +import { getDb } from "../db/index.js" +import { onchainPools } from "../db/schema/onchain_pools.js" +import { readVol, priceFromTick } from "../lib/onchain-chain.js" + +// hook fee math mirrored off-chain for display: BASE_FEE=3000 pips, VOL_K=50, MAX_FEE=10000 (pips, 1e-6). +const BASE_FEE = 3000, + VOL_K = 50, + MAX_FEE = 10000 + +export function feeBpsFromVol(volBps: number): number { + const pips = Math.min(MAX_FEE, BASE_FEE + volBps * VOL_K) + return Math.round(pips / 100) // pips → bps +} + +export async function poolSnapshot(poolId: string) { + const db = getDb() + const [pool] = await db + .select() + .from(onchainPools) + .where(eq(onchainPools.poolId, poolId)) + .limit(1) + if (!pool) return null + const { tick, volBps } = await readVol(pool.hookAddress, poolId) + return { + poolId, + poolLabel: pool.label, + tick, + price: priceFromTick(tick, pool.token0Decimals, pool.token1Decimals), + volBps, + feeBps: feeBpsFromVol(volBps), + asOf: new Date().toISOString(), + } +} diff --git a/packages/api/src/validators/onchain.validators.ts b/packages/api/src/validators/onchain.validators.ts new file mode 100644 index 00000000..2d826b85 --- /dev/null +++ b/packages/api/src/validators/onchain.validators.ts @@ -0,0 +1,21 @@ +import { z } from "zod" + +const hexAddr = z.string().regex(/^0x[a-fA-F0-9]{40}$/) +const bytes32 = z.string().regex(/^0x[a-fA-F0-9]{64}$/) + +export const registerPositionSchema = z.object({ + chainId: z.number().int().positive(), + poolId: bytes32, + positionTokenId: z.string().optional(), + tickLower: z.number().int(), + tickUpper: z.number().int(), + walletAddress: hexAddr, + label: z.string().max(40).optional(), +}) + +export const prepareActionSchema = z.object({ + positionId: z.string().uuid(), + action: z.enum(["hedge", "exit", "rebalance"]), + amount: z.string().optional(), + slippageBps: z.number().int().min(1).max(5000).optional(), +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index 64e6f706..3df51419 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,8 @@ "cli-table3": "^0.6.5", "commander": "^13.0.0", "qrcode-terminal": "^0.12.0", - "tar": "^7.5.15" + "tar": "^7.5.15", + "viem": "^2.51.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 436878e3..996b67eb 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -336,6 +336,11 @@ export async function runDaemon(): Promise { case "chart": orchestrator.dispatch({ kind: "chart-key", now, tty }) return + case "hedge": + case "exit": + case "rebalance": + orchestrator.onchainAction(action, tty) + return } }, log, diff --git a/packages/cli/src/commands/onchain.ts b/packages/cli/src/commands/onchain.ts new file mode 100644 index 00000000..0a6a5b3f --- /dev/null +++ b/packages/cli/src/commands/onchain.ts @@ -0,0 +1,74 @@ +import { Command } from "commander" +import { hasKey, createKey, loadAccount } from "../lib/onchain/keystore.js" +import { apiFetch } from "../lib/api-client.js" + +type OnchainAction = "hedge" | "exit" | "rebalance" + +export const onchainCmd = new Command("onchain").description( + "LP GUARD — monitor + act on a v4 position" +) + +interface PositionRow { + id: string + status: string + label: string | null +} + +// one-click action: resolve the target position, sign + broadcast via the local key. +// keypress capture is intentionally off in the ambient daemon (never steals host input), +// so the terminal one-click is this subcommand (run directly or via `!distro onchain hedge`). +async function actCmd(action: OnchainAction, opts: { position?: string }): Promise { + if (!hasKey()) { + console.log("no signing key — run: distro onchain init") + return + } + let positionId = opts.position + if (!positionId) { + const { positions } = await apiFetch<{ positions: PositionRow[] }>("/me/onchain/positions", { + method: "GET", + }) + const active = positions.find((p) => p.status === "active") + if (!active) { + console.log("no active position — register one in the dashboard first") + return + } + positionId = active.id + } + console.log(`${action} → position ${positionId} (signer ${loadAccount().address})`) + try { + const { runAction } = await import("../lib/onchain/actions.js") + const hash = await runAction(positionId, action) + console.log(`🔔 ${action} sent · ${hash}`) + } catch (err) { + console.log(`${action} failed: ${(err as Error).message}`) + } +} + +for (const a of ["hedge", "exit", "rebalance"] as const) { + onchainCmd + .command(a) + .description(`${a} your watched LP position — signs + broadcasts on X Layer`) + .option("--position ", "position id (defaults to your first active position)") + .action((opts: { position?: string }) => actCmd(a, opts)) +} + +onchainCmd + .command("init") + .description("create or import a testnet signing key") + .option("--import ", "import an existing 0x private key") + .action((opts: { import?: `0x${string}` }) => { + if (hasKey() && !opts.import) { + console.log(`already configured: ${loadAccount().address}`) + return + } + const addr = createKey(opts.import) + console.log(`onchain key ready: ${addr}`) + console.log(`fund it on X Layer testnet, then: distro channel onchain`) + }) + +onchainCmd + .command("status") + .description("show the active key") + .action(() => { + console.log(hasKey() ? loadAccount().address : "no key — run: distro onchain init") + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad1ebc47..5599e2c6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ import { preferencesCmd } from "./commands/preferences.js" import { watchlistCmd } from "./commands/watchlist.js" import { feedbackCmd } from "./commands/feedback.js" import { logoutCmd } from "./commands/logout.js" +import { onchainCmd } from "./commands/onchain.js" const require = createRequire(import.meta.url) const { version = "0.0.0" } = require("../package.json") as { @@ -53,6 +54,7 @@ program .addCommand(watchlistCmd) .addCommand(feedbackCmd) .addCommand(logoutCmd) + .addCommand(onchainCmd) async function main() { await program.parseAsync() diff --git a/packages/cli/src/lib/daemon/__tests__/input.test.ts b/packages/cli/src/lib/daemon/__tests__/input.test.ts index ed776cbf..0ca4bfac 100644 --- a/packages/cli/src/lib/daemon/__tests__/input.test.ts +++ b/packages/cli/src/lib/daemon/__tests__/input.test.ts @@ -13,6 +13,14 @@ describe("byteToAction", () => { ["M", "mute"], ["b", "save"], ["B", "save"], + ["c", "chart"], + ["C", "chart"], + ["h", "hedge"], + ["H", "hedge"], + ["e", "exit"], + ["E", "exit"], + ["r", "rebalance"], + ["R", "rebalance"], ["\r", "dismiss"], ["\n", "dismiss"], [" ", "dismiss"], diff --git a/packages/cli/src/lib/daemon/input.ts b/packages/cli/src/lib/daemon/input.ts index 26113c6f..f272d561 100644 --- a/packages/cli/src/lib/daemon/input.ts +++ b/packages/cli/src/lib/daemon/input.ts @@ -2,7 +2,17 @@ import { closeSync, constants as fsConstants, openSync } from "node:fs" import { ReadStream } from "node:tty" import type { LoggerApi } from "./orchestrator.js" -export type KeyAction = "discover" | "skip" | "kill" | "mute" | "dismiss" | "save" | "chart" +export type KeyAction = + | "discover" + | "skip" + | "kill" + | "mute" + | "dismiss" + | "save" + | "chart" + | "hedge" + | "exit" + | "rebalance" export interface KeyCapture { start(ttyPath: string): void @@ -55,6 +65,15 @@ export function byteToAction(byte: string): KeyAction | null { case "c": case "C": return "chart" + case "h": + case "H": + return "hedge" + case "e": + case "E": + return "exit" + case "r": + case "R": + return "rebalance" case "\r": case "\n": case " ": diff --git a/packages/cli/src/lib/daemon/orchestrator.ts b/packages/cli/src/lib/daemon/orchestrator.ts index f621cd8a..2416e96e 100644 --- a/packages/cli/src/lib/daemon/orchestrator.ts +++ b/packages/cli/src/lib/daemon/orchestrator.ts @@ -17,6 +17,7 @@ import type { Ledger, LocalNewsImpression } from "../ledger.js" import type { KeyCapture } from "./input.js" import { step, type Effect, type Event, type State } from "./state-machine.js" import { writeNowPlaying } from "../now-playing-writer.js" +import type { OnchainAction } from "../onchain/actions.js" export interface DisplayHandleApi { vanish: () => { latencyMs: number } @@ -57,6 +58,10 @@ export interface OrchestratorDeps { export interface Orchestrator { dispatch(event: Event): void + // hedge/exit/rebalance act on the onchain slot currently showing on `tty`. + // not a state-machine event — like save-key it flashes + side-effects without + // a transition, so it lives outside dispatch(). + onchainAction(action: OnchainAction, tty: string | null): void currentState(): State adsShown(): number hooksReceived(): number @@ -389,6 +394,74 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { applyStep(session, event) } + // [h]edge / [e]xit / [r]ebalance act on the onchain slot currently on `tty`. + // mirrors save-key: no state transition, just flash + a fire-and-forget + // side-effect. ignored (no-op) unless an onchain slot with a positionId is + // showing — same as how chart/save ignore non-applicable slots. + function onchainAction(action: OnchainAction, tty: string | null): void { + const session = resolveSessionForTty(tty) + if (!session) { + deps.log.debug("onchain action skipped — no session", { action, tty }) + return + } + const showing = session.state.kind === "SHOWING" ? session.state : null + const slot = showing?.ad ?? null + if (!slot || slot.kind !== "onchain" || !slot.positionId) { + deps.log.debug("onchain action ignored — no active onchain slot", { action, tty }) + return + } + const positionId = slot.positionId + // flash the box so the keystroke reads as captured, same as save-key. + if (session.currentDisplay) { + try { + session.currentDisplay.flash() + } catch { + /* non-fatal */ + } + } + deps.log.info("onchain action sent", { action, positionId, tty }) + // fire-and-forget: signing + broadcast can take seconds; never block the + // render loop. only `hedge` succeeds server-side today — exit/rebalance + // reject with action_not_implemented, which we surface cleanly. + // dynamic import so the eager viem client in onchain/clients.js is only + // constructed when an action actually fires (it throws at module-load if + // no RPC URL is configured — must not poison the daemon's import graph). + import("../onchain/actions.js") + .then(({ runAction }) => runAction(positionId, action)) + .then((hash) => { + const short = `${hash.slice(0, 6)}…${hash.slice(-4)}` + deps.log.info("onchain action confirmed", { action, positionId, hash }) + deps.log.info(`🔔 ${action} sent · ${short}`) + if (session.currentDisplay) { + try { + session.currentDisplay.flash() + } catch { + /* non-fatal */ + } + } + }) + .catch((err: Error) => { + deps.log.warn(`${action} failed: ${err.message}`, { action, positionId }) + }) + } + + // resolve the session a tty-targeted action belongs to, mirroring the + // non-idle-start path of resolveSession: explicit tty wins, otherwise infer + // the single active session (single-terminal / piped contexts). + function resolveSessionForTty(tty: string | null): Session | null { + if (tty !== null) return getOrCreateSession(ttyKey(tty), tty) + const active: Session[] = [] + for (const s of sessions.values()) { + if (s.state.kind !== "IDLE") active.push(s) + } + if (active.length === 1) return active[0] ?? null + if (sessions.size === 1) { + const first = sessions.values().next().value as Session | undefined + return first ?? null + } + return null + } + function runEffect(session: Session, effect: Effect): void { switch (effect.kind) { case "startGraceTimer": @@ -422,7 +495,12 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } return case "displayAd": { - const payloadId = effect.ad.kind === "news" ? effect.ad.id : effect.ad.symbol + const payloadId = + effect.ad.kind === "news" + ? effect.ad.id + : effect.ad.kind === "ticker" + ? effect.ad.symbol + : effect.ad.poolId if (!effect.tty) { deps.log.warn("display skipped: no tty path", { payloadId }) queueMicrotask(() => dispatch({ kind: "dismiss", now: now(), tty: session.tty })) @@ -748,6 +826,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { return { dispatch, + onchainAction, currentState, adsShown: () => adsShownCount, hooksReceived: () => hooksReceivedCount, diff --git a/packages/cli/src/lib/onchain/actions.ts b/packages/cli/src/lib/onchain/actions.ts new file mode 100644 index 00000000..5663d093 --- /dev/null +++ b/packages/cli/src/lib/onchain/actions.ts @@ -0,0 +1,24 @@ +import { apiFetch } from "../api-client.js" +import { walletClient, publicClient } from "./clients.js" + +export type OnchainAction = "hedge" | "exit" | "rebalance" + +interface PreparedTx { + chainId: number + to: `0x${string}` + data: `0x${string}` + value: string +} + +// fetch unsigned swap calldata, sign with the local keystore key, broadcast, +// and wait for the receipt. returns the tx hash. +export async function runAction(positionId: string, action: OnchainAction): Promise<`0x${string}`> { + const tx = await apiFetch("/me/onchain/actions/prepare", { + method: "POST", + body: { positionId, action }, + }) + const wc = walletClient() + const hash = await wc.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value) }) + await publicClient().waitForTransactionReceipt({ hash }) + return hash +} diff --git a/packages/cli/src/lib/onchain/clients.ts b/packages/cli/src/lib/onchain/clients.ts new file mode 100644 index 00000000..cbec59b3 --- /dev/null +++ b/packages/cli/src/lib/onchain/clients.ts @@ -0,0 +1,20 @@ +import { createPublicClient, createWalletClient, defineChain, http } from "viem" +import { loadAccount } from "./keystore.js" + +const RPC = process.env["XLAYER_RPC_URL"] ?? "" +const CHAIN_ID = Number(process.env["XLAYER_CHAIN_ID"] ?? 1952) + +const xlayer = defineChain({ + id: CHAIN_ID, + name: "X Layer Testnet", + nativeCurrency: { name: "OKB", symbol: "OKB", decimals: 18 }, + rpcUrls: { default: { http: [RPC] } }, +}) + +export function publicClient() { + return createPublicClient({ transport: http(RPC), chain: xlayer }) +} + +export function walletClient() { + return createWalletClient({ account: loadAccount(), transport: http(RPC), chain: xlayer }) +} diff --git a/packages/cli/src/lib/onchain/keystore.ts b/packages/cli/src/lib/onchain/keystore.ts new file mode 100644 index 00000000..3b46d02c --- /dev/null +++ b/packages/cli/src/lib/onchain/keystore.ts @@ -0,0 +1,24 @@ +import { writeFileSync, readFileSync, existsSync, mkdirSync, chmodSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts" + +const DIR = join(homedir(), ".distro") +const KEY_PATH = join(DIR, "onchain-key.json") + +export function hasKey(): boolean { + return existsSync(KEY_PATH) +} + +export function loadAccount() { + const { privateKey } = JSON.parse(readFileSync(KEY_PATH, "utf8")) as { privateKey: `0x${string}` } + return privateKeyToAccount(privateKey) +} + +export function createKey(imported?: `0x${string}`): string { + if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true }) + const privateKey = imported ?? generatePrivateKey() + writeFileSync(KEY_PATH, JSON.stringify({ privateKey }), { mode: 0o600 }) + chmodSync(KEY_PATH, 0o600) + return privateKeyToAccount(privateKey).address +} diff --git a/packages/cli/src/lib/prompts/preferences.ts b/packages/cli/src/lib/prompts/preferences.ts index f9cb30fc..43625efc 100644 --- a/packages/cli/src/lib/prompts/preferences.ts +++ b/packages/cli/src/lib/prompts/preferences.ts @@ -15,6 +15,10 @@ export async function pickChannelMode(current?: ChannelMode): Promise= w) return s + return s + " ".repeat(w - len) +} + +function clampWidth(w: number | undefined): number { + const v = w ?? DEFAULT_WIDTH + if (!Number.isFinite(v)) return DEFAULT_WIDTH + if (v < MIN_WIDTH) return MIN_WIDTH + if (v > MAX_WIDTH) return MAX_WIDTH + return v +} + +function nowClock(): string { + const d = new Date() + const hh = String(d.getHours()).padStart(2, "0") + const mm = String(d.getMinutes()).padStart(2, "0") + return `${hh}:${mm}` +} + +export interface OnchainRenderOpts { + width?: number + color?: ColorMode +} + +export function renderOnchainBox(p: OnchainPayload, opts: OnchainRenderOpts = {}): string { + const width = clampWidth(opts.width) + const mode: ColorMode = opts.color ?? detectColor() + const isAlert = p.alert != null + const accent = isAlert ? "warning" : "indigo" + + // ── header ────────────────────────────────────────────── + const headerLeftWords = + `${color(accent, "▍", mode)} ${color(accent, "lp guard", mode)} ` + + `${color("muted", "·", mode)} ${color("muted", p.poolLabel, mode)}` + const headerLeftPlain = `▍ lp guard · ${p.poolLabel}` + const clock = nowClock() + const headerPad = Math.max(1, width - headerLeftPlain.length - clock.length) + const header = headerLeftWords + " ".repeat(headerPad) + color("muted", clock, mode) + + // ── rules ─────────────────────────────────────────────── + const rule = color("rule", "─".repeat(width), mode) + + // ── price line ────────────────────────────────────────── + const volTok = p.volBps > 50 ? "warning" : "muted" + const priceLine = + ` ${color("fg", `price ${p.price.toFixed(2)}`, mode)}` + + ` ${color("muted", `fee ${p.feeBps}bps`, mode)}` + + ` ${color(volTok, `vol ${p.volBps}`, mode)}` + + // ── range line ────────────────────────────────────────── + const rangeTok = p.inRange ? "positive" : "negative" + const rangeLine = + ` ${color("muted", `range ${p.rangeLower}–${p.rangeUpper}`, mode)}` + + ` ${color(rangeTok, p.inRange ? "in range" : "out of range", mode)}` + + // ── alert line ────────────────────────────────────────── + let alertLine = "" + if (isAlert && p.alert) { + alertLine = ` ${color("warning", "▲ " + p.alert.message, mode)}` + } + + // ── footer ────────────────────────────────────────────── + const footerLine = ` ${color("muted", "[r]ebalance [e]xit [h]edge", mode)}` + + // ── assembly ──────────────────────────────────────────── + const blocks: string[] = [header, rule, "", priceLine, rangeLine, ""] + if (isAlert && alertLine) blocks.push(alertLine, "") + blocks.push(rule, footerLine) + + return blocks.map((l) => padVisible(l, width)).join("\n") +} diff --git a/packages/cli/src/lib/slot-cache.ts b/packages/cli/src/lib/slot-cache.ts index 66e7cd66..839d011d 100644 --- a/packages/cli/src/lib/slot-cache.ts +++ b/packages/cli/src/lib/slot-cache.ts @@ -12,6 +12,8 @@ const RATIO_PATTERNS: Record = { [ChannelMode.Balanced]: ["news", "ticker"], [ChannelMode.TickerHeavy]: ["news", "ticker", "ticker", "ticker"], [ChannelMode.TickerOnly]: ["ticker"], + // onchain slots bypass the news/ticker picker; channel renderer handles them directly + [ChannelMode.OnchainOnly]: [], } export function pickKind(mode: ChannelMode, slotIndex: number): "news" | "ticker" { diff --git a/packages/contracts/.env.example b/packages/contracts/.env.example new file mode 100644 index 00000000..6bc10710 --- /dev/null +++ b/packages/contracts/.env.example @@ -0,0 +1,8 @@ +XLAYER_RPC_URL= +XLAYER_CHAIN_ID= +DEPLOYER_PRIVATE_KEY= +# leave blank to deploy our own in Deploy.s.sol +POOL_MANAGER= +POSITION_MANAGER= +UNIVERSAL_ROUTER= +PERMIT2= diff --git a/packages/contracts/.gitignore b/packages/contracts/.gitignore new file mode 100644 index 00000000..e5c2266f --- /dev/null +++ b/packages/contracts/.gitignore @@ -0,0 +1,6 @@ +out/ +cache/ +.env +broadcast/ +export/addresses.*.json +lib/ diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 00000000..b3ebbe61 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,27 @@ +# @distrotv/contracts + +Uniswap v4 hook (`DistroGuardHook`) + pool deploy scripts for X Layer. + +## Dependencies + +`lib/` is gitignored. Install the pinned deps once after cloning: + +```shell +forge install uniswap/v4-core@v1.0.2 uniswap/v4-periphery@v1.0.4 foundry-rs/forge-std --no-git +``` + +## Build & test + +```shell +forge build +forge test -vvv +``` + +## Deploy (X Layer testnet) + +```shell +cp .env.example .env # fill RPC + key (+ v4 addresses if official ones exist) +forge script script/Deploy.s.sol --rpc-url xlayer --broadcast --ffi +``` + +Outputs `export/addresses..json` consumed by @distrotv/shared. diff --git a/packages/contracts/export/abis/DistroGuardHook.json b/packages/contracts/export/abis/DistroGuardHook.json new file mode 100644 index 00000000..c50c630a --- /dev/null +++ b/packages/contracts/export/abis/DistroGuardHook.json @@ -0,0 +1,915 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_poolManager", + "type": "address", + "internalType": "contract IPoolManager" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "afterAddLiquidity", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "tuple", + "internalType": "struct ModifyLiquidityParams", + "components": [ + { + "name": "tickLower", + "type": "int24", + "internalType": "int24" + }, + { + "name": "tickUpper", + "type": "int24", + "internalType": "int24" + }, + { + "name": "liquidityDelta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "afterDonate", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "afterInitialize", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "key", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "uint160", + "internalType": "uint160" + }, + { + "name": "tick", + "type": "int24", + "internalType": "int24" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "afterRemoveLiquidity", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "tuple", + "internalType": "struct ModifyLiquidityParams", + "components": [ + { + "name": "tickLower", + "type": "int24", + "internalType": "int24" + }, + { + "name": "tickUpper", + "type": "int24", + "internalType": "int24" + }, + { + "name": "liquidityDelta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "afterSwap", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "key", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "tuple", + "internalType": "struct SwapParams", + "components": [ + { + "name": "zeroForOne", + "type": "bool", + "internalType": "bool" + }, + { + "name": "amountSpecified", + "type": "int256", + "internalType": "int256" + }, + { + "name": "sqrtPriceLimitX96", + "type": "uint160", + "internalType": "uint160" + } + ] + }, + { + "name": "", + "type": "int256", + "internalType": "BalanceDelta" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "", + "type": "int128", + "internalType": "int128" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beforeAddLiquidity", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "tuple", + "internalType": "struct ModifyLiquidityParams", + "components": [ + { + "name": "tickLower", + "type": "int24", + "internalType": "int24" + }, + { + "name": "tickUpper", + "type": "int24", + "internalType": "int24" + }, + { + "name": "liquidityDelta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beforeDonate", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beforeInitialize", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "uint160", + "internalType": "uint160" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beforeRemoveLiquidity", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "", + "type": "tuple", + "internalType": "struct ModifyLiquidityParams", + "components": [ + { + "name": "tickLower", + "type": "int24", + "internalType": "int24" + }, + { + "name": "tickUpper", + "type": "int24", + "internalType": "int24" + }, + { + "name": "liquidityDelta", + "type": "int256", + "internalType": "int256" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "beforeSwap", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "key", + "type": "tuple", + "internalType": "struct PoolKey", + "components": [ + { + "name": "currency0", + "type": "address", + "internalType": "Currency" + }, + { + "name": "currency1", + "type": "address", + "internalType": "Currency" + }, + { + "name": "fee", + "type": "uint24", + "internalType": "uint24" + }, + { + "name": "tickSpacing", + "type": "int24", + "internalType": "int24" + }, + { + "name": "hooks", + "type": "address", + "internalType": "contract IHooks" + } + ] + }, + { + "name": "params", + "type": "tuple", + "internalType": "struct SwapParams", + "components": [ + { + "name": "zeroForOne", + "type": "bool", + "internalType": "bool" + }, + { + "name": "amountSpecified", + "type": "int256", + "internalType": "int256" + }, + { + "name": "sqrtPriceLimitX96", + "type": "uint160", + "internalType": "uint160" + } + ] + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes4", + "internalType": "bytes4" + }, + { + "name": "", + "type": "int256", + "internalType": "BeforeSwapDelta" + }, + { + "name": "", + "type": "uint24", + "internalType": "uint24" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "currentTick", + "inputs": [ + { + "name": "id", + "type": "bytes32", + "internalType": "PoolId" + } + ], + "outputs": [ + { + "name": "tick", + "type": "int24", + "internalType": "int24" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "poolManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IPoolManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "volOf", + "inputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "PoolId" + } + ], + "outputs": [ + { + "name": "lastTick", + "type": "int24", + "internalType": "int24" + }, + { + "name": "ewmaVolBps", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "seeded", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "FeeApplied", + "inputs": [ + { + "name": "poolId", + "type": "bytes32", + "indexed": true, + "internalType": "PoolId" + }, + { + "name": "feePips", + "type": "uint24", + "indexed": false, + "internalType": "uint24" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "VolUpdated", + "inputs": [ + { + "name": "poolId", + "type": "bytes32", + "indexed": true, + "internalType": "PoolId" + }, + { + "name": "tick", + "type": "int24", + "indexed": false, + "internalType": "int24" + }, + { + "name": "ewmaVolBps", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "HookNotImplemented", + "inputs": [] + }, + { + "type": "error", + "name": "NotPoolManager", + "inputs": [] + } +] diff --git a/packages/contracts/foundry.toml b/packages/contracts/foundry.toml new file mode 100644 index 00000000..6ce93813 --- /dev/null +++ b/packages/contracts/foundry.toml @@ -0,0 +1,13 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.26" +evm_version = "cancun" +optimizer = true +optimizer_runs = 800 +ffi = true +fs_permissions = [{ access = "read-write", path = "./export" }] + +[rpc_endpoints] +xlayer = "${XLAYER_RPC_URL}" diff --git a/packages/contracts/remappings.txt b/packages/contracts/remappings.txt new file mode 100644 index 00000000..9d1b805d --- /dev/null +++ b/packages/contracts/remappings.txt @@ -0,0 +1,3 @@ +@uniswap/v4-core/=lib/v4-core/ +@uniswap/v4-periphery/=lib/v4-periphery/ +forge-std/=lib/forge-std/src/ diff --git a/packages/contracts/script/Deploy.s.sol b/packages/contracts/script/Deploy.s.sol new file mode 100644 index 00000000..e03900d2 --- /dev/null +++ b/packages/contracts/script/Deploy.s.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolModifyLiquidityTest} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {ModifyLiquidityParams, SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; + +import {DistroGuardHook} from "../src/DistroGuardHook.sol"; +import {MockERC20} from "../src/mocks/MockERC20.sol"; +import {HookMiner} from "./HookMiner.sol"; + +// self-deploys manager + v4-core test routers + mock pair, mines/deploys the hook, +// initializes a dynamic-fee pool, seeds liquidity, runs 2 demo swaps, writes addresses json +contract Deploy is Script { + // canonical create2 proxy (deployed on chain 1952) + address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336; // 1:1 + int24 constant TICK_SPACING = 60; + + struct Deployment { + PoolManager pm; + PoolModifyLiquidityTest modifyLiquidityRouter; + PoolSwapTest swapRouter; + MockERC20 token0; + MockERC20 token1; + DistroGuardHook hook; + } + + function run() external { + uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(pk); + + // simulation-only balance so the broadcast sender can pay gas during sim. + // harmless for the real broadcast (real funding exists then). + vm.deal(deployer, 100 ether); + + vm.startBroadcast(pk); + + Deployment memory d = _deploy(deployer); + PoolKey memory key = _initAndSeed(d); + _demoSwaps(d, key); + + vm.stopBroadcast(); + + PoolId poolId = key.toId(); + _writeAddresses(d, poolId); + + console2.log("hook:", address(d.hook)); + console2.log("poolId:", vm.toString(PoolId.unwrap(poolId))); + console2.log("poolManager:", address(d.pm)); + } + + // core + test routers + mock pair + mined hook + function _deploy(address deployer) internal returns (Deployment memory d) { + // 1. core + test routers (deployer is manager owner) + d.pm = new PoolManager(deployer); + d.modifyLiquidityRouter = new PoolModifyLiquidityTest(IPoolManager(address(d.pm))); + d.swapRouter = new PoolSwapTest(IPoolManager(address(d.pm))); + + // 2. mock pair, sorted so currency0 < currency1 by address + MockERC20 tokenA = new MockERC20("Mock WETH", "mWETH", 18); + MockERC20 tokenB = new MockERC20("Mock USDC", "mUSDC", 18); + (d.token0, d.token1) = address(tokenA) < address(tokenB) ? (tokenA, tokenB) : (tokenB, tokenA); + + uint256 mintAmount = 1_000_000_000 ether; + d.token0.mint(deployer, mintAmount); + d.token1.mint(deployer, mintAmount); + + d.token0.approve(address(d.modifyLiquidityRouter), type(uint256).max); + d.token1.approve(address(d.modifyLiquidityRouter), type(uint256).max); + d.token0.approve(address(d.swapRouter), type(uint256).max); + d.token1.approve(address(d.swapRouter), type(uint256).max); + + // 3. mine + deploy the hook so its address encodes the permission flags + uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG); + (address hookAddr, bytes32 salt) = HookMiner.find( + CREATE2_DEPLOYER, flags, type(DistroGuardHook).creationCode, abi.encode(IPoolManager(address(d.pm))) + ); + d.hook = new DistroGuardHook{salt: salt}(IPoolManager(address(d.pm))); + require(address(d.hook) == hookAddr, "hook addr mismatch"); + } + + // dynamic-fee pool init + seed liquidity + function _initAndSeed(Deployment memory d) internal returns (PoolKey memory key) { + key = PoolKey({ + currency0: Currency.wrap(address(d.token0)), + currency1: Currency.wrap(address(d.token1)), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(d.hook)) + }); + d.pm.initialize(key, SQRT_PRICE_1_1); + + // tickSpacing-aligned range + d.modifyLiquidityRouter.modifyLiquidity( + key, + ModifyLiquidityParams({tickLower: -600, tickUpper: 600, liquidityDelta: 1e18, salt: bytes32(0)}), + "" + ); + } + + // demo swaps exercise beforeSwap/afterSwap (dynamic fee + vol accumulator) + function _demoSwaps(Deployment memory d, PoolKey memory key) internal { + PoolSwapTest.TestSettings memory settings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + + d.swapRouter.swap( + key, + SwapParams({zeroForOne: true, amountSpecified: -1e15, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + settings, + "" + ); + d.swapRouter.swap( + key, + SwapParams({zeroForOne: true, amountSpecified: -1e16, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), + settings, + "" + ); + } + + // persist addresses for the dashboard/cli (gitignored — env-specific) + function _writeAddresses(Deployment memory d, PoolId poolId) internal { + string memory obj = "addresses"; + vm.serializeUint(obj, "chainId", block.chainid); + vm.serializeAddress(obj, "poolManager", address(d.pm)); + vm.serializeAddress(obj, "hook", address(d.hook)); + vm.serializeAddress(obj, "token0", address(d.token0)); + vm.serializeAddress(obj, "token1", address(d.token1)); + vm.serializeInt(obj, "tickSpacing", int256(TICK_SPACING)); + vm.serializeBytes32(obj, "poolId", PoolId.unwrap(poolId)); + vm.serializeAddress(obj, "swapRouter", address(d.swapRouter)); + string memory json = vm.serializeAddress(obj, "modifyLiquidityRouter", address(d.modifyLiquidityRouter)); + + string memory path = string.concat("export/addresses.", vm.toString(block.chainid), ".json"); + vm.writeFile(path, json); + console2.log("wrote:", path); + } +} diff --git a/packages/contracts/script/HookMiner.sol b/packages/contracts/script/HookMiner.sol new file mode 100644 index 00000000..92879937 --- /dev/null +++ b/packages/contracts/script/HookMiner.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library HookMiner { + uint160 constant FLAG_MASK = 0x3FFF; // bottom 14 bits = hook permission flags + uint256 constant MAX_LOOP = 160_444; + + function find(address deployer, uint160 flags, bytes memory creationCode, bytes memory constructorArgs) + internal + view + returns (address hookAddress, bytes32 salt) + { + bytes memory creationCodeWithArgs = abi.encodePacked(creationCode, constructorArgs); + for (uint256 i; i < MAX_LOOP; i++) { + salt = bytes32(i); + hookAddress = computeAddress(deployer, uint256(salt), creationCodeWithArgs); + if (uint160(hookAddress) & FLAG_MASK == flags && hookAddress.code.length == 0) { + return (hookAddress, salt); + } + } + revert("HookMiner: could not find salt"); + } + + function computeAddress(address deployer, uint256 salt, bytes memory creationCode) + internal + pure + returns (address hookAddress) + { + return address( + uint160(uint256(keccak256(abi.encodePacked(bytes1(0xFF), deployer, salt, keccak256(creationCode))))) + ); + } +} diff --git a/packages/contracts/src/DistroGuardHook.sol b/packages/contracts/src/DistroGuardHook.sol new file mode 100644 index 00000000..3bc052a4 --- /dev/null +++ b/packages/contracts/src/DistroGuardHook.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {BaseTestHooks} from "@uniswap/v4-core/src/test/BaseTestHooks.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +// volatility + size-aware dynamic lp fee, computed on-chain from the pool's own swap flow +contract DistroGuardHook is BaseTestHooks { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + // fee pips (1e-6) + uint24 internal constant BASE_FEE = 3000; // 0.30% + uint24 internal constant MAX_FEE = 10000; // 1.00% + uint256 internal constant VOL_K = 50; + uint256 internal constant SIZE_K = 20; + + struct VolState { + int24 lastTick; + uint32 ewmaVolBps; + bool seeded; + } + + IPoolManager public immutable poolManager; + + mapping(PoolId => VolState) public volOf; + + event VolUpdated(PoolId indexed poolId, int24 tick, uint32 ewmaVolBps); + event FeeApplied(PoolId indexed poolId, uint24 feePips); + + error NotPoolManager(); + + modifier onlyPoolManager() { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + _; + } + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + function afterInitialize(address, PoolKey calldata key, uint160, int24 tick) + external + override + onlyPoolManager + returns (bytes4) + { + require(key.fee == LPFeeLibrary.DYNAMIC_FEE_FLAG, "fee:not-dynamic"); + volOf[key.toId()] = VolState(tick, 0, true); + return IHooks.afterInitialize.selector; + } + + function beforeSwap(address, PoolKey calldata key, SwapParams calldata params, bytes calldata) + external + override + onlyPoolManager + returns (bytes4, BeforeSwapDelta, uint24) + { + uint256 absAmount = + params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); + PoolId id = key.toId(); + uint24 fee = _currentFee(id, absAmount); + emit FeeApplied(id, fee); + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee | LPFeeLibrary.OVERRIDE_FEE_FLAG); + } + + function afterSwap(address, PoolKey calldata key, SwapParams calldata, BalanceDelta, bytes calldata) + external + override + onlyPoolManager + returns (bytes4, int128) + { + PoolId id = key.toId(); + (, int24 tick,,) = poolManager.getSlot0(id); + VolState storage v = volOf[id]; + + int24 delta = tick - v.lastTick; + uint32 absBps = uint32(uint256(int256(delta < 0 ? -delta : delta))); + // ewma with alpha = 1/4 + v.ewmaVolBps = uint32((uint256(v.ewmaVolBps) * 3 + absBps) / 4); + v.lastTick = tick; + + emit VolUpdated(id, tick, v.ewmaVolBps); + return (IHooks.afterSwap.selector, int128(0)); + } + + function currentTick(PoolId id) external view returns (int24 tick) { + (, tick,,) = poolManager.getSlot0(id); + } + + function _currentFee(PoolId id, uint256 absAmount) internal view returns (uint24) { + uint256 sizeUnit = absAmount > 1e21 ? absAmount / 1e21 : 0; + uint256 fee = uint256(BASE_FEE) + uint256(volOf[id].ewmaVolBps) * VOL_K + sizeUnit * SIZE_K; + if (fee < BASE_FEE) fee = BASE_FEE; + if (fee > MAX_FEE) fee = MAX_FEE; + return uint24(fee); + } +} diff --git a/packages/contracts/src/mocks/MockERC20.sol b/packages/contracts/src/mocks/MockERC20.sol new file mode 100644 index 00000000..c2f5cac6 --- /dev/null +++ b/packages/contracts/src/mocks/MockERC20.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC20} from "@uniswap/v4-core/lib/solmate/src/tokens/ERC20.sol"; + +// mintable erc20 for the demo pool pair +contract MockERC20 is ERC20 { + constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_, decimals_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/packages/contracts/test/DistroGuardHook.t.sol b/packages/contracts/test/DistroGuardHook.t.sol new file mode 100644 index 00000000..03afd643 --- /dev/null +++ b/packages/contracts/test/DistroGuardHook.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {DistroGuardHook} from "../src/DistroGuardHook.sol"; + +contract DistroGuardHookTest is Test, Deployers { + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + + DistroGuardHook hook; + + function setUp() public { + // deploy manager + routers first so poolManager immutable is valid + deployFreshManagerAndRouters(); + + // construct impl with real poolManager so the immutable is baked in + DistroGuardHook impl = new DistroGuardHook(IPoolManager(address(manager))); + + // compute flagged hook address (must match the flags the hook requires) + address hookAddr = address( + uint160( + uint256(type(uint160).max) & clearAllHookPermissionsMask + | Hooks.AFTER_INITIALIZE_FLAG + | Hooks.BEFORE_SWAP_FLAG + | Hooks.AFTER_SWAP_FLAG + ) + ); + + // etch runtime bytecode — immutable poolManager value is baked into it + vm.etch(hookAddr, address(impl).code); + hook = DistroGuardHook(hookAddr); + + deployMintAndApprove2Currencies(); + (key,) = initPoolAndAddLiquidity( + currency0, currency1, IHooks(address(hook)), LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1 + ); + } + + // afterInitialize must seed the vol state with seeded=true + function test_afterInitialize_seeds_vol() public view { + (,, bool seeded) = hook.volOf(key.toId()); + assertTrue(seeded); + } + + // two sequential swaps: the ewma vol accumulator must not decrease + function test_fee_rises_with_volatility() public { + // small swap — moves tick, seeds the ewma + swap(key, true, -1e15, ZERO_BYTES); + (, uint32 vBefore,) = hook.volOf(key.toId()); + + // larger swap — moves tick further, ewma should be >= previous value + swap(key, true, -1e16, ZERO_BYTES); + (, uint32 vAfter,) = hook.volOf(key.toId()); + + assertGe(vAfter, vBefore); + } + + // a direct external afterSwap (not from the PoolManager) must revert NotPoolManager + function test_afterSwap_direct_reverts() public { + vm.expectRevert(DistroGuardHook.NotPoolManager.selector); + hook.afterSwap( + address(0), + key, + SwapParams({zeroForOne: true, amountSpecified: 0, sqrtPriceLimitX96: 0}), + BalanceDelta.wrap(0), + ZERO_BYTES + ); + } +} diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 09a40cec..8b42e08a 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -102,3 +102,5 @@ export function defaultPreferences(): DevdripPreferences { // alias for legacy callers; safe to remove in M3 export const defaultDevdripPreferences = defaultPreferences + +export * from "./onchain.js" diff --git a/packages/shared/src/constants/onchain.ts b/packages/shared/src/constants/onchain.ts new file mode 100644 index 00000000..66e0132f --- /dev/null +++ b/packages/shared/src/constants/onchain.ts @@ -0,0 +1,15 @@ +// X Layer testnet chain + deployed v4 addresses. Hook/pool addresses come from +// packages/contracts/export/addresses..json after deploy. +export const XLAYER_CHAIN_ID = Number(process.env["XLAYER_CHAIN_ID"] ?? 1952) +export const XLAYER_RPC_URL = process.env["XLAYER_RPC_URL"] ?? "" +export const XLAYER_EXPLORER = + process.env["XLAYER_EXPLORER"] ?? "https://www.oklink.com/xlayer-test" + +export interface OnchainDeployment { + chainId: number + poolManager: `0x${string}` + hook: `0x${string}` + token0: `0x${string}` + token1: `0x${string}` + tickSpacing: number +} diff --git a/packages/shared/src/types/OnchainPayload.ts b/packages/shared/src/types/OnchainPayload.ts new file mode 100644 index 00000000..8bbbe866 --- /dev/null +++ b/packages/shared/src/types/OnchainPayload.ts @@ -0,0 +1,26 @@ +import type { SlotLayout } from "./SlotPayload.js" + +export interface OnchainAlert { + type: "range_breach" | "near_breach" | "vol_spike" + message: string + firedAt: string +} + +export interface OnchainPayload { + kind: "onchain" + poolId: string + poolLabel: string // e.g. "ETH/USDC" + price: number + tick: number + rangeLower: number + rangeUpper: number + inRange: boolean + feeBps: number // current dynamic fee, in bps + volBps: number // hook ewma volatility, in bps + feesEarnedUsd: number + ilPct: number + layout: SlotLayout + asOf: string + positionId?: string + alert?: OnchainAlert +} diff --git a/packages/shared/src/types/SlotPayload.ts b/packages/shared/src/types/SlotPayload.ts index 9e54beeb..b58a68f2 100644 --- a/packages/shared/src/types/SlotPayload.ts +++ b/packages/shared/src/types/SlotPayload.ts @@ -1,11 +1,12 @@ import type { NewsPayload } from "./NewsPayload.js" import type { TickerPayload } from "./TickerPayload.js" +import type { OnchainPayload } from "./OnchainPayload.js" -export type SlotKind = "news" | "ticker" | "sponsored" | "portfolio" +export type SlotKind = "news" | "ticker" | "onchain" | "sponsored" | "portfolio" export type SlotLayout = "single" | "grid" // SlotKind includes "sponsored" and "portfolio" as reserved future kinds. // Their payload types do not exist yet, so a discriminant check on those // values will narrow SlotPayload to `never`. Add the payload types when // these kinds become live. -export type SlotPayload = NewsPayload | TickerPayload +export type SlotPayload = NewsPayload | TickerPayload | OnchainPayload diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8e687adf..1f1587ab 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -25,6 +25,7 @@ export enum ChannelMode { Balanced = "balanced", // 1:1 news:ticker (default; replaces "mix") TickerHeavy = "ticker_heavy", // 1:3 news:ticker TickerOnly = "ticker_only", + OnchainOnly = "onchain_only", } import type { NewsTopic } from "./news.js" @@ -82,6 +83,7 @@ export interface Device { export type { NewsPayload } from "./NewsPayload.js" export type { TickerPayload, TickerStats } from "./TickerPayload.js" export type { SlotPayload, SlotKind, SlotLayout } from "./SlotPayload.js" +export type { OnchainPayload, OnchainAlert } from "./OnchainPayload.js" export type { WatchlistDto, WatchlistTickerDto, AssetClass } from "./WatchlistDto.js" export type { AlertDto, AlertScope, PendingAlert, AlertReplacement } from "./AlertDto.js" export type { ChannelDto } from "./ChannelDto.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3df6c460..82b1713f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: postgres: specifier: ^3.4.9 version: 3.4.9 + viem: + specifier: ^2.51.0 + version: 2.51.0(typescript@5.9.3)(zod@3.25.76) zod: specifier: ^3.24.2 version: 3.25.76 @@ -265,6 +268,9 @@ importers: tar: specifier: ^7.5.15 version: 7.5.15 + viem: + specifier: ^2.51.0 + version: 2.51.0(typescript@5.9.3)(zod@3.25.76) devDependencies: '@types/better-sqlite3': specifier: ^7.6.12 @@ -351,6 +357,9 @@ importers: packages: + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -1182,6 +1191,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -1819,6 +1836,15 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -2249,6 +2275,17 @@ packages: '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3132,6 +3169,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3584,6 +3624,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -4005,6 +4050,15 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: + resolution: {tarball: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a} + version: 0.14.26-386a343.0 + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4867,6 +4921,14 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + viem@2.51.0: + resolution: {integrity: sha512-8C0Ca+eEapXE29vHMUW59NqKENl1X4s9P6xSNC9Nvw6EvAeAhn/LNUlgztk6TOw7KN1Gzz5a/n9Wv4okUfmY9g==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -5057,6 +5119,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5079,6 +5153,8 @@ packages: snapshots: + '@adraffy/ens-normalize@1.11.1': {} + '@alloc/quick-lru@5.2.0': {} '@babel/runtime@7.29.2': {} @@ -5636,6 +5712,12 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.8.0': {} '@nodable/entities@2.1.0': {} @@ -6103,6 +6185,19 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -6628,6 +6723,11 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abitype@1.2.3(typescript@5.9.3)(zod@3.25.76): + optionalDependencies: + typescript: 5.9.3 + zod: 3.25.76 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -7410,8 +7510,8 @@ snapshots: '@typescript-eslint/parser': 8.58.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -7430,7 +7530,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -7441,22 +7541,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7467,7 +7567,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7712,6 +7812,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} expand-template@2.0.3: {} @@ -8194,6 +8296,10 @@ snapshots: isexe@2.0.0: {} + isows@1.0.7(ws@8.20.1): + dependencies: + ws: 8.20.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -8575,6 +8681,21 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -9661,6 +9782,23 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + viem@2.51.0(typescript@5.9.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.20.1) + ox: https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a(typescript@5.9.3)(zod@3.25.76) + ws: 8.20.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-node@2.1.9(@types/node@20.19.39)(lightningcss@1.32.0): dependencies: cac: 6.7.14 @@ -9838,6 +9976,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.1: {} + xtend@4.0.2: {} yallist@5.0.0: {}