Files
zeekayandHanzo Dev 33b43f3682 v3v4: decomplect the V3→V4 migration tooling (Rob Pike pass) + canonical RUNBOOK
The migration EXECUTED on mainnet 2026-07-05 (V4 PoolManager
0x2e317c5ce2c3e3aa720a3bb7f366f5959d940d4c, LiquidityDeployer
0x9888015bd7cda1905bbebf560f800731772957fd handed to the DAO Safe). Logic is
proven — this only removes cruft, unifies one way to do each thing, and rewrites
the docs. No proven behavior changed; verified by re-running the full pipeline.

Deleted / simplified:
- execute.py: dead `_await_pending_nonce` (never called); dead `ZERO` const;
  dead `Q96` import; inline `from v4math import liquidity_for_amount0` hoisted to
  the top import.
- Env surface trimmed to the genuinely operator-tuned knobs. Dropped never-set
  overrides: PLAN/STATE (now HERE-relative), READ_POD (= RPC_PODS[1], removing a
  vacuous len>1 guard), V4CORE_ART/LD_ART (now derived via the contracts/lib/v4-core
  symlink — no ~/work absolute path to keep in sync), snapshot OUT/RPC/TRANSPORT,
  investigate hardcoded ctx. Kept: DRY_RUN/EXECUTE, LUX_PRIVATE_KEY, GAS_PRICE_GWEI,
  SLIPPAGE_BPS, ORACLE_TICK_BAND, ONESIDED_MAX_WLUX, ONESIDED_BAND_TICKS, FINALIZE,
  ORACLE, KCTX/KNS, RPC_PODS.
- snapshot.py: collapsed the dual http/kubectl transport to kubectl-exec only,
  matching execute.py + investigate_all.py (one transport, one way); dropped the
  urllib path and the redundant `except (DecodingError, Exception)`.
- investigate_all.py: honor KCTX/KNS/KPOD from env (the Makefile already passes them).
- v4math.py: hoist `import decimal, math` to module top.
- Makefile: drop the now-dead TRANSPORT=kubectl from the snapshot target.

Load-bearing live fixes LEFT INTACT (why the run worked, do not weaken):
no-gossip nonce reconcile (_our_tx_mined), latest-based drift detection (not
pending), checksummed tx `to`, 5-validator broadcast at ≥250 gwei.

Docs:
- RUNBOOK.md rewritten as the single canonical, deterministic replay guide:
  EXECUTED status + live addresses, chain-heartbeat suspend requirement, the
  one-way pipeline (snapshot→plan→test→migrate dry-run→migrate→finalize),
  post-reboot re-run, surviving-env table, one-sided oracle dual-control.
- v3v4/LLM.md refreshed (concise agent guide, CLAUDE.md symlinks to it).
- top-level LLM.md: added a pointer/status line for v3v4 (distinct from Quasar).

Verified after cleanup: make test 16/16, python3 tools/test_red_oracle.py PASS,
and make snapshot && make plan && make migrate (dry-run) reconciles clean against
live chain (deploys + all steps report already-done, no broadcast).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-05 10:16:32 -07:00

16 KiB
Raw Permalink Blame History

Lux mainnet V3 → V4 liquidity migration — RUNBOOK

Scope: move HALF of the DAO treasury's Uniswap-V3 LP liquidity on Lux C-Chain (chainId 96369) into freshly-deployed Uniswap-V4 pools. This is a DEX LP move only — it has nothing to do with the Quasar network migration in ../CLAUDE.md. Everything derives from live chain state; every run is idempotent and re-runnable, including after a full network RLP reboot.

No transaction is broadcast unless DRY_RUN=0 (EXECUTE=1) and LUX_PRIVATE_KEY is set. The default make migrate is a dry-run: it simulates every step and prints the exact staged plan, broadcasting nothing.


0. Status — EXECUTED on Lux mainnet 2026-07-05

The migration ran successfully against chainId 96369. These are the current live contracts; re-running the tooling reconciles against them (every step's on-chain precheck reports "already done → skip").

What Address / value
V4 PoolManager 0x2e317c5ce2c3e3aa720a3bb7f366f5959d940d4c (owner = treasury)
LiquidityDeployer (LD) 0x9888015bd7cda1905bbebf560f800731772957fd — ownership transferred to the DAO Safe
Treasury / deployer EOA 0x9011E888251AB053B7bD1cdB598Db4f9DED94714
DAO Safe (final LD owner) 0x51284dc2133e8d3a8e213dca6a6fa768cfdfcce2
gasPrice / broadcast 250 gwei, fanned to all 5 validators (luxd-0..4)
Post-run state LD approvals zeroed; LD owned by the DAO Safe (finalize ran)

Migrated (50% of each live treasury position), ~71M WLUX + 1.434B LZOO total:

Pool Class Migrated into V4 V4 init
WLUX/LZOO MIGRATE_SPOT 65,941,975.39 WLUX + 1,434,223,151.30 LZOO V3 spot tick 30797
LSOL/WLUX MIGRATE_SPOT 0.5173 LSOL + 41,017.05 WLUX V3 spot tick 125284
LPOL/WLUX MIGRATE_SPOT 1.3223 LPOL + 3,498.88 WLUX V3 spot tick 79006
LBTC/WLUX MIGRATE_ONESIDED_ORACLE 5,000,000 WLUX resting bid oracle tick 85207, bid [78300,85200]
LAVAX/WLUX MIGRATE_ONESIDED_ORACLE 10,000 WLUX resting bid oracle tick −6136, bid [−13080,−6180]
LAVAX/LUSD SKIP_DUST — (6th non-empty pool; negligible) —

The live tx hashes + per-pool amounts are recorded in tools/state.json (gitignored; regenerated by every run). state.json is a convenience record for the console UI — correctness never depends on it (all prechecks are direct on-chain reads).


1. Before every run — SUSPEND the chain-heartbeat cronjob

The treasury EOA 0x9011 is actively signed by the chain-heartbeat cronjob in namespace lux-mainnet (schedule */5 * * * *). If it fires mid-migration it consumes the treasury's next nonce, and the executor's concurrent-signer guard will (correctly) abort. Suspend it for the duration of any broadcast run and resume it after:

CTX=do-sfo3-lux-k8s; NS=lux-mainnet
kubectl --context $CTX -n $NS patch cronjob chain-heartbeat -p '{"spec":{"suspend":true}}'
# ... run the migration ...
kubectl --context $CTX -n $NS patch cronjob chain-heartbeat -p '{"spec":{"suspend":false}}'

Dry-runs never sign, so this is only strictly required for DRY_RUN=0. Verify it is suspended before broadcasting: kubectl -n $NS get cronjob chain-heartbeat → SUSPEND=True.


2. The one and only path

make snapshot   →   make plan   →   make test   →   make migrate   →   make migrate DRY_RUN=0   →   … FINALIZE=1
   (read chain)     (classify)      (contract)      (dry-run)            (broadcast)                (hand LD to DAO)
make setup                 # one-time: populate pinned v4-core build deps (see §7)
make snapshot              # read all treasury V3 positions from live 96369 -> tools/snapshot.json
make plan                  # classify + accounting table + FREEZE tools/baseline.json -> tools/plan.json
make test                  # foundry: 16/16 (unit + 256-run fuzz + mirror-rounding invariant)
cp tools/oracle.example.json tools/oracle.json   # then set LIVE price + expected_tick (§5)
make migrate               # DRY-RUN: prints every staged tx + finalize preview, broadcasts nothing

# broadcast (after review, with heartbeat suspended per §1):
LUX_PRIVATE_KEY=$(kubectl --context do-sfo3-lux-k8s -n lux-mainnet \
  get secret lux-deployer -o jsonpath='{.data.LUX_PRIVATE_KEY}' | base64 -d) \
  make migrate DRY_RUN=0

# after verifying the pools, hand LD to the DAO Safe + drop residual approvals:
LUX_PRIVATE_KEY=$(...) make migrate DRY_RUN=0 FINALIZE=1

Everything reads live chain — nothing is hardcoded. Re-running is safe: each step has an on-chain idempotency precheck, so an interrupted run resumes exactly where it stopped.


3. What the executor does (tools/execute.py)

Numbered steps; each has an on-chain precheck so completed work is skipped:

STEP 1  deploy V4 PoolManager        (skip if code at the computed CREATE address)
STEP 2  deploy LiquidityDeployer      (skip if code at the computed CREATE address)
per MIGRATE pool:
  decreaseLiquidity(50%) each pos     (skip if NPM position liquidity <= frozen target)
  collect(max) each pos -> treasury   (skip if tokensOwed == 0)
  approve token0/token1 -> LD         (skip if allowance >= need)
  initAndAdd (first) / add (rest)     (skip if V4 position liquidity >= target)
FINALIZE (gated, FINALIZE=1):
  zero each token approval treasury->LD, then transferOwnership LD -> DAO Safe

Idempotency reads are direct storage reads (PoolManager.extsload for pool-init + position liquidity, NPM.positions for V3) — so correctness does not depend on any local file. initAndAdd sets the price and adds liquidity in one tx, closing the front-run window between init and add.

Why the broadcast path looks the way it does (load-bearing — do not weaken)

Lux mainnet's mempool does not gossip. Four behaviors follow directly and are the reason the live run succeeded:

  • Broadcast every signed tx to all 5 validators (RPC_PODS=luxd-0..4) at legacy gasPrice = 250 gwei. A flat-baseFee tx never mines (does not cover block gas cost); a single-validator submit may sit unmined because peers never learn of it.
  • No-gossip nonce reconcile. A tx can MINE via one validator and then a different validator reports nonce too low for that same (now-consumed) nonce. That is SUCCESS, not a conflict. On any nonce too low/high, the executor first checks whether our own txhash already mined (_our_tx_mined, polling all validators); if so it advances and continues. Only if a different tx took the nonce (our hash absent AND latest > ours) does it abort as a real concurrent signer.
  • Drift detection reads the LATEST (mined) nonce, not pending. Pending is unreliable on a no-gossip chain — our just-broadcast tx may sit in one node's mempool while another node's pending still reads low, which would false-abort against our own in-flight tx. The only real hazard is a tx we did not send mining at/above our next nonce → latest > expected.
  • Signed-tx to is checksummed (to_checksum_address) so no validator rejects on a bad address encoding.

Execute-mode preflights (abort before STEP 1)

  • Base fee affordable — reads live baseFeePerGas; if it exceeds gasPrice, txs would never mine → abort (raise GAS_PRICE_GWEI).
  • No concurrent signer — reads the treasury pending nonce twice 3s apart; if it moved, another process is signing 0x9011 (see §1) → abort. This is the coarse early warning; the per-tx latest > expected check above is the authoritative guard.

V3 → V4 rounding

Re-adding the removed liquidity L needs ≤ a couple wei more than the decrease freed (V3 rounds down, V4 rounds up). Approvals carry a SLIPPAGE_BPS (1%) buffer and the per-position amount*Max cap bounds the pull. Proven by test_MirrorRoundingDirection (gap ≤ 2 wei) and the testFuzz_AddRemoveNoResidual fuzz.


4. Contracts

Contract Source Constructor
PoolManager (v4-core) contracts/lib/v4-core (symlink → ~/work/lux-amm/v4-core) (address initialOwner) = treasury
LiquidityDeployer contracts/src/LiquidityDeployer.sol (IPoolManager, address owner) = (PoolManager, treasury)

LiquidityDeployer is a minimal, owner-gated IUnlockCallback helper: holds no idle balances (add pulls funder→manager; remove pushes manager→recipient), amount*Max caps on add / amount*Min floors on remove, ERC20-only (reverts on native), atomic initAndAdd, transferOwnership, and a rescue escape hatch. No delegatecall, upgradeability, or selfdestruct.

Reproducible build: solc 0.8.26, evm_version=cancun, via_ir=true, optimizer_runs=44444444, bytecode_hash=none; deps pinned in scripts/setup.sh. make test → 16/16 (RED-approved).


5. One-sided oracle pools (dual-control)

A pool that is ~100% WLUX sits at a range edge where the scarce base is priced absurdly (LBTC at ~116,000,000 WLUX each; LAVAX at ~1e38). Cloning that edge into a fresh V4 pool with real WLUX is a honeypot — anyone holding a wei of the base drains the WLUX. Instead, V4 is initialized at the oracle (fair) price and the WLUX is placed as a resting BID ladder BELOW oracle: it can only be filled by someone selling the base at ≤ fair price, so there is no free arbitrage.

tools/oracle.json is dual-controlled — for each one-sided base it carries both a price (base USD, operator A, from the live Kraken mid) and an expected_tick (the tick operator B derives independently from their own read of that mid). Never derive expected_tick from price — the independence is the control.

{
  "LUX_USD": 12.5,
  "LBTC":  { "price": 62692.9, "expected_tick": 85207 },
  "LAVAX": { "price": 6.768,   "expected_tick": -6136, "band_ticks": 2000 }
}

Three fail-secure guards, evaluated before any V3 liquidity is touched:

  1. Tight band (primary). The executor recomputes the tick from price and aborts if it deviates from expected_tick by more than ORACLE_TICK_BAND ticks (default ±2000 ≈ ±22%; band_ticks overrides per base). A 10× fat-finger moves the tick ~+23,000 → immediate abort. Proven by python3 tools/test_red_oracle.py.
  2. Honeypot backstop. The bid must rest strictly below the V3 edge tick, never clone it.
  3. Hard cap. ONESIDED_MAX_WLUX (default 5,000,000e18) bounds the WLUX any single one-sided pool can ever risk — a scaling bug cannot exceed it.

A one-sided base with no oracle entry is skipped (fail-secure); a malformed, out-of-band, or over-cap entry aborts the run. LUX_USD is the WLUX peg (LETH/LUX = ETHUSD/12.5 ⇒ LUX_USD = 12.5). Bid-ladder width = ONESIDED_BAND_TICKS (default 6900).


6. Environment (the only knobs)

Set on the make command line; the Makefile forwards them. Anything not listed is derived from live chain or fixed by the code — there are no other environment inputs.

Var Default Meaning
DRY_RUN 1 make-level switch; 0 broadcasts (derives EXECUTE=1)
LUX_PRIVATE_KEY — treasury signer; required only when DRY_RUN=0
GAS_PRICE_GWEI 250 legacy gasPrice; must exceed baseFee or txs never mine
SLIPPAGE_BPS 100 min-out / max-in tolerance (1%)
ORACLE_TICK_BAND 2000 max |tick − expected_tick| for a one-sided bid
ONESIDED_MAX_WLUX 5000000e18 hard cap on WLUX per one-sided pool
ONESIDED_BAND_TICKS 6900 resting-bid ladder width
FINALIZE 0 1 → zero LD approvals + transferOwnership LD → DAO Safe
ORACLE tools/oracle.json one-sided oracle path (dual-control schema, §5)
KCTX do-sfo3-lux-k8s kube context
KNS lux-mainnet kube namespace
RPC_PODS luxd-0..luxd-4 the validators every tx is broadcast to (no gossip)

Reads use kubectl exec into a validator (no port-forward). Contract-artifact paths are derived from the repo layout (v4-core via the contracts/lib/v4-core symlink), not env.


7. Deterministic replay after a network RLP reboot (and mid-migration re-plan)

The network can be rebooted from the RLP archive (see ../CLAUDE.md). Re-running the migration afterward is safe because the 50% target is frozen in tools/baseline.json, not re-derived from live liquidity. The trap this avoids: if a migration partially ran (V3 liquidity already halved) and you regenerate the plan from a fresh snapshot, a naive builder reads the halved liquidity as the new baseline and removes 50% of the remainder → 75% gone. The frozen baseline prevents that.

After the chain is live again (heartbeat suspended per §1 if you intend to broadcast):

  1. make setup — one-time: clone v4-core's pinned build deps and build the PoolManager artifact (cd contracts/lib/v4-core && forge build src/PoolManager.sol). Deterministic bytecode reproduces the same PoolManager address only if the treasury nonce also matches.
  2. make snapshot — re-read all positions from the fresh chain (read-only).
  3. make plan — reads the frozen tools/baseline.json (created on the first ever make plan) and computes removeLiquidity/target from the original liquidity, never from the re-snapshot. Positions already at/below their baseline target print "already migrated"; the executor's on-chain precheck skips their decrease. Regenerating the plan mid-migration is therefore idempotent — it can only ever remove the original 50%, once.
  4. make migrate (dry-run) — on-chain extsload/NPM.positions prechecks detect completed steps and skip them; if the reboot rewound state, it starts fresh. Either way it reconciles against exactly what the chain currently shows.
  5. make clean deletes only tools/state.json (the console record). Never delete baseline.json mid-migration — it is the double-decrease guard. Delete it only to begin a brand-new migration (a deliberate fresh 50% cut of whatever remains).

8. Editing the policy / adding pools

  • Change what migrates: edit the thresholds/classes in tools/build_plan.py (classify()), then make plan. Classes: MIGRATE_SPOT, MIGRATE_ONESIDED_ORACLE, SKIP_DUST, SKIP_EMPTY.
  • Migrate a different fraction: the "50%" is removeLiquidity = originalLiquidity // 2, frozen into tools/baseline.json on the first make plan. To change it, start fresh: delete tools/baseline.json, then make plan recomputes the baseline from current live liquidity. (Do not hand-edit a live baseline — §7.)
  • Add a pool: it appears automatically once the treasury owns a liquid position in it (snapshot enumerates all owned NFTs). No code change.

9. Rollback (V4 → V3 reverse path)

LiquidityDeployer.remove(RemoveParams) is the exact inverse of add: it burns the V4 position (same key/ticks/salt) and takes the tokens to a recipient, with amount*Min floors. To reverse: (1) remove each V4 position (salts are bytes32(tokenId) for spot pools, bytes32(0) for one-sided bids — recorded in state.json); (2) re-increaseLiquidity on the original V3 NFTs (still treasury-owned, same ranges). A reverse script is intentionally not auto-run — LD ownership is with the DAO Safe, so any unwind is a Safe-governed action.


10. Findings (from the on-chain investigation, make investigate)

  • Treasury 0x9011 holds 73 V3 NFTs, only 9 with liquidity>0; the other 64 are empty. The DAO Safe owns 0 NFTs. The treasury (not the Safe) is therefore the migration source.
  • LETH could not be migrated. The WLUX/LETH pool's only two liquid NFTs (id103, id104) are owned by an external address 0x982942…, not the treasury; all treasury WLUX/LETH NFTs are empty and the treasury holds 0 LETH. There is no treasury-controlled path to move LETH without that external LP's key.

11. Supersedes

The root-level ../v3-to-v4-migrate.sh + ../v3-to-v4-withdraw.json assumed DAO-Safe-owned positions with a 100%-withdraw + burn flow against a local fork. Live chain contradicts that (the Safe owns 0 NFTs; the treasury owns all 73; the owner wanted 50% migrated). This toolkit reflects live reality and is canonical.