Skip to content

feat: CH 03 ONCHAIN — LP GUARD (Uniswap v4 hook)#92

Open
distroinfinity wants to merge 23 commits into
mainfrom
feature-onchain
Open

feat: CH 03 ONCHAIN — LP GUARD (Uniswap v4 hook)#92
distroinfinity wants to merge 23 commits into
mainfrom
feature-onchain

Conversation

@distroinfinity

Copy link
Copy Markdown
Owner

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).

  • Contracts (packages/contracts, new Foundry pkg): DistroGuardHook (vol/size-aware dynamic fee, onlyPoolManager-guarded, currentTick view), self-deploying Deploy.s.sol + vendored HookMiner.
  • Backend (packages/api): onchain_pools/positions/events tables, viem read client (read-only — no keeper), public pool snapshot, positions CRUD, hedge action calldata, onchain_only content dispatch, and a read-only range-breach evaluator that reuses the existing Redis alert pipeline.
  • CLI (packages/cli): LP GUARD slot renderer, local testnet keystore (distro onchain init), and one-click distro onchain hedge|exit|rebalance (signs + broadcasts via viem).
  • Dashboard: /dashboard/onchain position registry + live pool snapshot.
  • Docs: architecture/onchain-lp-guard.md, cli/onchain.md, product PRD + feature spec, + updates.

Live on X Layer testnet (chain 1952)

  • Hook 0xD8eB6573A3192387dd71c2e646ad23E8DB36d0c0 · PoolManager 0x822DdD95fC087c840e646F82432BE9e8276AF648 · poolId 0x126278a4094f9fa5d6a1494b1d8e60293837f02a2cf1eaf821c8f47dc725afbc · swapRouter 0x22CE7B3031fEAF5e4af2c056EAA8327965165c69
  • Verified: dynamic fee rises with on-chain volatility (FeeApplied 3000→3250); range-breach alerts fire; distro onchain hedge broadcast a real swap (tx 0x263d318a…) that moved the hook's vol.

Test plan

  • forge test -vvv in packages/contracts (hook permissions, vol response, direct-call reverts NotPoolManager)
  • forge script script/Deploy.s.sol --rpc-url xlayer --broadcast deploys on X Layer
  • API: GET /onchain/pools/:id + /me/content/next (onchain_only) return live data; positions CRUD; evaluator fires range_breach
  • CLI: distro onchain init + distro onchain hedge broadcasts a swap
  • /dashboard/onchain renders (authed)

Follow-ups

  • exit/rebalance actions are stubbed server-side (only hedge executes today)
  • daemon keypress capture is dormant by design (one-click is the CLI subcommand, never steals host TUI input)
  • prod API redeploy needed before the live dashboard snapshot works against api.distrotv.xyz

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
@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devdrip Ready Ready Preview, Comment May 28, 2026 4:48pm

@distroinfinity

Copy link
Copy Markdown
Owner Author

hey @claude
review this PR thoroughly:
•⁠ ⁠understand the feature that we are targeting then review the approach and implementation.
•⁠ ⁠review architectural level changes.
•⁠ ⁠review for bugs, unhandled errors, silent failure or dead code.
•⁠ ⁠poor coding practices, anti patterns, code smells that can be refactored or improved.

@manu-rajput-dodge manu-rajput-dodge left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 typecheck passed
  • pnpm --filter @distrotv/api typecheck passed
  • pnpm --filter @distrotv/api test passed
  • pnpm --filter distrotv typecheck passed
  • pnpm --filter @distrotv/cli typecheck passed
  • forge test -vvv in packages/contracts passed
  • pnpm --filter @distrotv/cli test failed in hook-timing.smoke.test.ts because it invokes packages/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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pnpm-lock.yaml
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}

ox@https://pkg.pr.new/ox@386a3439fe1ce76d237930f8c6e6bb493746069a:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants