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]>
16 KiB
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) andLUX_PRIVATE_KEYis set. The defaultmake migrateis 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 legacygasPrice = 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 lowfor that same (now-consumed) nonce. That is SUCCESS, not a conflict. On anynonce 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
tois 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 exceedsgasPrice, txs would never mine → abort (raiseGAS_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-txlatest > expectedcheck 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:
- Tight band (primary). The executor recomputes the tick from
priceand aborts if it deviates fromexpected_tickby more thanORACLE_TICK_BANDticks (default ±2000 ≈ ±22%;band_ticksoverrides per base). A 10× fat-finger moves the tick ~+23,000 → immediate abort. Proven bypython3 tools/test_red_oracle.py. - Honeypot backstop. The bid must rest strictly below the V3 edge tick, never clone it.
- 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):
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.make snapshot— re-read all positions from the fresh chain (read-only).make plan— reads the frozentools/baseline.json(created on the first evermake plan) and computesremoveLiquidity/targetfrom 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.make migrate(dry-run) — on-chainextsload/NPM.positionsprechecks 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.make cleandeletes onlytools/state.json(the console record). Never deletebaseline.jsonmid-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()), thenmake plan. Classes:MIGRATE_SPOT,MIGRATE_ONESIDED_ORACLE,SKIP_DUST,SKIP_EMPTY. - Migrate a different fraction: the "50%" is
removeLiquidity = originalLiquidity // 2, frozen intotools/baseline.jsonon the firstmake plan. To change it, start fresh: deletetools/baseline.json, thenmake planrecomputes 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
0x9011holds 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.