mirror of
https://github.com/luxfi/migration.git
synced 2026-10-30 15:25:07 +00:00
681 lines
34 KiB
Python
681 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Staged, idempotent Lux V3->V4 liquidity migration executor.
|
|
|
|
DRY-RUN by default (prints the exact staged plan, simulates every independent
|
|
step, writes state.json). Broadcasts to ALL 5 validator RPCs at >= GAS_PRICE_GWEI
|
|
only when EXECUTE=1 (the mainnet mempool does not gossip, so each tx is pushed to
|
|
every validator; flat-baseFee txs never mine).
|
|
|
|
Every decision derives from LIVE chain state (nonce, code, extsload pool/position
|
|
slots, NPM positions, ERC20 balances/allowances) -> re-running after any point,
|
|
including a full network RLP reboot, resumes exactly where it left off.
|
|
|
|
NOTHING here is Lux-network-migration (Quasar) related; this is the DEX LP move.
|
|
|
|
Env (all optional; the Makefile wires the operator-tunable ones):
|
|
EXECUTE=1 broadcast for real (default 0 = dry-run)
|
|
LUX_PRIVATE_KEY signer key (execute mode only; from k8s secret lux-deployer)
|
|
GAS_PRICE_GWEI=250 legacy gasPrice; must exceed baseFee or txs never mine (preflight aborts)
|
|
SLIPPAGE_BPS=100 min-out / max-in tolerance (1%)
|
|
ONESIDED_BAND_TICKS=6900 width of the one-sided resting-bid ladder
|
|
ORACLE_TICK_BAND=2000 max |oracleTick - expected_tick| for a one-sided bid (~+/-22%)
|
|
ONESIDED_MAX_WLUX=5000000e18 hard cap on WLUX deposited into any one one-sided pool
|
|
FINALIZE=1 opt-in: after all adds, zero LD approvals + transferOwnership LD->DAO Safe.
|
|
Dry-run always PREVIEWS it; execute performs it only when =1.
|
|
KCTX,KNS kube context / namespace
|
|
RPC_PODS=luxd-0,..,luxd-4 the validators every tx is broadcast to (mainnet has no gossip)
|
|
ORACLE=oracle.json per one-sided base an OBJECT {"price":<baseUSD>,"expected_tick":<2nd-op tick>},
|
|
plus "LUX_USD". expected_tick is a SECOND operator's independent anchor.
|
|
"""
|
|
import os, sys, json, subprocess, time
|
|
from eth_abi import encode as abi_encode, decode as abi_decode
|
|
from eth_utils import keccak, to_checksum_address
|
|
from eth_account import Account
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
from v4math import (get_sqrt_ratio_at_tick, get_tick_at_sqrt_price, sqrt_price_x96_from_price,
|
|
align_tick, liquidity_for_amount0, liquidity_for_amount1, get_amounts_for_liquidity)
|
|
|
|
# ---- config -----------------------------------------------------------------
|
|
CHAIN_ID = 96369
|
|
KCTX = os.environ.get("KCTX", "do-sfo3-lux-k8s")
|
|
KNS = os.environ.get("KNS", "lux-mainnet")
|
|
RPC_PODS = os.environ.get("RPC_PODS", "luxd-0,luxd-1,luxd-2,luxd-3,luxd-4").split(",")
|
|
READ_POD = RPC_PODS[1] # reads hit one validator; every signed tx still fans out to all of RPC_PODS
|
|
GAS_PRICE_WEI = int(float(os.environ.get("GAS_PRICE_GWEI", "250")) * 1e9)
|
|
EXECUTE = os.environ.get("EXECUTE", "0") == "1"
|
|
SLIPPAGE_BPS = int(os.environ.get("SLIPPAGE_BPS", "100"))
|
|
ONESIDED_BAND_TICKS = int(os.environ.get("ONESIDED_BAND_TICKS", "6900"))
|
|
# one-sided oracle safety: reject if the oracle-derived tick strays > this many ticks
|
|
# from the operator's independently-recorded expected_tick (2000 ticks ~= +/-22% price).
|
|
ORACLE_TICK_BAND = int(os.environ.get("ORACLE_TICK_BAND", "2000"))
|
|
# hard ceiling on WLUX put into any single one-sided pool (a bug can't scale the bid).
|
|
ONESIDED_MAX_WLUX = int(float(os.environ.get("ONESIDED_MAX_WLUX", str(5_000_000 * 10**18))))
|
|
# opt-in (gated): after all adds, zero LD approvals + hand LD ownership to the DAO Safe.
|
|
FINALIZE = os.environ.get("FINALIZE", "0") == "1"
|
|
ORACLE = os.environ.get("ORACLE", os.path.join(HERE, "oracle.json"))
|
|
PLAN = os.path.join(HERE, "plan.json")
|
|
STATE = os.path.join(HERE, "state.json")
|
|
OWNER = "0x9011E888251AB053B7bD1cdB598Db4f9DED94714"
|
|
DAO_SAFE = "0x51284dc2133e8d3a8e213dca6a6fa768cfdfcce2"
|
|
NPM = "0x7a4C48B9dae0b7c396569b34042fcA604150Ee28"
|
|
# Deployable bytecode, from the canonical build locations. v4-core is reached via the
|
|
# contracts/lib/v4-core symlink, so there is no absolute ~/work path to keep in sync.
|
|
V4CORE_ART = os.path.join(HERE, "..", "contracts", "lib", "v4-core", "out",
|
|
"PoolManager.sol", "PoolManager.json")
|
|
LD_ART = os.path.join(HERE, "..", "contracts", "out",
|
|
"LiquidityDeployer.sol", "LiquidityDeployer.json")
|
|
DEADLINE = 1 << 63
|
|
U128MAX = (1 << 128) - 1
|
|
|
|
# ---- rpc via kubectl exec (works without port-forward) ----------------------
|
|
_id = [0]
|
|
def _kexec(pod, body):
|
|
p = subprocess.run(["kubectl", "--context", KCTX, "-n", KNS, "exec", "-i", pod, "--",
|
|
"curl", "-s", "-X", "POST", "http://localhost:9630/v1/chain/C/rpc",
|
|
"-H", "content-type:application/json", "-d", "@-"],
|
|
input=body, capture_output=True, timeout=200)
|
|
if p.returncode != 0:
|
|
raise RuntimeError(f"kubectl exec {pod}: {p.stderr.decode()[:200]}")
|
|
return json.loads(p.stdout)
|
|
|
|
def rpc(method, params, pod=None):
|
|
_id[0] += 1
|
|
r = _kexec(pod or READ_POD, json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": method, "params": params}).encode())
|
|
if "error" in r:
|
|
raise RuntimeError(f"{method}: {r['error']}")
|
|
return r["result"]
|
|
|
|
def sel(sig):
|
|
return keccak(text=sig)[:4]
|
|
|
|
def call_raw(to, data_hex, frm=OWNER):
|
|
return rpc("eth_call", [{"to": to, "from": frm, "data": data_hex}, "latest"])
|
|
|
|
def estimate(to, data_hex, value=0, create=False):
|
|
tx = {"from": OWNER, "data": data_hex, "value": hex(value)}
|
|
if not create:
|
|
tx["to"] = to_checksum_address(to)
|
|
return int(rpc("eth_estimateGas", [tx]), 16)
|
|
|
|
# ---- abi encoders -----------------------------------------------------------
|
|
AP_TYPE = "((address,address,uint24,int24,address),int24,int24,uint128,bytes32,address,uint128,uint128)"
|
|
|
|
def _pk_tuple(pk):
|
|
return (pk['currency0'], pk['currency1'], pk['fee'], pk['tickSpacing'], pk['hooks'])
|
|
|
|
def enc_decrease(tid, liq, a0min, a1min):
|
|
return "0x" + sel("decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))").hex() \
|
|
+ abi_encode(["(uint256,uint128,uint256,uint256,uint256)"], [(tid, liq, a0min, a1min, DEADLINE)]).hex()
|
|
|
|
def enc_collect(tid, recipient):
|
|
return "0x" + sel("collect((uint256,address,uint128,uint128))").hex() \
|
|
+ abi_encode(["(uint256,address,uint128,uint128)"], [(tid, recipient, U128MAX, U128MAX)]).hex()
|
|
|
|
def enc_initialize(pk, sqrtP):
|
|
return "0x" + sel("initialize((address,address,uint24,int24,address),uint160)").hex() \
|
|
+ abi_encode(["(address,address,uint24,int24,address)", "uint160"], [_pk_tuple(pk), sqrtP]).hex()
|
|
|
|
def enc_approve(spender, amount):
|
|
return "0x" + sel("approve(address,uint256)").hex() + abi_encode(["address", "uint256"], [spender, amount]).hex()
|
|
|
|
def enc_transfer_ownership(next_owner):
|
|
return "0x" + sel("transferOwnership(address)").hex() + abi_encode(["address"], [next_owner]).hex()
|
|
|
|
def ld_owner(LD):
|
|
return "0x" + call_raw(LD, "0x" + sel("owner()").hex())[-40:]
|
|
|
|
def _ap(pk, tl, tu, liq, salt, funder, max0, max1):
|
|
return (_pk_tuple(pk), tl, tu, liq, salt, funder, max0, max1)
|
|
|
|
def enc_add(pk, tl, tu, liq, salt, funder, max0, max1):
|
|
return "0x" + sel("add(" + AP_TYPE + ")").hex() + abi_encode([AP_TYPE], [_ap(pk, tl, tu, liq, salt, funder, max0, max1)]).hex()
|
|
|
|
def enc_initadd(pk, sqrtP, tl, tu, liq, salt, funder, max0, max1):
|
|
return "0x" + sel("initAndAdd(uint160," + AP_TYPE + ")").hex() \
|
|
+ abi_encode(["uint160", AP_TYPE], [sqrtP, _ap(pk, tl, tu, liq, salt, funder, max0, max1)]).hex()
|
|
|
|
# ---- reads: NPM, ERC20, extsload -------------------------------------------
|
|
PTYPES = ["uint96", "address", "address", "address", "uint24", "int24", "int24", "uint128", "uint256", "uint256", "uint128", "uint128"]
|
|
|
|
def npm_position(tid):
|
|
raw = call_raw(NPM, "0x" + sel("positions(uint256)").hex() + abi_encode(["uint256"], [tid]).hex())
|
|
return abi_decode(PTYPES, bytes.fromhex(raw[2:]))
|
|
|
|
def erc20_balance(token, who):
|
|
return int(call_raw(token, "0x" + sel("balanceOf(address)").hex() + abi_encode(["address"], [who]).hex()), 16)
|
|
|
|
def erc20_allowance(token, own, spender):
|
|
return int(call_raw(token, "0x" + sel("allowance(address,address)").hex() + abi_encode(["address", "address"], [own, spender]).hex()), 16)
|
|
|
|
def pool_id(pk):
|
|
return keccak(abi_encode(["address", "address", "uint24", "int24", "address"], list(_pk_tuple(pk))))
|
|
|
|
def _extsload(manager, slot: bytes):
|
|
data = "0x" + sel("extsload(bytes32)").hex() + slot.hex()
|
|
return bytes.fromhex(call_raw(manager, data)[2:])
|
|
|
|
def pool_sqrtprice(manager, pk):
|
|
ss = keccak(pool_id(pk) + (6).to_bytes(32, "big"))
|
|
return int.from_bytes(_extsload(manager, ss), "big") & ((1 << 160) - 1)
|
|
|
|
def pos_liquidity(manager, pk, own, tl, tu, salt: bytes):
|
|
ss = keccak(pool_id(pk) + (6).to_bytes(32, "big"))
|
|
pmap = (int.from_bytes(ss, "big") + 6).to_bytes(32, "big")
|
|
pid = keccak(bytes.fromhex(own[2:]) + (tl & 0xFFFFFF).to_bytes(3, "big") + (tu & 0xFFFFFF).to_bytes(3, "big") + salt)
|
|
slot = keccak(pid + pmap)
|
|
return int.from_bytes(_extsload(manager, slot), "big") & ((1 << 128) - 1)
|
|
|
|
# ---- create-address (rlp[sender,nonce]) ------------------------------------
|
|
def create_address(sender, nonce):
|
|
s = bytes.fromhex(sender[2:])
|
|
def eb(b):
|
|
if len(b) == 1 and b[0] < 0x80:
|
|
return b
|
|
return bytes([0x80 + len(b)]) + b
|
|
def ei(n):
|
|
return b"\x80" if n == 0 else eb(n.to_bytes((n.bit_length() + 7) // 8, "big"))
|
|
payload = eb(s) + ei(nonce)
|
|
out = bytes([0xc0 + len(payload)]) + payload
|
|
return "0x" + keccak(out)[12:].hex()
|
|
|
|
# ---- signing + 5-RPC broadcast ---------------------------------------------
|
|
# The treasury EOA 0x9011 is known to be actively signed by other processes (RUNBOOK 0:
|
|
# nonce moved 748->757 during prep). Two signers racing the same nonce => one tx is
|
|
# silently dropped. So: (a) re-derive the live pending nonce before EVERY tx and refuse
|
|
# on drift; (b) never mask a nonce error as "sent" — that stalled the 240s receipt wait
|
|
# on a tx no validator actually holds.
|
|
NONCE = [None] # expected nonce for the next tx; seeded at preflight, +1 per broadcast
|
|
BENIGN_SEND = ("already known", "alreadyknown", "already imported", "already exists",
|
|
"known transaction", "replacement transaction underpriced")
|
|
FATAL_NONCE = ("nonce too low", "nonce too high", "invalid nonce", "nonce is too")
|
|
|
|
def _read_nonce(block="pending"):
|
|
return int(rpc("eth_getTransactionCount", [OWNER, block]), 16)
|
|
|
|
def _nonce():
|
|
if NONCE[0] is None:
|
|
NONCE[0] = _read_nonce("pending")
|
|
if not EXECUTE:
|
|
return NONCE[0] # dry-run: never signs; used only for CREATE-address prediction
|
|
expected = NONCE[0]
|
|
# Concurrent-signer detection via the LATEST (mined) nonce, NOT pending. On a no-gossip
|
|
# chain our just-broadcast tx may sit in ONE node's mempool while READ_POD's pending nonce
|
|
# still reads lower — so a pending==expected equality check false-aborts against our OWN
|
|
# in-flight txs. The real hazard is a tx WE DID NOT SEND mining at/above our next nonce:
|
|
# that only happens if another process signs 0x9011. latest > expected catches exactly that.
|
|
latest = _read_nonce("latest")
|
|
if latest > expected:
|
|
raise RuntimeError(
|
|
f"NONCE DRIFT: {OWNER} latest MINED nonce {latest} > our next {expected}. A "
|
|
f"concurrent signer took our nonce — PAUSE it (suspend chain-heartbeat) and re-run "
|
|
f"(idempotent; completed steps skip).")
|
|
return expected
|
|
|
|
def _our_tx_mined(txhash, tries=6, delay=2):
|
|
"""Poll every validator for OUR tx's receipt (no-gossip: it may be on any one node).
|
|
Returns True if it mined with status 1. Short poll — the tx either already mined
|
|
(that is why a peer said nonce-low) or it did not."""
|
|
for _ in range(tries):
|
|
for pod in RPC_PODS:
|
|
try:
|
|
r = rpc("eth_getTransactionReceipt", [txhash], pod=pod)
|
|
if r:
|
|
if int(r["status"], 16) != 1:
|
|
raise RuntimeError(f"tx {txhash} REVERTED (status 0)")
|
|
return True
|
|
except RuntimeError as e:
|
|
if "REVERTED" in str(e):
|
|
raise
|
|
time.sleep(delay)
|
|
return False
|
|
|
|
def sign_and_broadcast(to, data_hex, gas, value=0, create=False):
|
|
key = os.environ["LUX_PRIVATE_KEY"]
|
|
nonce = _nonce()
|
|
tx = {"nonce": nonce, "gasPrice": GAS_PRICE_WEI, "gas": gas, "value": value,
|
|
"data": data_hex, "chainId": CHAIN_ID}
|
|
if not create:
|
|
tx["to"] = to_checksum_address(to)
|
|
signed = Account.sign_transaction(tx, key)
|
|
raw = "0x" + signed.raw_transaction.hex()
|
|
txhash = "0x" + keccak(signed.raw_transaction).hex()
|
|
sent = 0
|
|
for pod in RPC_PODS:
|
|
try:
|
|
rpc("eth_sendRawTransaction", [raw], pod=pod)
|
|
sent += 1
|
|
except RuntimeError as e:
|
|
msg = str(e).lower()
|
|
if any(k in msg for k in FATAL_NONCE):
|
|
# NO-GOSSIP RECONCILE: on a chain whose mempool does not gossip we broadcast to
|
|
# ALL validators; a tx can MINE via one node and then a LATER node reports "nonce
|
|
# too low" for the SAME (now-consumed) nonce — that is SUCCESS, not a conflict.
|
|
# Distinguish: (a) OUR txhash already mined -> success; (b) a DIFFERENT tx took our
|
|
# nonce (our txhash absent AND latest nonce > ours) -> real concurrent signer, abort.
|
|
if _our_tx_mined(txhash):
|
|
print(f" {pod} reports nonce-low but our tx {txhash} MINED — success")
|
|
NONCE[0] = nonce + 1
|
|
return txhash
|
|
live_latest = _read_nonce("latest")
|
|
if live_latest > nonce and sent == 0:
|
|
raise RuntimeError(
|
|
f"FATAL nonce conflict on tx {txhash} (nonce={nonce}): a DIFFERENT tx took "
|
|
f"this nonce (latest={live_latest}, our tx not mined). Concurrent signer on "
|
|
f"0x9011 — pause it and re-run (idempotent).")
|
|
# else: this pod is just lagging (our tx is in flight elsewhere) — skip it.
|
|
print(f" broadcast {pod}: nonce-low but tx in flight elsewhere — skipping pod")
|
|
continue
|
|
if any(k in msg for k in BENIGN_SEND):
|
|
sent += 1 # already in this validator's pool == accepted
|
|
else:
|
|
print(f" broadcast {pod}: {str(e)[:120]}")
|
|
if sent == 0:
|
|
# last check: did it mine anyway (accepted+mined between our sends)?
|
|
if _our_tx_mined(txhash):
|
|
NONCE[0] = nonce + 1
|
|
return txhash
|
|
raise RuntimeError(
|
|
f"tx {txhash} (nonce={nonce}) accepted by 0/{len(RPC_PODS)} validators; not "
|
|
f"waiting on a tx no validator holds. Check nonce / gasPrice vs baseFee.")
|
|
NONCE[0] = nonce + 1
|
|
print(f" broadcast {sent}/{len(RPC_PODS)} validators tx={txhash} nonce={nonce}")
|
|
return txhash
|
|
|
|
# ---- broadcast-safety preflights (EXECUTE only) -----------------------------
|
|
def preflight_gas():
|
|
"""Abort if the live base fee already exceeds our legacy gasPrice — such txs never
|
|
mine and would only stall the receipt wait."""
|
|
blk = rpc("eth_getBlockByNumber", ["latest", False])
|
|
base_fee = int(blk.get("baseFeePerGas", "0x0"), 16)
|
|
if base_fee > GAS_PRICE_WEI:
|
|
print(f"FATAL: baseFee {base_fee/1e9:.2f} gwei > gasPrice {GAS_PRICE_WEI/1e9:.2f} "
|
|
f"gwei; txs would never mine. Raise GAS_PRICE_GWEI. Aborting.")
|
|
sys.exit(5)
|
|
print(f"preflight: baseFee {base_fee/1e9:.2f} gwei <= gasPrice {GAS_PRICE_WEI/1e9:.2f} gwei OK")
|
|
return base_fee
|
|
|
|
def preflight_nonce_stability():
|
|
"""Detect a concurrent signer on 0x9011 before we start: read the pending nonce twice
|
|
3s apart; abort if it moved. Returns the stable nonce to seed the counter."""
|
|
a = _read_nonce("pending")
|
|
time.sleep(3)
|
|
b = _read_nonce("pending")
|
|
if a != b:
|
|
print(f"FATAL: treasury {OWNER} pending nonce moved {a} -> {b} in 3s. Another process "
|
|
f"is signing 0x9011 — PAUSE it, then re-run (idempotent).")
|
|
sys.exit(4)
|
|
print(f"preflight: treasury nonce stable at {b} (no concurrent signer) OK")
|
|
return b
|
|
|
|
def wait_receipt(txhash, timeout=240):
|
|
t0 = time.time()
|
|
while time.time() - t0 < timeout:
|
|
for pod in RPC_PODS:
|
|
try:
|
|
r = rpc("eth_getTransactionReceipt", [txhash], pod=pod)
|
|
if r:
|
|
if int(r["status"], 16) != 1:
|
|
raise RuntimeError(f"tx {txhash} REVERTED")
|
|
return r
|
|
except RuntimeError as e:
|
|
if "REVERTED" in str(e):
|
|
raise
|
|
time.sleep(4)
|
|
raise TimeoutError(f"receipt {txhash} not mined in {timeout}s")
|
|
|
|
# ---- state.json (stable schema for the console UI) --------------------------
|
|
def load_state():
|
|
if os.path.exists(STATE):
|
|
st = json.load(open(STATE))
|
|
# normalize: one record per pool (older runs appended a fresh copy each run).
|
|
seen = {}
|
|
for p in st.get("pools", []):
|
|
seen[p.get("v4PoolId")] = p
|
|
st["pools"] = list(seen.values())
|
|
return st
|
|
return {"chainId": CHAIN_ID, "mode": None, "contracts": {}, "txs": [], "pools": []}
|
|
|
|
def save_state(st):
|
|
st["updatedAt"] = int(time.time())
|
|
st["mode"] = "execute" if EXECUTE else "dry-run"
|
|
st["gasPriceGwei"] = GAS_PRICE_WEI // 10**9
|
|
json.dump(st, open(STATE, "w"), indent=2)
|
|
|
|
ST = load_state()
|
|
def record_tx(label, txhash, to):
|
|
ST["txs"].append({"label": label, "hash": txhash, "to": to, "ts": int(time.time())})
|
|
save_state(ST)
|
|
|
|
def record_pool(entry):
|
|
"""Upsert a pool record by v4PoolId (idempotent — re-runs replace, never append)."""
|
|
pools = ST.setdefault("pools", [])
|
|
for i, p in enumerate(pools):
|
|
if p.get("v4PoolId") == entry["v4PoolId"]:
|
|
pools[i] = entry
|
|
save_state(ST)
|
|
return
|
|
pools.append(entry)
|
|
save_state(ST)
|
|
|
|
# ---- step engine ------------------------------------------------------------
|
|
def do_step(tag, label, to, data_hex, done_fn, value=0):
|
|
print(f" [{tag}] {label}")
|
|
try:
|
|
if done_fn():
|
|
print(" already done -> skip")
|
|
return None
|
|
except Exception as e:
|
|
print(f" precheck read failed: {str(e)[:120]} (continuing)")
|
|
# simulate/estimate
|
|
gas = None
|
|
try:
|
|
gas = int(estimate(to, data_hex, value=value) * 1.3)
|
|
except RuntimeError as e:
|
|
if not EXECUTE:
|
|
print(f" [dry-run] PLANNED (unsimulated: {str(e)[:80]}) to={to} data={data_hex[:26]}..")
|
|
return None
|
|
raise
|
|
if not EXECUTE:
|
|
print(f" [dry-run] to={to} gas~{gas} gasPrice={GAS_PRICE_WEI//10**9}gwei data={data_hex[:26]}..")
|
|
return None
|
|
txhash = sign_and_broadcast(to, data_hex, gas, value)
|
|
wait_receipt(txhash)
|
|
if not done_fn():
|
|
raise RuntimeError(f"{label}: post-state check FALSE after mining {txhash}")
|
|
record_tx(label, txhash, to)
|
|
print(f" confirmed {txhash}")
|
|
return txhash
|
|
|
|
def do_create(tag, label, key, bytecode, ctor_hex, code_addr_state_key):
|
|
print(f" [{tag}] {label}")
|
|
addr = ST["contracts"].get(code_addr_state_key)
|
|
if addr and rpc("eth_getCode", [addr, "latest"]) not in ("0x", "0x0"):
|
|
print(f" already deployed at {addr} -> skip")
|
|
return addr
|
|
data = "0x" + bytecode.replace("0x", "") + ctor_hex.replace("0x", "")
|
|
gas = int(estimate(None, data, create=True) * 1.3)
|
|
predicted = create_address(OWNER, _nonce())
|
|
if not EXECUTE:
|
|
print(f" [dry-run] CREATE gas~{gas} predicted={predicted}")
|
|
return predicted
|
|
txhash = sign_and_broadcast(None, data, gas, create=True)
|
|
wait_receipt(txhash)
|
|
if rpc("eth_getCode", [predicted, "latest"]) in ("0x", "0x0"):
|
|
raise RuntimeError(f"{label}: no code at {predicted}")
|
|
ST["contracts"][code_addr_state_key] = predicted
|
|
record_tx(label, txhash, predicted)
|
|
print(f" deployed at {predicted}")
|
|
return predicted
|
|
|
|
def assert_pool_price(PM, pk, expected_sqrtP, label):
|
|
"""If a pool is already initialized (possibly by a third party), refuse to add
|
|
unless its price matches ours within tolerance — defends against front-run-init
|
|
griefing that would make us deposit at an attacker-chosen price."""
|
|
try:
|
|
actual = pool_sqrtprice(PM, pk)
|
|
except Exception:
|
|
return # PM not deployed yet (dry-run) -> nothing to check
|
|
if actual == 0:
|
|
return # not initialized; we will initialize it
|
|
if abs(actual - expected_sqrtP) * 10000 > expected_sqrtP * SLIPPAGE_BPS:
|
|
raise RuntimeError(
|
|
f"{label}: pool already initialized at sqrtP={actual} != expected {expected_sqrtP} "
|
|
f"(>{SLIPPAGE_BPS}bps). Refusing to add at a foreign price. Investigate before proceeding.")
|
|
|
|
def bps_down(x):
|
|
return x * (10000 - SLIPPAGE_BPS) // 10000
|
|
|
|
def bps_up(x):
|
|
return x * (10000 + SLIPPAGE_BPS) // 10000
|
|
|
|
# ---- phases -----------------------------------------------------------------
|
|
def phase_withdraw(op, tag):
|
|
"""decrease 50% + collect each position -> OWNER. Idempotent."""
|
|
for pos in op["positions"]:
|
|
tid = pos["tokenId"]
|
|
orig = int(pos["originalLiquidity"]); half = int(pos["removeLiquidity"])
|
|
target = orig - half
|
|
f0, f1 = int(pos["freed0"]), int(pos["freed1"])
|
|
do_step(f"{tag}.dec.{tid}", f"V3 decreaseLiquidity 50% tokenId={tid} (~{f0/1e18:.4f}/{f1/1e18:.4f})",
|
|
NPM, enc_decrease(tid, half, bps_down(f0), bps_down(f1)),
|
|
lambda tid=tid, target=target: npm_position(tid)[7] <= target)
|
|
for pos in op["positions"]:
|
|
tid = pos["tokenId"]
|
|
do_step(f"{tag}.col.{tid}", f"V3 collect tokenId={tid} -> {OWNER}",
|
|
NPM, enc_collect(tid, OWNER),
|
|
lambda tid=tid: (lambda p: p[10] == 0 and p[11] == 0)(npm_position(tid)))
|
|
|
|
def phase_add_spot(op, tag, PM, LD):
|
|
pk = op["poolKey"]; sqrtP = int(op["initSqrtPriceX96"])
|
|
need0 = bps_up(sum(int(p["addRequired0"]) for p in op["positions"]))
|
|
need1 = bps_up(sum(int(p["addRequired1"]) for p in op["positions"]))
|
|
if need0 > 0:
|
|
do_step(f"{tag}.appr0", f"approve {op['token0sym']} -> LD ({need0/1e18:.4f})",
|
|
pk["currency0"], enc_approve(LD, need0),
|
|
lambda: erc20_allowance(pk["currency0"], OWNER, LD) >= need0)
|
|
if need1 > 0:
|
|
do_step(f"{tag}.appr1", f"approve {op['token1sym']} -> LD ({need1/1e18:.4f})",
|
|
pk["currency1"], enc_approve(LD, need1),
|
|
lambda: erc20_allowance(pk["currency1"], OWNER, LD) >= need1)
|
|
assert_pool_price(PM, pk, sqrtP, f"{op['token0sym']}/{op['token1sym']}")
|
|
try:
|
|
inited = pool_sqrtprice(PM, pk) != 0
|
|
except Exception:
|
|
inited = False
|
|
for i, pos in enumerate(op["positions"]):
|
|
tl, tu = pos["tickLower"], pos["tickUpper"]; L = int(pos["removeLiquidity"])
|
|
salt = pos["tokenId"].to_bytes(32, "big")
|
|
max0 = bps_up(int(pos["addRequired0"])); max1 = bps_up(int(pos["addRequired1"]))
|
|
done = lambda tl=tl, tu=tu, L=L, salt=salt: pos_liquidity(PM, pk, LD, tl, tu, salt) >= L
|
|
if not inited:
|
|
do_step(f"{tag}.initadd.{pos['tokenId']}",
|
|
f"V4 initAndAdd @tick{op['currentTick']} L={L} [{tl},{tu}]",
|
|
LD, enc_initadd(pk, sqrtP, tl, tu, L, salt, OWNER, max0, max1), done)
|
|
inited = True
|
|
else:
|
|
do_step(f"{tag}.add.{pos['tokenId']}", f"V4 add L={L} [{tl},{tu}]",
|
|
LD, enc_add(pk, tl, tu, L, salt, OWNER, max0, max1), done)
|
|
record_pool({"pair": f"{op['token0sym']}/{op['token1sym']}", "classification": op["classification"],
|
|
"v4PoolId": "0x" + pool_id(pk).hex(), "initSqrtPriceX96": str(sqrtP),
|
|
"migrated": {op["token0sym"]: str(sum(int(p['freed0']) for p in op['positions'])),
|
|
op["token1sym"]: str(sum(int(p['freed1']) for p in op['positions']))}})
|
|
|
|
def onesided_band_ok(tick_o, expected_tick, band=None):
|
|
"""Fail-secure sanity band for a one-sided oracle bid. The oracle-derived tick must
|
|
sit within +/- band ticks of the operator's INDEPENDENTLY-recorded expected_tick
|
|
(2-operator confirm; see RUNBOOK 6). A price fat-finger (e.g. 10x) jumps tick_o by
|
|
~23000 ticks -> immediate reject. Returns (ok, reason)."""
|
|
if band is None:
|
|
band = ORACLE_TICK_BAND
|
|
dev = abs(tick_o - expected_tick)
|
|
if dev > band:
|
|
pct = (1.0001 ** dev - 1.0) * 100.0
|
|
return False, (f"tick {tick_o} deviates {dev} ticks (~{pct:,.0f}% price) from "
|
|
f"expected_tick {expected_tick} (band +/-{band})")
|
|
return True, f"tick {tick_o} within +/-{band} of expected_tick {expected_tick}"
|
|
|
|
def compute_onesided_bid(op, oracle, tag):
|
|
"""Parse+validate the oracle for a one-sided pool and compute the resting-bid params
|
|
(sqrtP, ticks, L, req). Returns None if there is no oracle entry (fail-secure skip).
|
|
ABORTS (exit 3) on a malformed entry, an out-of-band tick (price fat-finger), a
|
|
honeypot-edge tick, or a WLUX deposit over ONESIDED_MAX_WLUX. Pure/read-only, so
|
|
main() calls it BEFORE phase_withdraw -> a bad oracle never even decreases V3."""
|
|
strat = op["oneSidedStrategy"]; base = strat["baseSymbol"]
|
|
pair = f"{op['token0sym']}/{op['token1sym']}"
|
|
entry = oracle.get(base)
|
|
if entry is None:
|
|
print(f" [{tag}] MIGRATE_ONESIDED_ORACLE {pair}: no oracle entry for {base} "
|
|
f"-> SKIP (fail-secure). Provide {ORACLE}.")
|
|
return None
|
|
if not isinstance(entry, dict) or "price" not in entry or "expected_tick" not in entry:
|
|
print(f" [{tag}] ABORT: oracle[{base}] must be an object with 'price' AND "
|
|
f"'expected_tick' (2-operator confirm; see RUNBOOK 6); got {entry!r}.")
|
|
save_state(ST); sys.exit(3)
|
|
pk = op["poolKey"]; spacing = op["tickSpacing"]
|
|
lux_usd = float(oracle.get("LUX_USD", 12.5)); asset_usd = float(entry["price"])
|
|
expected_tick = int(entry["expected_tick"])
|
|
tick_band = int(entry.get("band_ticks", ORACLE_TICK_BAND))
|
|
wlux_is_1 = strat["wluxCurrency"] == "currency1"
|
|
# price = token1 per token0
|
|
price = (asset_usd / lux_usd) if wlux_is_1 else (lux_usd / asset_usd)
|
|
sqrtP = sqrt_price_x96_from_price(price)
|
|
tick_o = get_tick_at_sqrt_price(sqrtP)
|
|
v3edge = strat["v3EdgeTick"]
|
|
# PRIMARY GUARD: tight, independently-anchored band -> a price fat-finger aborts.
|
|
ok, why = onesided_band_ok(tick_o, expected_tick, tick_band)
|
|
if not ok:
|
|
print(f" [{tag}] ABORT: oracle sanity failed for {base}: {why}. Suspected fat-finger/"
|
|
f"oracle error — refusing (fail-secure). Fix oracle.json price, re-confirm "
|
|
f"expected_tick with a SECOND operator, then re-run.")
|
|
save_state(ST); sys.exit(3)
|
|
# BACKSTOP: the bid must rest strictly below the v3 honeypot edge, never clone it.
|
|
if wlux_is_1 and not (tick_o < v3edge - 1000):
|
|
print(f" [{tag}] ABORT: oracle tick {tick_o} not below v3 honeypot edge {v3edge}-1000; "
|
|
f"would clone the honeypot. Refusing.")
|
|
save_state(ST); sys.exit(3)
|
|
wlux_amt = int(strat["wluxAmount"])
|
|
if wlux_is_1:
|
|
tu = align_tick(tick_o, spacing, up=False)
|
|
tl = align_tick(tu - ONESIDED_BAND_TICKS, spacing, up=False)
|
|
sL, sU = get_sqrt_ratio_at_tick(tl), get_sqrt_ratio_at_tick(tu)
|
|
L = liquidity_for_amount1(sL, sU, wlux_amt)
|
|
# ensure required(roundUp) <= wlux_amt
|
|
req = get_amounts_for_liquidity(sqrtP, tl, tu, L, True)[1]
|
|
while req > wlux_amt and L > 0:
|
|
L -= 1; req = get_amounts_for_liquidity(sqrtP, tl, tu, L, True)[1]
|
|
wlux_token = pk["currency1"]; sym = op["token1sym"]
|
|
else:
|
|
tl = align_tick(tick_o, spacing, up=True)
|
|
tu = align_tick(tl + ONESIDED_BAND_TICKS, spacing, up=True)
|
|
sL, sU = get_sqrt_ratio_at_tick(tl), get_sqrt_ratio_at_tick(tu)
|
|
L = liquidity_for_amount0(sL, sU, wlux_amt)
|
|
req = get_amounts_for_liquidity(sqrtP, tl, tu, L, True)[0]
|
|
while req > wlux_amt and L > 0:
|
|
L -= 1; req = get_amounts_for_liquidity(sqrtP, tl, tu, L, True)[0]
|
|
wlux_token = pk["currency0"]; sym = op["token0sym"]
|
|
# HARD CAP: a scaling bug / bad wluxAmount can never risk more than ONESIDED_MAX_WLUX.
|
|
if req > ONESIDED_MAX_WLUX:
|
|
print(f" [{tag}] ABORT: one-sided WLUX deposit {req/1e18:,.2f} exceeds cap "
|
|
f"ONESIDED_MAX_WLUX={ONESIDED_MAX_WLUX/1e18:,.2f}. Refusing (fail-secure).")
|
|
save_state(ST); sys.exit(3)
|
|
return dict(pk=pk, sqrtP=sqrtP, tick_o=tick_o, expected_tick=expected_tick, v3edge=v3edge,
|
|
tl=tl, tu=tu, L=L, req=req, wlux_amt=wlux_amt, wlux_token=wlux_token, sym=sym,
|
|
wlux_is_1=wlux_is_1, price=price)
|
|
|
|
def phase_add_onesided(op, tag, PM, LD, oracle, bid=None):
|
|
if bid is None:
|
|
bid = compute_onesided_bid(op, oracle, tag)
|
|
if bid is None:
|
|
return # no oracle entry -> fail-secure skip
|
|
pk, sqrtP, tick_o, tl, tu = bid["pk"], bid["sqrtP"], bid["tick_o"], bid["tl"], bid["tu"]
|
|
L, req, wlux_amt, wlux_token, sym, wlux_is_1 = (bid["L"], bid["req"], bid["wlux_amt"],
|
|
bid["wlux_token"], bid["sym"], bid["wlux_is_1"])
|
|
print(f" [{tag}] one-sided bid: price={bid['price']:.6g} {op['token1sym']}/{op['token0sym']} "
|
|
f"oracleTick={tick_o} bidRange=[{tl},{tu}] L={L} depositWLUX={req/1e18:.4f} (v3edge={bid['v3edge']})")
|
|
do_step(f"{tag}.appr", f"approve {sym} -> LD ({req/1e18:.4f} WLUX resting bid)",
|
|
wlux_token, enc_approve(LD, wlux_amt),
|
|
lambda: erc20_allowance(wlux_token, OWNER, LD) >= req)
|
|
salt = (0).to_bytes(32, "big")
|
|
max0 = req if not wlux_is_1 else 0
|
|
max1 = req if wlux_is_1 else 0
|
|
assert_pool_price(PM, pk, sqrtP, f"{op['token0sym']}/{op['token1sym']}")
|
|
try:
|
|
inited = pool_sqrtprice(PM, pk) != 0
|
|
except Exception:
|
|
inited = False
|
|
done = lambda: pos_liquidity(PM, pk, LD, tl, tu, salt) >= L
|
|
if not inited:
|
|
do_step(f"{tag}.initadd", f"V4 initAndAdd @oracleTick{tick_o} (resting bid) L={L}",
|
|
LD, enc_initadd(pk, sqrtP, tl, tu, L, salt, OWNER, max0, max1), done)
|
|
else:
|
|
do_step(f"{tag}.add", f"V4 add resting bid L={L}", LD, enc_add(pk, tl, tu, L, salt, OWNER, max0, max1), done)
|
|
record_pool({"pair": f"{op['token0sym']}/{op['token1sym']}", "classification": op["classification"],
|
|
"v4PoolId": "0x" + pool_id(pk).hex(), "initSqrtPriceX96": str(sqrtP),
|
|
"oracleTick": tick_o, "expectedTick": bid["expected_tick"], "bidRange": [tl, tu],
|
|
"migratedWLUX": str(req)})
|
|
|
|
def phase_finalize(ops, PM, LD):
|
|
"""End state: no standing token approvals from the hot treasury EOA to LD, and LD
|
|
owned by the DAO Safe (not the hot EOA). Idempotent; each step prechecks on-chain."""
|
|
tokens = []
|
|
for op in ops:
|
|
for c in (op["poolKey"]["currency0"], op["poolKey"]["currency1"]):
|
|
if c.lower() not in tokens:
|
|
tokens.append(c.lower())
|
|
for i, tok in enumerate(tokens):
|
|
do_step(f"FIN.zero.{i}", f"zero treasury approval {tok} -> LD",
|
|
tok, enc_approve(LD, 0),
|
|
lambda tok=tok: erc20_allowance(tok, OWNER, LD) == 0)
|
|
do_step("FIN.owner", f"transferOwnership LD -> DAO Safe {DAO_SAFE}",
|
|
LD, enc_transfer_ownership(DAO_SAFE),
|
|
lambda: ld_owner(LD).lower() == DAO_SAFE.lower())
|
|
ST["ownership"] = {"liquidityDeployer": LD, "owner": DAO_SAFE}
|
|
save_state(ST)
|
|
|
|
# ---- main -------------------------------------------------------------------
|
|
def main():
|
|
plan = json.load(open(PLAN))
|
|
oracle = json.load(open(ORACLE)) if os.path.exists(ORACLE) else {}
|
|
pmbc = json.load(open(V4CORE_ART))["bytecode"]["object"]
|
|
ldbc = json.load(open(LD_ART))["bytecode"]["object"]
|
|
|
|
mode = "EXECUTE (BROADCAST TO MAINNET)" if EXECUTE else "DRY-RUN (no broadcast)"
|
|
print("=" * 78)
|
|
print(f"Lux V3->V4 migration executor chainId={CHAIN_ID} mode={mode}")
|
|
print(f"gasPrice={GAS_PRICE_WEI//10**9} gwei broadcast_pods={RPC_PODS} slippage={SLIPPAGE_BPS}bps")
|
|
if EXECUTE and "LUX_PRIVATE_KEY" not in os.environ:
|
|
print("FATAL: EXECUTE=1 but LUX_PRIVATE_KEY not set."); sys.exit(2)
|
|
print("=" * 78)
|
|
|
|
# broadcast-safety preflights (execute only): base fee affordable + no concurrent signer.
|
|
if EXECUTE:
|
|
preflight_gas()
|
|
NONCE[0] = preflight_nonce_stability()
|
|
|
|
# STEP 1/2 — deploy V4 PoolManager + LiquidityDeployer
|
|
print("\n[STEP 1] Deploy V4 PoolManager(initialOwner=OWNER)")
|
|
pm_ctor = abi_encode(["address"], [OWNER]).hex()
|
|
PM = do_create("1", "deploy PoolManager", "poolManager", pmbc, pm_ctor, "poolManager")
|
|
print("\n[STEP 2] Deploy LiquidityDeployer(manager=PM, owner=OWNER)")
|
|
ld_ctor = abi_encode(["address", "address"], [PM, OWNER]).hex()
|
|
LD = do_create("2", "deploy LiquidityDeployer", "liquidityDeployer", ldbc, ld_ctor, "liquidityDeployer")
|
|
save_state(ST)
|
|
|
|
ops = [o for o in plan["operations"] if o["classification"].startswith("MIGRATE")]
|
|
n = 3
|
|
for op in ops:
|
|
pair = f"{op['token0sym']}/{op['token1sym']}"
|
|
print(f"\n[STEP {n}] {op['classification']} {pair} ({op['reason']})")
|
|
tag = f"{n}"
|
|
if op["classification"] == "MIGRATE_SPOT":
|
|
phase_withdraw(op, tag)
|
|
phase_add_spot(op, tag, PM, LD)
|
|
else:
|
|
# validate the oracle (abort on fat-finger) BEFORE decreasing any V3 liquidity.
|
|
bid = compute_onesided_bid(op, oracle, tag)
|
|
phase_withdraw(op, tag)
|
|
phase_add_onesided(op, tag, PM, LD, oracle, bid=bid)
|
|
n += 1
|
|
|
|
# FINALIZE — hand LD to the DAO Safe + drop residual approvals (gated in execute mode).
|
|
if EXECUTE and not FINALIZE:
|
|
print("\n[FINALIZE] skipped (set FINALIZE=1 to zero LD approvals + hand LD ownership "
|
|
"to the DAO Safe once pools are verified).")
|
|
else:
|
|
print("\n[FINALIZE] zero residual LD approvals + transferOwnership LD -> DAO Safe"
|
|
+ ("" if EXECUTE else " [dry-run preview]"))
|
|
phase_finalize(ops, PM, LD)
|
|
|
|
save_state(ST)
|
|
print("\n" + "=" * 78)
|
|
print(f"{'DRY-RUN complete — no transactions broadcast.' if not EXECUTE else 'EXECUTE complete.'}")
|
|
print(f"state -> {STATE}")
|
|
if not EXECUTE:
|
|
print("Review the plan above, then re-run with EXECUTE=1 LUX_PRIVATE_KEY=$(...) to broadcast.")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|