feat: CH 03 ONCHAIN — LP GUARD (Uniswap v4 hook)#92
Conversation
adds nextOnchainForDevice (redis alert pop → position fallback → live chain read) and wires OnchainOnly mode into GET /me/content/next; migration 0024 extends channel_mode check constraint to include onchain_only.
polls active LP positions every minute (ONCHAIN_ENABLED guard), detects tick-outside-range breaches, pushes PendingOnchainAlert to redis with 60-min per-(device,position) debounce via onchain_events. mirrors alert-evaluator lpush-before-insert ordering. adds /__test/fire-onchain demo endpoint.
…ew; fix stale-tick read, onchain slot dedup, chain-id default
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
hey @claude |
manu-rajput-dodge
left a comment
There was a problem hiding this comment.
Reviewed the PR end to end against the intended CH 03 ONCHAIN / LP Guard flow: deployed v4 hook -> backend pool/position registry + evaluator -> terminal slot/alert -> CLI-signed hedge.
Main blockers I found are inline. The largest issues are that the deployed pool metadata is not reliably present in the API database, the new onchain_only migration is not wired into Drizzle's journal, prepared hedge transactions are hardcoded/unsafe rather than derived from breach direction and slippage, and range-breach alerts have boundary and delivery-loss bugs.
Verification run locally:
pnpm --filter @distrotv/shared typecheckpassedpnpm --filter @distrotv/api typecheckpassedpnpm --filter @distrotv/api testpassedpnpm --filter distrotv typecheckpassedpnpm --filter @distrotv/cli typecheckpassedforge test -vvvinpackages/contractspassedpnpm --filter @distrotv/cli testfailed inhook-timing.smoke.test.tsbecause it invokespackages/cli/dist/index.js; I did not turn that into an inline finding without first separating stale/missing dist artifacts from this PR's source changes.
| "when": 1779727932564, | ||
| "tag": "0023_common_zodiak", | ||
| "breakpoints": true | ||
| } |
There was a problem hiding this comment.
0024_onchain_channel_mode.sql is present as a file, but the Drizzle journal stops at 0023_common_zodiak. packages/api/src/migrate.ts uses Drizzle's migrator, which is driven by this journal, so the onchain_only check-constraint migration will not run on deployed databases. Existing DBs will keep rejecting channel_mode = 'onchain_only' until this migration is added to the journal/snapshot flow or folded into the generated migration set.
| @@ -0,0 +1,42 @@ | |||
| CREATE TABLE "onchain_pools" ( | |||
There was a problem hiding this comment.
This creates the onchain_pools registry, but nothing in the PR seeds the deployed X Layer pool metadata or imports packages/contracts/export/addresses.<chainId>.json. On a fresh deploy the registry is empty, so /onchain/pools/:id returns 404, terminal selection returns no slot for registered positions, the evaluator skips them, and /me/onchain/actions/prepare fails with pool_not_found. The live pool row needs to be part of a migration/seed/startup bootstrap path.
| export const dynamic = "force-dynamic" | ||
|
|
||
| // demo pool — mWETH/mUSDC on base sepolia. prefilled in the register form too. | ||
| const DEMO_POOL_ID = "0xe6d570f5d75318142a44ff50c345bbdca0e57786430d14adb7786d39dec6a2b7" |
There was a problem hiding this comment.
This hardcoded default pool ID does not match the live X Layer pool ID documented in this PR (0x126278a4094f9fa5d6a1494b1d8e60293837f02a2cf1eaf821c8f47dc725afbc in gitbook-docs/architecture/onchain-lp-guard.md). The dashboard snapshot call and registration prefill will point users at a different pool than the backend/deployment docs, and the comment still says Base Sepolia. This should come from the same deployment metadata used to seed onchain_pools.
| meOnchainRouter.post("/positions", async (req, res, next) => { | ||
| try { | ||
| const userId = res.locals["userId"] as string | ||
| const input = registerPositionSchema.parse(req.body) |
There was a problem hiding this comment.
parse() throws ZodError, but the shared error handler only maps ApiError and pg codes. Invalid position/action request bodies therefore become 500 internal_error responses in production instead of 400 validation errors. Use safeParse and throw the existing ValidationError, or teach errorHandler to serialize ZodError consistently.
| .limit(1) | ||
| if (!pool) throw new Error("pool_not_found") | ||
|
|
||
| if (input.action !== "hedge") throw new Error("action_not_implemented") // exit/rebalance deferred |
There was a problem hiding this comment.
These business-rule failures are thrown as plain Errors, so Express forwards them to errorHandler as unknown errors and production clients see 500 internal_error. That means exit/rebalance do not actually surface action_not_implemented cleanly as the PR/docs claim. Use the existing NotFoundError / StateError / ValidationError classes so CLI and dashboard callers get stable status codes and error bodies.
| .limit(1) | ||
| if (!pool) continue | ||
| const { tick } = await readVol(pool.hookAddress, pos.poolId) | ||
| const breached = tick < pos.tickLower || tick > pos.tickUpper |
There was a problem hiding this comment.
Uniswap concentrated-liquidity ranges are active on [tickLower, tickUpper), and the dashboard already uses < tickUpper. With tick > pos.tickUpper, a position exactly at tickUpper is out of range but will not fire a range_breach. This should be tick >= pos.tickUpper and should match the terminal payload logic.
| const db = getDb() | ||
| const redis = getRedis() | ||
|
|
||
| const pending = await redis.lpop<PendingOnchainAlert>(pendingAlertsKey(args.deviceId)) |
There was a problem hiding this comment.
This destructively pops the pending alert before buildPayload performs DB and on-chain RPC reads. If readVol fails transiently, or the pool metadata is temporarily missing, the request errors/returns null and the alert is gone, so the device never sees the breach. Use a non-destructive read plus ack, requeue on failure, or build enough alert payload in the evaluator that delivery does not depend on a fresh RPC call after LPOP.
| tick, | ||
| rangeLower: pos.tickLower, | ||
| rangeUpper: pos.tickUpper, | ||
| inRange: tick >= pos.tickLower && tick <= pos.tickUpper, |
There was a problem hiding this comment.
This has the same upper-bound issue as the evaluator: tick == tickUpper is out of range for Uniswap positions, but the terminal payload marks it inRange: true. Use < pos.tickUpper so alerts, dashboard, and terminal status agree.
| body: { positionId, action }, | ||
| }) | ||
| const wc = walletClient() | ||
| const hash = await wc.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value) }) |
There was a problem hiding this comment.
The server returns chainId, but the CLI never verifies it before broadcasting. If the user's XLAYER_RPC_URL / XLAYER_CHAIN_ID env points at the wrong chain, this signs and sends the prepared calldata to that chain anyway. Check tx.chainId against the configured wallet/public client chain and fail before sendTransaction on mismatch.
| resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} | ||
| engines: {node: '>= 0.4'} | ||
|
|
||
| ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a: |
There was a problem hiding this comment.
The lockfile now resolves ox from a pkg.pr.new tarball. That is an ephemeral PR package host for a wallet/signing dependency in both API/CLI via viem, which is a supply-chain and reproducibility risk for a released CLI. Please regenerate/pin so this resolves from the normal npm registry package/version instead of a PR tarball URL.
# Conflicts: # packages/api/src/app.ts # packages/api/src/lib/background-jobs.ts # packages/api/src/routes/me-content.ts # packages/cli/package.json # packages/cli/src/lib/daemon/orchestrator.ts # pnpm-lock.yaml
Summary
New DistroTV channel CH 03 ONCHAIN — LP GUARD: a Uniswap v4 hook (
DistroGuardHook) that protects LPs on-chain with a volatility + size-aware dynamic swap fee, surfaced and acted on from the terminal. Built for the Build X / "Hook the Future" hackathon (X Layer + Uniswap + Flap).packages/contracts, new Foundry pkg):DistroGuardHook(vol/size-aware dynamic fee,onlyPoolManager-guarded,currentTickview), self-deployingDeploy.s.sol+ vendoredHookMiner.packages/api):onchain_pools/positions/eventstables, viem read client (read-only — no keeper), public pool snapshot, positions CRUD, hedge action calldata,onchain_onlycontent dispatch, and a read-only range-breach evaluator that reuses the existing Redis alert pipeline.packages/cli): LP GUARD slot renderer, local testnet keystore (distro onchain init), and one-clickdistro onchain hedge|exit|rebalance(signs + broadcasts via viem)./dashboard/onchainposition registry + live pool snapshot.architecture/onchain-lp-guard.md,cli/onchain.md, product PRD + feature spec, + updates.Live on X Layer testnet (chain 1952)
0xD8eB6573A3192387dd71c2e646ad23E8DB36d0c0· PoolManager0x822DdD95fC087c840e646F82432BE9e8276AF648· poolId0x126278a4094f9fa5d6a1494b1d8e60293837f02a2cf1eaf821c8f47dc725afbc· swapRouter0x22CE7B3031fEAF5e4af2c056EAA8327965165c69FeeApplied3000→3250); range-breach alerts fire;distro onchain hedgebroadcast a real swap (tx0x263d318a…) that moved the hook's vol.Test plan
forge test -vvvinpackages/contracts(hook permissions, vol response, direct-call revertsNotPoolManager)forge script script/Deploy.s.sol --rpc-url xlayer --broadcastdeploys on X LayerGET /onchain/pools/:id+/me/content/next(onchain_only) return live data; positions CRUD; evaluator firesrange_breachdistro onchain init+distro onchain hedgebroadcasts a swap/dashboard/onchainrenders (authed)Follow-ups
exit/rebalanceactions are stubbed server-side (onlyhedgeexecutes today)