// Shared code for unwind.mjs, shuffle.mjs, reseed.mjs and rehearse.mjs (Stray Cucks pool re-seed, 2026-09-27). // // WHY. The 50 chairs first put in the peg pool were picked using pre-reveal trait data. Operator rule: no allocation // may use trait knowledge. The fix deals ALL 150 reserve chairs again from a public future block hash. // // The flow, and why it is cheaper than "redeem all 50 then shuffle": // unwind pull position 3339970 out: the launcher gets back ~50 claims (+ any ETH from sales). The 50 old pool NFTs // stay in the vault, backed by those claims. Nothing is redeemed yet. // shuffle trait-blind Fisher-Yates over every reserve id we control (ids 1..RESERVE_CAP held by the launcher OR // sitting in our vault), seeded by a future block hash. First 50 = pool, next 50 = Hood Street, last 50 = // stacc. // reseed only the vault ids the shuffle sent AWAY from the pool are redeemed (redeemSpecific, 1.125 claims each: // unit + 10% royalty + 0.5% platform + 2% premium). Their places are taken by the launcher ids the shuffle // sent TO the pool (deposited by addLiquidity). Every leftover claim goes back in as extraClaims. // End state: the vault holds exactly the 50 shuffled pool ids, the launcher exactly the other 100. // Redeeming all 50 instead would return only 45 (50 / 1.105) and leave 5 trait-picked chairs stuck in the vault. // // This file never imports the art or trait table. It must not. import { ethers } from '/home/arson/gmgn-brain/work/drafts/rh-mint-snipe/node_modules/ethers/lib.esm/index.js'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { spawn } from 'node:child_process'; export { ethers }; export const HERE = new URL('.', import.meta.url).pathname; export const MAINNET_RPC = 'https://rpc.mainnet.chain.robinhood.com'; export const A = { launcher: '0x4Cef272343A712724096528357366Ef8183c0939', nft: '0xC6132BD1B7eE344ba6a53fa203116D168cDc5307', vault: '0xC478B69E41086717910313aEE915c86720f0440e', router: '0x1BB0A1972fc9D4a348542Eb65a5ecC7c21A4712F', posm: '0x58daec3116aae6D93017bAAea7749052E8a04fA7', pm: '0x8366a39CC670B4001A1121B8F6A443A643e40951', }; export const OLD_POSITION = 3339970n; export const POOL_ID = '0x5156efe10e4b9c2812bfa7ed1638d1430e36d0ad58fdd45d6fb4631b32e6cc40'; export const KEY = { currency0: ethers.ZeroAddress, currency1: A.vault, fee: 10000, tickSpacing: 200, hooks: ethers.ZeroAddress }; export const TICK_LOWER = 51200, TICK_UPPER = 67200; export const SIZES = { pool: 50, hoodStreet: 50, stacc: 50 }; const KEYT = '(address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks)'; export const ABI = { nft: ['function ownerOf(uint256) view returns (address)', 'function setApprovalForAll(address,bool)', 'function isApprovedForAll(address,address) view returns (bool)', 'function RESERVE_CAP() view returns (uint256)'], vault: ['function balanceOf(address) view returns (uint256)', 'function totalSupply() view returns (uint256)', 'function inventoryCount() view returns (uint256)', 'function inVault(uint256) view returns (bool)', 'function unit() view returns (uint256)', 'function redeemCost(uint256,bool) view returns (uint256 total, uint256 royalty, address royaltyReceiver, uint256 platform, address treasury, uint256 premium)', 'function redeemSpecific(uint256,address)', 'function depositBatch(uint256[],address) returns (uint256)', 'function approve(address,uint256) returns (bool)', 'function allowance(address,address) view returns (uint256)', 'function taxBps() view returns (uint16)', 'function owner() view returns (address)'], posm: ['function modifyLiquidities(bytes unlockData, uint256 deadline) payable', 'function getPositionLiquidity(uint256) view returns (uint128)', 'function ownerOf(uint256) view returns (address)'], router: [`function addLiquidity((${KEYT} key, address vault, uint256[] tokenIds, uint256 extraClaims, uint256 amountQuote, int24 tickLower, int24 tickUpper, uint128 minLiquidity, address recipient, bool lock) p) payable returns (uint256 positionId, uint128 liquidity)`, `function buyFloor(${KEYT} key, address vault, uint256 maxQuote, address receiver) payable returns (uint256 tokenId, uint256 quotePaid)`, 'event LiquidityAdded(address indexed owner, address indexed vault, uint256 indexed positionId, uint128 liquidity, bool locked)'], pm: ['function extsload(bytes32) view returns (bytes32)'], }; export const fmt = (x) => ethers.formatEther(x); export const log = (...a) => console.log(...a); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---------------------------------------------------------------- context: live wallet or anvil fork // DRY: start our own anvil fork of mainnet at latest (port 8601), impersonate the launcher; no key is read. // LIVE: mainnet RPC + the launcher key file. The key is never printed. export async function context({ live, port = 8601, fork = true }) { if (live) { const provider = new ethers.JsonRpcProvider(MAINNET_RPC, 4663, { staticNetwork: true, cacheTimeout: -1 }); const signer = new ethers.Wallet(JSON.parse(readFileSync(homedir() + '/.config/stray-cucks/launcher.json', 'utf8')).privateKey, provider); if (signer.address.toLowerCase() !== A.launcher.toLowerCase()) throw new Error('key file is not the launcher'); return mkctx({ provider, signer, live: true, close: async () => {} }); } if (!fork) { const provider = new ethers.JsonRpcProvider(MAINNET_RPC, 4663, { staticNetwork: true }); return mkctx({ provider, signer: null, live: false, close: async () => {} }); } // Price gas like mainnet (anvil's default 1 gwei would burn the launcher's 0.0026 ETH in ~20 txs and make the // rehearsal lie about cost). L2 execution only: the fork cannot charge Orbit's L1 data component. const mainGas = BigInt(await new ethers.JsonRpcProvider(MAINNET_RPC, 4663, { staticNetwork: true }).send('eth_gasPrice', [])); const anvil = spawn(homedir() + '/.foundry/bin/anvil', ['--port', String(port), '--fork-url', MAINNET_RPC, '--chain-id', '4663', '--silent', '--block-base-fee-per-gas', String(mainGas), '--gas-price', String(mainGas)], { stdio: 'ignore' }); const url = `http://127.0.0.1:${port}`; const provider = new ethers.JsonRpcProvider(url, 4663, { staticNetwork: true, batchMaxCount: 1 }); for (let i = 0; ; i++) { try { await provider.send('eth_blockNumber', []); break; } catch (e) { if (i > 60) throw new Error('anvil did not start'); await sleep(500); } } // anvil prices a forked Orbit block at ~1 gwei whatever the flags say, 40x mainnet, which would burn the launcher's // 0.0026 ETH in ~20 txs. So the fork launcher is topped up by 1 ETH and the COST is reported as gasUsed x the // mainnet gas price (ctx.mainGas), then compared with the launcher's real balance. await provider.send('evm_mine', []); // first call on a fresh Orbit fork fails with "Excess blob gas not set" otherwise const realBal = await provider.getBalance(A.launcher); await provider.send('anvil_setBalance', [A.launcher, ethers.toQuantity(realBal + ethers.parseEther('1'))]); await provider.send('anvil_impersonateAccount', [A.launcher]); const signer = await provider.getSigner(A.launcher); const close = async () => { try { anvil.kill('SIGTERM'); } catch (e) {} }; process.on('exit', () => { try { anvil.kill('SIGTERM'); } catch (e) {} }); log(`[dry] anvil fork on ${url} (pid ${anvil.pid}), block ${await provider.getBlockNumber()}, launcher impersonated (no key loaded)`); const c = mkctx({ provider, signer, live: false, close }); c.mainGas = mainGas; c.realLauncherWei = realBal; return c; } function mkctx({ provider, signer, live, close }) { const ctx = { provider, signer, live, close, spentWei: 0n, gasUsed: 0n, txs: [], mainGas: null, realLauncherWei: null }; // What these transactions cost (or would cost) on mainnet: the real spend when live, gasUsed x mainnet gas price on a fork. ctx.costEth = () => fmt(live ? ctx.spentWei : ctx.gasUsed * (ctx.mainGas ?? 0n)); ctx.c = (addr, abi) => new ethers.Contract(addr, abi, signer || provider); // Send one tx, wait for its receipt by polling (ethers' tx.wait() can hang on anvil automine). ctx.send = async (label, fn) => { const tx = await fn(); for (let i = 0; i < 600; i++) { const r = await provider.getTransactionReceipt(tx.hash); if (r) { if (r.status !== 1) throw new Error(`${label} REVERTED (${tx.hash})`); const cost = r.gasUsed * (r.gasPrice ?? tx.gasPrice ?? 0n); ctx.spentWei += cost; ctx.gasUsed += r.gasUsed; ctx.txs.push({ label, hash: tx.hash, gas: Number(r.gasUsed) }); log(` ok ${label} gas ${r.gasUsed} block ${r.blockNumber} tx ${tx.hash}`); return r; } await sleep(live ? 1000 : 25); } throw new Error(`${label}: no receipt for ${tx.hash}`); }; return ctx; } // ---------------------------------------------------------------- reads export async function poolTick(ctx) { const w = BigInt(await ctx.c(A.pm, ABI.pm).extsload(ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'uint256'], [POOL_ID, 6n])))); let t = Number((w >> 160n) & 0xffffffn); if (t >= 0x800000) t -= 0x1000000; return { tick: t, sqrtP: w & ((1n << 160n) - 1n) }; } // Every reserve id we control, ascending: launcher-owned, or inside our vault. Trait-blind: ids and owners only. export async function reserveHoldings(ctx) { const nft = ctx.c(A.nft, ABI.nft); let cap = 150; try { cap = Number(await nft.RESERVE_CAP()); } catch (e) {} const launcher = [], inVault = [], elsewhere = []; const owners = []; for (let i = 1; i <= cap; i += 25) owners.push(...(await Promise.all(Array.from({ length: Math.min(25, cap - i + 1) }, (_, k) => nft.ownerOf(i + k))))); for (let id = 1; id <= cap; id++) { const o = owners[id - 1].toLowerCase(); if (o === A.launcher.toLowerCase()) launcher.push(id); else if (o === A.vault.toLowerCase()) inVault.push(id); else elsewhere.push({ id, owner: o }); } return { cap, launcher, inVault, elsewhere }; } export async function snapshot(ctx, label) { const v = ctx.c(A.vault, ABI.vault); const s = { label, launcherEth: fmt(await ctx.provider.getBalance(A.launcher)), launcherClaims: fmt(await v.balanceOf(A.launcher)), claimSupply: fmt(await v.totalSupply()), vaultInventory: Number(await v.inventoryCount()), unit: fmt(await v.unit()), oldPositionLiquidity: String(await ctx.c(A.posm, ABI.posm).getPositionLiquidity(OLD_POSITION)), poolTick: (await poolTick(ctx)).tick, }; log(` [${label}] launcher ETH ${s.launcherEth}, claims ${s.launcherClaims}; vault ${s.vaultInventory} NFTs, claim supply ${s.claimSupply}, unit ${s.unit}; old position liq ${s.oldPositionLiquidity}; pool tick ${s.poolTick}`); return s; } // ---------------------------------------------------------------- 1. unwind export async function unwind(ctx) { const posm = ctx.c(A.posm, ABI.posm); const owner = await posm.ownerOf(OLD_POSITION); if (owner.toLowerCase() !== A.launcher.toLowerCase()) throw new Error('position ' + OLD_POSITION + ' is owned by ' + owner); const liq = await posm.getPositionLiquidity(OLD_POSITION); const before = await snapshot(ctx, 'before unwind'); if (liq === 0n) { log(' position already empty, nothing to unwind'); return { before, after: before, skipped: true }; } const coder = ethers.AbiCoder.defaultAbiCoder(); // DECREASE_LIQUIDITY (0x01) all of it, then TAKE_PAIR (0x11) both currencies to the launcher. const data = coder.encode(['bytes', 'bytes[]'], [ethers.solidityPacked(['uint8', 'uint8'], [0x01, 0x11]), [ coder.encode(['uint256', 'uint256', 'uint128', 'uint128', 'bytes'], [OLD_POSITION, liq, 0, 0, '0x']), coder.encode(['address', 'address', 'address'], [KEY.currency0, KEY.currency1, A.launcher])]]); const deadline = BigInt(Math.floor(Date.now() / 1000) + 1800); await posm.modifyLiquidities.staticCall(data, deadline); log(` static call OK: withdraw liquidity ${liq} of position ${OLD_POSITION}`); await ctx.send('withdraw position ' + OLD_POSITION + ' (DECREASE_LIQUIDITY + TAKE_PAIR)', () => posm.modifyLiquidities(data, deadline)); const after = await snapshot(ctx, 'after unwind'); return { before, after, liquidity: String(liq) }; } // ---------------------------------------------------------------- 2. shuffle (pure) export function fisherYates(input, seedHash) { const a = [...input].sort((x, y) => x - y); const coder = ethers.AbiCoder.defaultAbiCoder(); for (let i = a.length - 1; i > 0; i--) { const j = Number(BigInt(ethers.keccak256(coder.encode(['bytes32', 'uint256'], [seedHash, i]))) % BigInt(i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // Hood Street and stacc get 50 each; the pool takes the rest (50 when all 150 are ours; fewer only if pool sales // happened before the unwind, and those sales left ETH in the launcher instead). export function split(shuffled) { const n = shuffled.length, poolN = Math.max(0, n - SIZES.hoodStreet - SIZES.stacc); const asc = (x) => [...x].sort((p, q) => p - q); return { pool: asc(shuffled.slice(0, poolN)), hoodStreet: asc(shuffled.slice(poolN, poolN + SIZES.hoodStreet)), stacc: asc(shuffled.slice(poolN + SIZES.hoodStreet)) }; } // ---------------------------------------------------------------- 3. reseed export async function reseed(ctx, result) { const nft = ctx.c(A.nft, ABI.nft), v = ctx.c(A.vault, ABI.vault), router = ctx.c(A.router, ABI.router); const posm = ctx.c(A.posm, ABI.posm); if ((await posm.getPositionLiquidity(OLD_POSITION)) !== 0n) throw new Error('old position still holds liquidity: run unwind first'); const pool = result.split.pool, poolSet = new Set(pool); const all = [...result.split.pool, ...result.split.hoodStreet, ...result.split.stacc]; const h = await reserveHoldings(ctx); const held = new Set([...h.launcher, ...h.inVault]); for (const id of all) if (!held.has(id)) throw new Error(`#${id} is neither with the launcher nor in the vault any more`); const evict = h.inVault.filter((id) => all.includes(id) && !poolSet.has(id)); // in vault, shuffled away const toDeposit = h.launcher.filter((id) => poolSet.has(id)); // with launcher, shuffled in const before = await snapshot(ctx, 'before reseed'); log(` plan: redeem ${evict.length} vault ids shuffled away [${evict.join(',')}]`); log(` deposit ${toDeposit.length} launcher ids shuffled in [${toDeposit.join(',')}]`); // A one-sided claims add needs the pool price at or above the band's upper tick. Live on 2026-09-27 the tick is // 67199, one tick inside the old band (someone bought 0.0016 claims straight from the pool), so the band's top is // snapped down to the spacing below the current tick (67000 = 0.001231 ETH floor instead of 0.001207). Same rule as // the harvest step in LP-PLAN.md. const { tick } = await poolTick(ctx); const tickUpper = Math.min(TICK_UPPER, Math.floor(tick / 200) * 200); if (tickUpper < 66000) throw new Error(`pool tick ${tick}: the empty pool was pushed up to a claim price above 0.00136 ETH; re-adding there would raise the floor. Stop and ask.`); if (tickUpper !== TICK_UPPER) log(` pool tick ${tick} is inside the old band: new band top snapped to ${tickUpper} (floor ${(1 / 1.0001 ** tickUpper).toFixed(6)} ETH)`); // Claims must cover every redeem. If the shuffle sent unusually many vault ids away, deposit some shuffled-in ids // straight into the vault first (1 claim each) so the redeems can be paid for. const cost1 = (await v.redeemCost(evict[0] ?? 1, true)).total; let claims = await v.balanceOf(A.launcher); const need = cost1 * BigInt(evict.length) + cost1 / 10n; // 10% of one redeem as rounding margin let preDeposit = []; if (claims < need) { const short = Number((need - claims) / (10n ** 18n)) + 1; preDeposit = toDeposit.slice(0, short); log(` claims ${fmt(claims)} < ${fmt(need)} needed: depositing ${preDeposit.length} shuffled-in ids into the vault first`); await ctx.send('approve vault for NFTs', () => nft.setApprovalForAll(A.vault, true)); await ctx.send(`depositBatch ${preDeposit.length} ids`, () => v.depositBatch(preDeposit, A.launcher)); await ctx.send('revoke vault NFT approval', () => nft.setApprovalForAll(A.vault, false)); } for (const id of evict) { const c = await v.redeemCost(id, true); await v.redeemSpecific.staticCall(id, A.launcher); await ctx.send(`redeemSpecific #${id} (cost ${fmt(c.total)} claims)`, () => v.redeemSpecific(id, A.launcher)); } const addIds = toDeposit.filter((id) => !preDeposit.includes(id)); claims = await v.balanceOf(A.launcher); const p = { key: KEY, vault: A.vault, tokenIds: addIds, extraClaims: claims, amountQuote: 0, tickLower: TICK_LOWER, tickUpper, minLiquidity: 0, recipient: A.launcher, lock: false }; log(` addLiquidity: ${addIds.length} NFTs + ${fmt(claims)} extra claims, ticks ${TICK_LOWER}..${tickUpper}, 0 ETH, unlocked`); if (addIds.length) await ctx.send('approve router for NFTs', () => nft.setApprovalForAll(A.router, true)); if (claims > 0n) await ctx.send('approve router for claims', () => v.approve(A.router, claims)); const est = await router.addLiquidity.staticCall(p); log(` static call OK: liquidity ${est[1]}`); p.minLiquidity = est[1] * 99n / 100n; let gasLimit; try { gasLimit = (await router.addLiquidity.estimateGas(p)) * 12n / 10n; } catch (e) { gasLimit = 12_000_000n; } const r = await ctx.send('addLiquidity (new position)', () => router.addLiquidity(p, { gasLimit })); const ev = r.logs.map((l) => { try { return router.interface.parseLog(l); } catch (e) { return null; } }).find((x) => x && x.name === 'LiquidityAdded'); if (addIds.length) await ctx.send('revoke router NFT approval', () => nft.setApprovalForAll(A.router, false)); if ((await v.allowance(A.launcher, A.router)) > 0n) await ctx.send('reset router claim allowance', () => v.approve(A.router, 0)); const after = await snapshot(ctx, 'after reseed'); return { before, after, tickLower: TICK_LOWER, tickUpper, evicted: evict, deposited: toDeposit, preDeposit, positionId: ev ? String(ev.args.positionId) : null, liquidity: ev ? String(ev.args.liquidity) : null }; } // ---------------------------------------------------------------- checks export async function assertEndState(ctx, result, positionId) { const h = await reserveHoldings(ctx); const eq = (a, b) => a.length === b.length && [...a].sort((x, y) => x - y).every((x, i) => x === [...b].sort((p, q) => p - q)[i]); const want = [...result.split.hoodStreet, ...result.split.stacc]; const checks = { vaultHoldsExactlyPoolIds: eq(h.inVault, result.split.pool), launcherHoldsExactlyHoodAndStacc: eq(h.launcher, want), noReserveIdLost: h.elsewhere.length === 0, }; const posm = ctx.c(A.posm, ABI.posm); checks.newPositionOwnedByLauncher = (await posm.ownerOf(positionId)).toLowerCase() === A.launcher.toLowerCase(); checks.newPositionHasLiquidity = (await posm.getPositionLiquidity(positionId)) > 0n; checks.routerApprovalRevoked = !(await ctx.c(A.nft, ABI.nft).isApprovedForAll(A.launcher, A.router)); // A buyer can take a chair at the band floor, and the chair is one of the shuffled pool ids. DRY only (needs a // funded throwaway address, which only the fork can conjure). if (!ctx.live) { const buyer = ethers.Wallet.createRandom().address; await ctx.provider.send('anvil_setBalance', [buyer, '0x16345785D8A0000']); try { const q = await new ethers.Contract(A.router, ABI.router, ctx.provider).buyFloor.staticCall(KEY, A.vault, ethers.parseEther('0.01'), buyer, { value: ethers.parseEther('0.01'), from: buyer }); checks.buyQuoteEth = fmt(q[1]); checks.firstBuyIsAPoolId = result.split.pool.includes(Number(q[0])); } catch (e) { checks.buyQuoteEth = 'ERR ' + (e.shortMessage || e.message).slice(0, 80); checks.firstBuyIsAPoolId = false; } } return { checks, holdings: h }; } export function readJson(name) { return JSON.parse(readFileSync(HERE + name, 'utf8')); } export function writeJson(name, obj) { writeFileSync(HERE + name, JSON.stringify(obj, (_, x) => (typeof x === 'bigint' ? x.toString() : x), 1)); } export { existsSync, sleep };