Runbook: deploying Bias on Robinhood Chain
Every command runs from hoodperps-contracts/ with export PATH="$HOME/.foundry/bin:$HOME/.local/node/bin:$PATH" and
set -a; source .env; set +a. Steps 3 onward broadcast real transactions: run each without --broadcast first (the
simulation prints every call), read it, then add --broadcast.
No private key goes in .env or on a command line. Every script signs with forge's wallet flags and broadcasts as
--sender (script/SkewScript.sol): --ledger --sender $DEPLOYER (or --account <keystore> --sender ..). The exact
mainnet sequence with expected outputs and checks is LAUNCH_PACK.md.
0. Wallets (set up before anything else)#
| Wallet | Holds | Used for |
|---|---|---|
| Custody wallet (yours, cold) | $BIAS creator-tax ETH, long-term USDG | Claims Pons tax, converts ETH → USDG, tops up the treasury. No contract ever has an allowance on it. |
| Treasury wallet (Safe recommended) | Only the USDG you are willing to have backing open positions | USDG.approve(vault, budget). Receives fees, losses and reserves back. |
| Safe multisig (2-of-3 or 3-of-5) | Nothing | Timelock proposer / executor / canceller. |
| Guardian (hot, ops team) | Gas ETH | Close-only, remove keepers. Nothing else. |
| Deployer (hot, temporary) | Gas ETH | Steps 3–7 only; powerless afterwards. |
| Executors ×2 | Gas ETH | PositionRouter keepers, liquidations, TWAP records, funding. |
| Signers ×3, separate hosts | Nothing | Sign TWAP-class prices. |
Worst case if the vault or a keeper is compromised = escrow for open positions + remaining treasury allowance. The custody wallet is out of reach by construction.
1. Verify#
forge build
forge test # 100+ tests
FOUNDRY_PROFILE=deep forge test # long fuzz / invariant campaign, run before every deploy
forge test --match-contract ForkOracle --fork-url $RPC_URL -vv
node keeper/fetch-feeds.js # re-verify Chainlink feeds; compare with script/Configure.s.sol
All must pass. Any change to src/ after an audit invalidates the audit.
2. Rehearsal#
Two environments, because neither one covers everything.
2a. Mainnet fork — for the real Uniswap v4 pools, and only for about 10 minutes#
B=$(( $(cast block-number --rpc-url $RPC_URL) - 2 ))
anvil --fork-url $RPC_URL --fork-block-number $B --chain-id 4663 --accounts 20 --block-time 1 --no-rate-limit &
# immediately: the public RPC is not an archive node. It keeps about 6,000 blocks (~10 minutes) of state. After that
# anvil panics in the EIP-2935 system call on the next block; removing that system code avoids it:
cast rpc anvil_setCode 0x0000F90827F1C53a10cb7A02335B175320002935 0x --rpc-url http://127.0.0.1:8545
cast rpc anvil_setCode 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02 0x --rpc-url http://127.0.0.1:8545
forge script script/fork/ForkPrep.s.sol --rpc-url http://127.0.0.1:8545 --unlocked --sender <anvil0> --broadcast --slow
script/fork/ForkPrep.s.sol is the prewarm: it reads every Chainlink feed, every day-one pool's slot0 and
liquidity and every token's metadata, deploys a v4 swap router, and walks FRONG/ETH up 60% and back so the tick
range a later liquidation swap crosses is cached. Anvil caches every slot it fetches, so anything touched inside
the window keeps working afterwards. Anything not touched inside the window is gone, and that includes things
that are easy to forget:
- the precompiles. Anvil loads the account of every address a call touches.
0x01(ecrecover) is used byOracleRouter.postPriceand0x64(ArbSys) byPositionRouteron every execution, and a plain deploy touches neither. Warm0x01–0x14and0x64–0x6fwithcast balancebefore you need them. - USDG balance and allowance slots for every address that will ever hold or approve USDG, including the vault, router and factory once they exist. USDG lives upstream; its storage does not.
anvil_dumpState / anvil_loadState is not a way around this: the dump contains only written state, and
accounts restored by loadState are not marked as locally created, so anvil then tries to fetch the storage of
your own freshly deployed contracts from an upstream block that no longer exists.
So: script the fork run end to end and keep it under ten minutes. Deploy, Configure, SetMarketHours, the treasury approval, Handoff, the first timelock operation and listing every launch coin fit comfortably (4m45s on 2026-09-20). Warping time afterwards is free — it needs no upstream reads — so the warm-up and the trading can take as long as you like, as long as they only touch cached state.
Move deployments/4663.json aside the moment the fork run ends: it writes the same file as mainnet, and
skew-site's npm run sync copies whatever is in it into content/deployment.json. A fork address that
reaches the site points every visitor at a contract that does not exist. Rename it to
deployments/fork-rehearsal-<date>.json and re-run npm run sync in the site before committing anything.
2b. Local chain with a real Uniswap v4 PoolManager — for everything that needs days#
anvil --port 8555 --chain-id 31337 --accounts 20 --block-time 1 &
FOUNDRY_PROFILE=localv4 LOCAL_DEPLOYER_PK=0x.. LOCAL_TREASURY=0x.. LOCAL_GUARDIAN=0x.. \
LOCAL_KEEPERS=0x..,0x.. LOCAL_SIGNERS=0x..,0x..,0x.. LOCAL_TRADERS=0x..,0x.. \
forge script script/fork/LocalV4.s.sol --tc LocalV4 --rpc-url http://127.0.0.1:8555 --broadcast --slow
This deploys Uniswap v4's own PoolManager from lib/v4-core, a USDG mock, three coins with real concentrated
liquidity and the whole Bias stack, and lists the three pools through the factory. Real v4 reads, real swaps, no
upstream at all, so it survives arbitrary evm_increaseTime. Use it for the liquidation-by-swap, the group reserve
cap, the 3-day dead-coin clock, delist + settle and the site.
v4's PoolManager pins solc =0.8.26, which is why this lives in its own localv4 profile with its own out
directory. Nothing in that profile produces mainnet bytecode; out/ and keeper/abi/ stay on 0.8.24.
The site runs against it with NEXT_PUBLIC_CHAIN_ID, NEXT_PUBLIC_RPC_URL, NEXT_PUBLIC_USDG,
NEXT_PUBLIC_POOL_MANAGER and NEXT_PUBLIC_DEV_MOCK_ACCOUNT, plus Multicall3 installed at its canonical address
with anvil_setCode.
3. Deploy core ⚠️ mainnet#
forge script script/Deploy.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER # simulate, read the output
forge script script/Deploy.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER --broadcast --slow
Deploy.s.sol now also deploys RiskManager and MarketFactory and makes the factory the lister on the vault, oracle and
risk manager (deployments/4663.json gains riskManager and factory).
Then one UniV4TwapSource per launch coin (table in script/Configure.s.sol), before Configure. USDG-quoted pools add
QUOTE=0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168:
SYMBOL=FRONG TOKEN=0x6245e67affa44a23077f0ea7f981a8dc743a0c47 HOOK=0x0000000000000000000000000000000000000000 POOL_FEE=2500 TICK_SPACING=60 \
RECORDERS=$RECORDER GUARDIAN=$GUARDIAN forge script script/DeployTwapSource.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER --broadcast --slow
Start the recorder right away: a coin market cannot open until its source has 180s of records (RiskManager.warmup,
the market's twapWindow and the source's depthWindow). For a source deployed by this script pass
DEPTH_WINDOW=180 MAX_OBSERVATION_AGE=90 so all three match; factory listings take them from the template.
script/SetWarmup.s.sol moves all three on an existing deployment.
Check deployments/4663.json, then verify the sources:
forge verify-contract <address> src/PerpVault.sol:PerpVault --chain 4663 \
--verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api/
(Repeat for OracleRouter, PositionRouter, RiskManager and MarketFactory, with --constructor-args from the broadcast file.)
4. Configure markets ⚠️ mainnet#
BUDGET_USDG=250000 TWAP_NOVAAI=0x.. TWAP_FRONG=0x.. TWAP_PROLOGUE=0x.. TWAP_HOOKR=0x.. TWAP_BUN=0x.. TWAP_POOLS=0x.. \
forge script script/Configure.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER --broadcast --slow
ORACLE=$(jq -r .oracle deployments/4663.json) forge script script/SetMarketHours.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER --broadcast --slow
Configure lists ETH, BTC, SPY, QQQ, NVDA, AAPL and the six launch coins (NOVAAI, FRONG, PROLOGUE, HOOKR, BUN, POOLS),
plus PONS, AI and BONER when their TWAP_ sources are set. It also sets the tier table, group budgets, the coin reserve
cap, per-wallet caps on majors, and the factory (quotes, MemeHook, template, denylist, recorders, fee and
MAX_AUTO_MARKETS; use 0 and open listing through the timelock after step 7).
It skips GLD and AMC (no feed) and logs SKIPPED for each. Check that every Chainlink market prices (coin markets
price once their source has 180s of records and two signers are posting):
cast call $(jq -r .oracle deployments/4663.json) "getMark(bytes32)(uint256)" $(cast keccak ETH) --rpc-url $RPC_URL
5. Fund the treasury#
From the custody wallet, send USDG to the treasury wallet. Then build the approval (nothing is signed or sent):
(cd keeper && npm ci)
node ops/approve-vault.js --budget $BUDGET_USDG --rpc $RPC_URL
It writes ops/out/approve-vault-<ts>.json with a Safe Transaction Builder batch (import it in the treasury Safe) and
an unsigned transaction plus a cast send --ledger line for an EOA treasury. Approve only the budget, never unlimited;
the script refuses uint256.max. vault.treasuryAvailable() must equal the approved amount afterwards.
Allowance is consumed: every open pulls its reserve with transferFrom, and reserves coming back on close are plain
transfers that do not restore it. USDG at risk is totalReserved + allowance, which is why --budget approves
budget - totalReserved. Re-run it whenever the monitor alerts treasury-allowance-low.
6. Keepers#
Deploy per keeper/DEPLOY.md: one container per service (executor, liquidator, recorder, settler, three pricers on
three hosts with three signer keys, monitor + watchdog on their own host and RPC). First run with DRY_RUN=1, then
without. Confirm /health returns 200 on each, and npm run test-alert in the monitor container reaches
Telegram/Discord.
The recorder is not optional once MarketFactory is live: a listed coin that is never recorded never warms up, so
nobody can trade it, and RiskManager.poke is what runs the dead-coin clock and tier upgrades. Its key must be in
RECORDERS (Configure.s.sol, i.e. factory.setRecorder). Budget its gas from the table in keeper/README.md
("Recorder cadence"). At the 2026-09-23 cadence (60-75s for a market with positions, 25-30 min for an idle one)
and 0.0565 gwei: 0.0046 ETH/day per market that has open interest, 0.0002 ETH/day per idle one. Twenty
markets with three of them busy is ~0.017 ETH/day; all twenty busy at once is ~0.09 ETH/day. Fund it for a
month, not a week, and set recorder.dailyGasBudget if you want a hard stop.
Auto-listed coins trade without any signed price (capped at the Lite tier), so the pricers are not on the critical
path for them. Point pricer.autoSources at DexScreener on one signer and GeckoTerminal on another, and keep
autoSignMode: "active" so only markets with real exposure and depth are signed.
7. Governance handoff ⚠️ irreversible#
SAFE=0x... TIMELOCK_DELAY=172800 TWAP_SOURCES=0x..,0x..,0x..,0x..,0x..,0x.. \
forge script script/Handoff.s.sol --rpc-url robinhood --ledger --sender $DEPLOYER --broadcast --slow
Afterwards owner() of the vault, router, oracle, risk manager, factory and every TWAP source is the timelock, the deployer has no role (hasRole false for all
four), and getMinDelay() is 172800. Sweep the deployer's ETH and retire the key.
8. Smoke test#
With a small wallet: open $50 on ETH at 2x, close it, and confirm both keeper executions. Add collateral to a position,
then queue a close and let it expire to test emergencyDecrease (stop the executor for 3 minutes). Also have the
guardian set close-only on one market, confirm opens refund with IncreaseFailed(CloseOnly), then schedule the re-open
through the Safe.
9. More TWAP markets (after the launch coins)#
Permissionless: anyone calls MarketFactory.list(poolKey) (no fee at launch). The market is keeper-optional, starts
at the Lite ceiling and cannot open until its source has 180s of records and $14k of depth. list() writes the first
observation and the source takes its depthWindow from the factory template, so there is no owner follow-up: the fork
rehearsal measured 184 seconds from the listing transaction to a filled open. Keepers pick new markets up from
MarketListed, apply the off-chain listing policy (LAUNCH.md) and record, price and poke the ones that pass.
Governance (signers required, higher starting ceiling) goes through the Safe + timelock:
SYMBOL=XYZ TOKEN=0x... HOOK=<MemeHook or 0x0> POOL_FEE=.. TICK_SPACING=.. RECORDERS=0x..,0x.. GUARDIAN=0x.. \
OWNER=$(jq -r .timelock deployments/4663.json) \
forge script script/DeployTwapSource.s.sol --rpc-url robinhood --ledger --sender <any funded key> --broadcast
Add the source to the keeper config and let it record for at least 30 minutes. Then through the Safe + timelock,
schedule oracle.setMarket, oracle.setSpotGuard, vault.setMarket, vault.setRisk, vault.setMarketExtras(id, 2, 10000),
riskManager.setMarketRisk(id, 2 /* COIN */, 0, ceiling), factory.setTokenMarket(token, id) and the source's
acceptOwnership, using the coin presets in Configure.s.sol. Before listing, go through the listing rule in
LAUNCH.md (pool shape, dominant venue, real depth, friction) and check the pool's tax/fee against RISK.md §3.
Emergencies#
| Situation | Who | Action | Time |
|---|---|---|---|
| Keeper or signer key leaked | Guardian | router.removeKeeper, oracle.removeKeeper, twapSource.removeKeeper | instant |
| Price source looks wrong / pool exploit | Guardian | vault.setCloseOnly(market, true) or setGlobalCloseOnly(true) | instant |
| Stop all new risk from the treasury | Treasury wallet | USDG.approve(vault, 0): opens fail, closes keep working | instant |
Suspicious CallScheduled on the timelock | Safe | timelock.cancel(id) | within 48h |
| Payout breaker tripped | Guardian | Opens re-open by themselves after 1h; if the trip looks like an attack, vault.setCloseOnly(market, true) | instant |
Coin daily budget reached (GroupBudgetExceeded) | Guardian | Coin opens resume as payouts roll out of the 25h lookback; if one coin is being farmed, factory.delist(market) | instant |
| Bad auto listing (stock, stable, honeypot, squatted thin pool) | Guardian | factory.setTokenDenied([token], true), factory.delist(market); the Safe can setTokenMarket(token, 0) to allow a better pool | instant / 48h |
| Listing spam | Guardian | factory.setPaused(true); the Safe raises the fee or lowers maxActive | instant / 48h |
| Dead coin with open positions | Anyone | after 3 days below the floor (or 24h after delisting): vault.settle(market, accounts, sides, receiver) | 3 days |
| Leaked recorder key (auto markets) | Ops | Gas theft only since record() became permissionless (RISK.md 8.6a): sweep the key's ETH, generate a new one, factory.setRecorder(new, true) through the Safe, restart the recorder. factory.removeRecorder(old) is bookkeeping, not a defence: it does not stop anyone recording | instant |
| Keepers down | Users | Queue a decrease with a realistic acceptable price, wait 180s, router.emergencyDecrease(key) within the next hour | 3 min |
| Rotating a signer | Guardian + Safe | Remove the old signer first (guardian, instant), then add the new one (timelock): with 3 live signers a 4th would evict the oldest slot | 48h |
Coin risk operations#
Every parameter change below is an owner call, which means the timelock after the handoff. Generate the calls
unsigned, review the decoded output, then sign from the ops Safe. Nothing in ops/ ever sends.
cd keeper && npm ci # the generators use the keeper's ethers
node ops/risk-governance.js listing --max-active 50 --fee 100 --label open-listing
node ops/risk-governance.js tiers --tiers '[[14000,300,750,3],[35000,500,1500,3],[175000,1000,3000,3],[700000,2000,5000,5]]'
node ops/risk-governance.js budget --group 2 --daily 3000 --reserve-cap 7500
node ops/risk-governance.js hook --hook 0xE5e702641Ea86F4ae6cC3cDaeD2B886f976Be044 --fee-bps 600
node ops/risk-governance.js deny --tokens 0xaa..,0xbb..
node ops/risk-governance.js delist --market 0x<poolId>
Each run writes ops/out/<action>-<ts>.json with the decoded calls, the timelock schedule and execute steps as
Safe Transaction Builder batches, unsigned transactions and cast commands. The salt comes from --label, so the
same label reproduces the same operation id: use it to re-derive the execute step 48h later. Check the numbers
against PARAMS.md and RISK.md before signing.
Faster paths that do not need the timelock: the guardian can factory.setPaused(true), factory.setTokenDenied,
factory.delist and riskManager.delist immediately (see Emergencies).
Opening listing to the public. MAX_AUTO_MARKETS starts at 20. Before raising it: check
skew_factory_active_markets vs skew_factory_max_markets, confirm the recorder's gas for the new count against the
README table, and confirm the group daily budget still covers the extra exposure. Then run the listing generator.
Daily. Watch these alerts: group-budget:* (50/80/100%), new-listing:* (run the honeypot sell test and the
stock / stable / wrapped review on each), risk-status:* and dead-clock:*, recorder-lag:*, listing-capacity.
Routine#
- Weekly: claim the Pons tax to the custody wallet, convert ETH → USDG (ETH/USDG v4 pool, 1 bp tier), top up the treasury to the budget. Review every market listed that week (honeypot test, stock / stable / wrapped check) and deny + delist the ones that should not be there.
- Monthly:
node keeper/fetch-feeds.js; check signer, executor, recorder and settler ETH balances; compareskew_recorder_gas_todaywith the README estimate and re-budget if the market mix has changed. - Every December: extend
SetMarketHours.s.solwith next year's NYSE calendar and run it through the timelock.