English governs the contract statements on this page. Contract names, addresses and numbers are shown as they are on the chain.
KAY9 deployment
The runbook for the testnet rehearsal and the mainnet launch, the environment variables every step needs, the verification commands, and the template for the final launch report.
Everything here assumes Foundry on the path:
export PATH="$PATH:$HOME/.foundry/bin"
cd packages/contracts
0. Networks
| Mainnet | Testnet | |
|---|---|---|
| Chain id | 4663 | 46630 |
| RPC | https://rpc.mainnet.chain.robinhood.com | https://rpc.testnet.chain.robinhood.com |
| Explorer | https://robinhoodchain.blockscout.com | https://explorer.testnet.chain.robinhood.com |
| Faucet | — | https://faucet.testnet.chain.robinhood.com |
| Gas token | ETH | ETH |
| Block time | Chain's own ~0.10 s, which is the clock the auction and KAY9Genesis read (ArbSys); 24 h ≈ 864,000 blocks. block.number is the parent chain's and is not used | same |
The public RPC endpoints are rate limited. Use a private endpoint for anything that broadcasts.
1. Environment variables
Never put a private key in a file. Use --ledger, --trezor, or the Safe UI. The scripts read only addresses and numbers.
Shared
| Variable | Meaning |
|---|---|
OWNER_SAFE | The project owner's address. Owns KAY9Genesis and is proposer and executor on the timelock. Must not be the deployer. The name is historical. The owner decided on 2026-09-16 that this stays a single externally owned account, so the variable holds an EOA and the deployment is not behind a multisig. |
TEAM_BENEFICIARY | The initial team vesting beneficiary. Must not be the deployer. |
CREATOR_FEE_RECIPIENT | Receives the LP fee beneficiary NFTs, and therefore 40 % of native LP fees. Must not be the deployer. |
TGE_TIMESTAMP | The token generation event, as a unix timestamp. Set it no earlier than the planned migration: the 10,000,000 KAY9 tranche is releasable from this moment, and KAY9 held outside the launch before the official pool exists can create that pool through the LBP strategy (docs/SECURITY.md). |
UNLOCK_6M_TIMESTAMP | Six calendar months after the TGE. Produce it with ComputeVesting. |
UNLOCK_12M_TIMESTAMP | Twelve calendar months after the TGE. Produce it with ComputeVesting. |
AUDITORS | Comma-separated operator addresses, for example 0xa…,0xb…,0xc…. |
AUDITOR_THRESHOLD | The quorum. 2 at launch. |
SCANNERS | Required, DeployWatchdog.s.sol only. Comma-separated addresses authorised to commit basic-scan batches. Auditors are not scanners by default, and the deploying key may not be one. A scanner can only ever append, and cannot alter or remove a committed batch; the owner can revoke one at once. |
EXISTING_TIMELOCK | Optional, Deploy.s.sol only. The timelock the live watchdog already answers to. Set it at the token launch, or the launch deploys a second one. |
EXISTING_AUDITOR_REGISTRY | Optional, Deploy.s.sol only. The auditor registry the live watchdog already uses. The script checks its quorum, its members and its owner against what the launch expects and refuses a mismatch. |
ETH_USD_FEED | Optional, Launch.s.sol only. Overrides the Chainlink ETH/USD feed the launch script reads to print implied FDV in USD. Display only: nothing in the access path reads a price feed. Defaults to the address book entry. |
INITIALIZER_HOOK | Optional override of the pool's initializer hook. Defaults to the address book entry, 0xD462a559337859369EF271814851A18F496ba000. KAY9Genesis rejects anything that is not a live IInitializerHook whose authorized() is the LBP strategy and whose address flags are exactly beforeInitialize. |
Deployment only
| Variable | Meaning |
|---|---|
MAINNET_CONFIRM | Must be exactly I_AM_THE_OWNER for the script to run against chain 4663. |
| - | There is no broadcast environment variable. Transactions are sent only when forge is invoked with --broadcast; a bare forge script run always simulates. |
Launch only
| Variable | Meaning |
|---|---|
GENESIS | The deployed KAY9Genesis address. |
FLOOR_FDV_USD | The floor valuation in whole US dollars. |
GRADUATION_FDV_USD | The graduation valuation in whole US dollars. |
DURATION_HOURS | The auction length in hours. 24 for the planned launch (owner, 2026-09-25), which is also the most the vault accepts. |
START_DELAY_MINUTES | How long after the transaction the auction starts. |
LAUNCH_SALT | Optional. Defaults to 1. Changing it changes the predicted auction address. |
Fork testing only
| Variable | Meaning |
|---|---|
ROBINHOOD_RPC_URL | Enables the fork suite. Absent means the suite skips itself. |
ROBINHOOD_FORK_BLOCK | Optional pinned block, which lets Foundry cache and stops the public RPC rate limiting you. |
2. Pre-flight
forge fmt --check
forge build
forge test -vv
FOUNDRY_PROFILE=ci forge test -vv
forge snapshot
ROBINHOOD_RPC_URL=https://rpc.mainnet.chain.robinhood.com forge test --match-path "test/fork/*" -vv
./export-abis.sh
# Static analysis, which needs the plain solc platform on this toolchain.
solc-select install 0.8.26 && solc-select use 0.8.26
slither src/KAY9Genesis.sol --compile-force-framework solc --solc "$HOME/.solc-select/artifacts/solc-0.8.26/solc-0.8.26" --config-file slither.solc.json
slither src/KAY9AuditHub.sol --compile-force-framework solc --solc "$HOME/.solc-select/artifacts/solc-0.8.26/solc-0.8.26" --config-file slither.solc.json
Every one of those must be green before anything is broadcast.
Compute the vesting dates and record them:
TGE_TIMESTAMP=<unix> forge script script/ComputeVesting.s.sol:ComputeVesting -vvv
It prints the two unlock timestamps as exact UTC dates, keeping the day of month and the time of day, clamping to the last day of the month when the target month is too short. Copy the three *_TIMESTAMP lines into the environment.
2b. Deploying the watchdog, which happens first
The watchdog goes live before $KAY9 exists. It is a separate script with a separate, much smaller blast radius: no token, no access vault, and an administrative surface of "add or remove an auditor" and "authorise or de-authorise a scanner".
cd packages/contracts
export OWNER_SAFE=0x… # must not be the deployer
export AUDITORS=0xa…,0xb…,0xc…
export AUDITOR_THRESHOLD=2
export SCANNERS=0x… # required; never the deployer
# simulate; nothing is sent without --broadcast
forge script script/DeployWatchdog.s.sol --rpc-url $ROBINHOOD_RPC_URL
# mainnet needs the phrase, exactly
MAINNET_CONFIRM=I_AM_THE_OWNER forge script script/DeployWatchdog.s.sol \
--rpc-url $ROBINHOOD_RPC_URL --broadcast --ledger
It deploys a 48-hour TimelockController, KAY9AuditorRegistry owned by it, KAY9ScanRegistry, and — this is the part that surprises people — KAY9Registry and KAY9AuditHub as well, with no access vault.
The hub has to come up now rather than at the token launch. KAY9Registry binds to its hub immutably, so whichever hub the registry is deployed against is the hub for good; deploying the pair later would mean either stranding every report published in the meantime, or having no permanent record for them. The vault holds KAY9 and cannot exist before the token, so the hub takes a zero vault, requestAudit reverts AccessVaultNotSet, and publishWatchdogReport — which an auditor submits and which consumes no quota — works immediately. That is the mechanism behind beta deep and forensic reports: see docs/ACCESS_MODEL.md §8.1.
Every contract it deploys is owned by the timelock from its constructor, so there is no acceptOwnership to queue and the deploying key holds no role afterwards. The script refuses the deployer as owner, auditor or scanner, and runs only on Robinhood Chain mainnet and testnet. The owner address is the scan registry's guardian: it can revoke a scanner at once, and nothing else. Check owner() on all four contracts against the printed timelock anyway. The guardian can be replaced later through the timelock with setGuardian.
A removal that leaves fewer auditors than the threshold halts the quorum until setThreshold runs, so rotate an auditor as one timelock batch: add the new key, remove the old one, set the threshold.
The report it prints ends with two things that must be acted on:
Write down all four reuse addresses. The token launch must be given EXISTING_TIMELOCK, EXISTING_AUDITOR_REGISTRY, EXISTING_REPORT_REGISTRY and EXISTING_AUDIT_HUB. Without them Deploy.s.sol stands up a second auditor registry and a second registry-and-hub pair, and every scan and report committed before the launch ends up answering to contracts nobody is reading — silent at the time, permanent afterwards. The script validates all four and refuses a mismatch, but it cannot invent them.
Except at the November 2026 launch, which replaces the registry-and-hub pair. The pair DeployWatchdog.s.sol put on mainnet on 25 Sep 2026 (hub 0x3d5Dac2b…36dA, registry 0x3bD24351…5068) predates the 21 Oct fix: its hub does not pin a job's analysis to the request time and its registry calls the last write latest. It has never recorded a report or a job (reportCount() == 0, jobCount() == 0, read on 21 Oct). So the launch is given EXISTING_TIMELOCK and EXISTING_AUDITOR_REGISTRY only, and Deploy.s.sol deploys a fresh pair bound to the same timelock and auditors. Until then nothing may be published to the old pair; read both counts again just before the launch, and stop if either is not zero. Deploy.s.sol refuses either old address as EXISTING_REPORT_REGISTRY or EXISTING_AUDIT_HUB.
Right after that launch, everything off-chain that names the old pair moves to the new one. The EIP-712 domain includes the hub's address, so an auditor left on the old hub signs for a contract nobody requests from: jobs on the new hub then never reach two of three and expire.
packages/chain/deployments/4663.json:KAY9Registry,KAY9AuditHuband bothcodeHashes; thennpm run build -w @kay9/chain,apps/web/api/deployment.json, and the site deploy.- All three auditors:
AUDIT_HUB_ADDRESS,REGISTRY_ADDRESSandAUDIT_HUB_START_BLOCK(the new hub's deployment block) — auditor A in the Azure job's environment, B in the GitHub Actions secrets, C wherever its operator runs it — and each one restarted and seen reading the new hub. services/social-bot/facts.md(the bot says the pair is replaced at launch) and the address table of the public repository, throughscripts/sync-protocol.mjs.- The old pair stays writable for ever: its hub still accepts
publishWatchdogReportfrom the same auditors. Read the old registry'sreportCount()once a day for the first week; anything above zero is an auditor still configured for it.
At launch, bind the vault. The token deployment prints a setAccessVault(address) proposal for the timelock to queue. Until it executes, requestAudit still reverts and nothing can spend quota. It can be called once and never again, so check the address before signing it.
What it costs to run once it is up
Measured on mainnet 2026-09-08, at 0.3086 gwei and ETH at $2,479.20. Full working in docs/WATCHDOG.md §12.
| Scans/day | Indexing everything | Indexing graduations and elevated scores |
|---|---|---|
| 0 | $0 | $0 |
| 100 | $47/month | $7/month |
| 1,000 | $567/month | $37/month |
| 10,000 | $5,670/month | $347/month |
Every scan is committed to a Merkle root and provable either way; the difference is only which assets get a one-call on-chain lookup. Off-chain, discovery and 100 scans a day fit inside the free grants; 1,000 needs one always-on worker and a dedicated RPC; 10,000 needs four or more concurrent workers and is not reachable on the public endpoint, which refuses three eth_getLogs calls in a row.
3. Testnet rehearsal
Testnet 46630 has the Uniswap v4 PoolManager, PositionManager, the CCA factory, Permit2 and Multicall3 at the same addresses as mainnet. It has no LiquidityLauncher, no LBPStrategy, no FeeSplitter, no beneficiary vault and no Chainlink feed. The rehearsal script deploys the launcher stack from the same upstream sources the mainnet contracts were built from. The missing feed only affects the USD figures Launch.s.sol prints; nothing in the access path reads one.
forge script script/Testnet.s.sol:Testnet \
--rpc-url https://rpc.testnet.chain.robinhood.com \
--broadcast -vvv
Two things about the rehearsal clock, both measured on 2026-09-16. Testnet 46630 runs at about 0.208 s per block, roughly half mainnet's cadence, so a window derived at 100 ms per block takes about twice as long in wall-clock terms as it says: a one-hour auction is a two-hour rehearsal. And currencyRaised() reads the last checkpoint, which only moves when somebody sends a transaction, so a quiet auction reports a stale figure until checkpoint() is called. Neither is a fault; both are surprising the first time.
bid() cannot be run just after launch(). It bids requiredCurrencyRaised at four times the current price, and early in a convex schedule almost no supply has been emitted, so that amount consumes everything on offer and pushes the clearing price up to the bid's own maximum. The auction then rejects it with BidMustBeAboveClearingPrice, because the check is strictly greater. Bid once a real share of the supply is out, or bid a smaller amount at a higher maximum with cast — in a continuous clearing auction the bidder pays the final clearing price, not the maximum they named, so a generous maximum costs nothing and only keeps the bid alive.
script/Rehearse.s.sol runs every step below as its own broadcast — launch(), bid(), graduate(), migrate(), govSchedule() / govExecute(), requirementSchedule() / requirementExecute(), claim(), lockAccess(), request(), attestPair(), dispute(), expire(), upgradeAccess(), renew(), unlock() — reading the printed addresses and the throwaway testnet keys from the environment (forge script script/Rehearse.s.sol:Rehearse --sig "launch()" …), so every transaction hash lands in broadcast/Rehearse.s.sol/46630/. The cast sequence below is the same rehearsal spelled out by hand:
# 1. Derive the parameters.
GENESIS=<testnet genesis> FLOOR_FDV_USD=1000 GRADUATION_FDV_USD=10000 \
DURATION_HOURS=1 START_DELAY_MINUTES=2 \
forge script script/Launch.s.sol:Launch --rpc-url https://rpc.testnet.chain.robinhood.com -vvv
# 2. Send the calldata from script/output/launch-calldata.json as the owner.
cast send <genesis> "$(python -c "import json;print(json.load(open('script/output/launch-calldata.json'))['data'])")" \
--rpc-url https://rpc.testnet.chain.robinhood.com --ledger
# 3. Bid, from several accounts, across the window.
cast send <auction> "submitBid(uint256,uint128,address,bytes)" <priceQ96> <amount> <bidder> 0x \
--value <amount> --rpc-url ... --ledger
# 4. After the end block.
cast send <auction> "checkpoint()" --rpc-url ...
cast call <auction> "isGraduated()(bool)" --rpc-url ...
# 5. After the migration block, anyone can migrate.
cast send <lbpStrategy> "migrate(address)" <auction> --rpc-url ...
# 6. Lock the LP NFT, then settle the unsold supply.
cast call <positionManager> "nextTokenId()(uint256)" --rpc-url ...
cast send <liquidityLock> "lock(uint256)" <tokenId - 1> --rpc-url ...
cast send <genesis> "settle()" --rpc-url ...
# 7. Read the lock requirement. It is a stored number; nothing has to be bound or warmed up.
cast call <accessVault> "requirementOf(uint8)(uint256)" 1 --rpc-url ... # 5000e18
cast call <accessVault> "requirementOf(uint8)(uint256)" 2 --rpc-url ... # 10000e18
3.1 Rehearse the access model and the audit protocol
The launch half of the rehearsal proves the token exists and the pool is locked. This half proves the part that handles other people's money, and it is the half that must not be skipped. Every step below has to be executed at least once on testnet, in this order, and the last one is the one that matters most.
Nothing has to be warmed up first: the requirement is requirementOf(tier), a number stored in the vault, so the access model works from the moment the depositor holds KAY9.
export RPC=https://rpc.testnet.chain.robinhood.com
CHAIN_KEY=$(cast keccak "eip155:46630")
ASSET_ID=$(cast call $REGISTRY "evmAssetId(address)(bytes32)" <some token> --rpc-url $RPC)
# 1. LOCK. Read the requirement, approve, lock a deep period, and check the quota that came with it.
cast call $VAULT "requirementOf(uint8)(uint256)" 1 --rpc-url $RPC
cast send $KAY9 "approve(address,uint256)" $VAULT $MAX --rpc-url $RPC --private-key $PK_USER
cast send $VAULT "lock(uint8,uint256)" 1 $MAX --rpc-url $RPC --private-key $PK_USER
cast call $VAULT "accessOf(address)((uint8,uint64,uint64,uint32,uint32,uint32,uint32,uint256))" $USER --rpc-url $RPC
cast call $VAULT "deepRemaining(address)(uint32)" $USER --rpc-url $RPC # expect 4
# Also check the refusals: a second lock while one is live, and a forensic request on a deep period.
cast send $VAULT "lock(uint8,uint256)" 1 $MAX --rpc-url $RPC --private-key $PK_USER # expect AccessLive
cast call $VAULT "canRequest(address,uint8)(bool)" $USER 2 --rpc-url $RPC # expect false
# 2. REQUEST. One quota unit spent, and nothing else.
cast send $HUB "requestAudit(bytes32,bytes32,uint8,uint8)" $CHAIN_KEY $ASSET_ID 1 1 \
--rpc-url $RPC --private-key $PK_USER
cast call $VAULT "deepRemaining(address)(uint32)" $USER --rpc-url $RPC # expect 3
cast call $KAY9 "balanceOf(address)(uint256)" $HUB --rpc-url $RPC # expect 0, always
# 3. ATTEST WITH TWO AUDITORS. The ordinary path: two agreeing signatures finalise the job.
# Produce both signatures over hashResult(jobId, result), then send them in one call.
cast call $HUB "hashResult(uint256,(bytes32,bytes32,uint8,uint8,uint8,uint8,uint8,uint8,uint8,uint8,uint64,uint32,uint64,bytes32,string))(bytes32)" \
1 "$RESULT" --rpc-url $RPC
cast send $HUB "attest(uint256,(bytes32,bytes32,uint8,uint8,uint8,uint8,uint8,uint8,uint8,uint8,uint64,uint32,uint64,bytes32,string),bytes[])" \
1 "$RESULT" "[$SIG_A,$SIG_B]" --rpc-url $RPC --private-key $PK_RELAY
cast call $HUB "getJob(uint256)((address,bytes32,bytes32,uint8,uint8,uint64,uint64,uint64,uint8,uint8,uint256))" 1 --rpc-url $RPC
# status 2 = Fulfilled. Then check the registry read surface an integrator will use:
cast call $REGISTRY "latestSummary(bytes32,bytes32)(bool,uint256,uint8,uint64,uint32,uint64)" $CHAIN_KEY $ASSET_ID --rpc-url $RPC
cast call $REGISTRY "scoreHistory(bytes32,bytes32,uint256,uint256)(uint64[],uint8[])" $CHAIN_KEY $ASSET_ID 0 100 --rpc-url $RPC
# 4. DISPUTE WITH THREE AUDITORS. Request a second audit, then have each of the three attest a
# DIFFERENT result, one transaction each. The third one must flip the job to Disputed.
cast send $HUB "requestAudit(bytes32,bytes32,uint8,uint8)" $CHAIN_KEY $ASSET_ID 1 1 --rpc-url $RPC --private-key $PK_USER
cast send $HUB "attest(...)" 2 "$RESULT_A" "[$SIG_A]" --rpc-url $RPC --private-key $PK_A
cast send $HUB "attest(...)" 2 "$RESULT_B" "[$SIG_B]" --rpc-url $RPC --private-key $PK_B
cast send $HUB "attest(...)" 2 "$RESULT_C" "[$SIG_C]" --rpc-url $RPC --private-key $PK_C
cast call $HUB "getJob(uint256)(...)" 2 --rpc-url $RPC # status 3 = Disputed
cast call $VAULT "deepRemaining(address)(uint32)" $USER --rpc-url $RPC # the unit came back
cast call $HUB "attestationOf(uint256,address)(bytes32)" 2 $AUDITOR_A --rpc-url $RPC # each position readable
# Also confirm an auditor cannot take a second position on the same job:
cast send $HUB "attest(...)" 2 "$RESULT_A" "[$SIG_A]" --rpc-url $RPC --private-key $PK_A # expect AlreadyAttested
# 5. EXPIRY. Request a third audit, let nobody answer it, and expire it.
# Lower the SLA to its 1 hour minimum through the timelock first, or the wait is six hours.
cast send $HUB "requestAudit(bytes32,bytes32,uint8,uint8)" $CHAIN_KEY $ASSET_ID 1 1 --rpc-url $RPC --private-key $PK_USER
cast call $HUB "jobExpiresAt(uint256)(uint64)" 3 --rpc-url $RPC
cast send $HUB "markExpired(uint256)" 3 --rpc-url $RPC --private-key $PK_ANYONE # expect NotExpired before
# ... after the SLA elapses:
cast send $HUB "markExpired(uint256)" 3 --rpc-url $RPC --private-key $PK_ANYONE
cast call $VAULT "deepRemaining(address)(uint32)" $USER --rpc-url $RPC # the unit came back
# 6. RENEWAL. Refused before expiry; settles against the current requirement and resets the quota after it.
cast send $VAULT "renew(uint8,uint256)" 1 $MAX --rpc-url $RPC --private-key $PK_USER # expect NotExpired
# ... after expiresAt, with the allowance partly spent:
cast send $KAY9 "approve(address,uint256)" $VAULT $MAX --rpc-url $RPC --private-key $PK_USER
cast send $VAULT "renew(uint8,uint256)" 1 $MAX --rpc-url $RPC --private-key $PK_USER
cast call $VAULT "deepRemaining(address)(uint32)" $USER --rpc-url $RPC # expect 4 again
# Check the balance moved by the DIFFERENCE only, in whichever direction the requirement went.
# 7. UNLOCK. The whole principal, back to the depositor, after the period ends.
BEFORE=$(cast call $KAY9 "balanceOf(address)(uint256)" $USER --rpc-url $RPC)
cast send $VAULT "unlock()" --rpc-url $RPC --private-key $PK_USER
cast call $KAY9 "balanceOf(address)(uint256)" $USER --rpc-url $RPC # BEFORE + the whole lockedKay9
cast call $VAULT "totalLocked()(uint256)" --rpc-url $RPC # back to 0 if nobody else holds one
Two things to assert deliberately while doing this, because they are the claims the whole model rests on:
kay9.balanceOf(auditHub)is zero at every point. If it is ever non-zero, something moved money that should not have.- The depositor's balance after step 7 is exactly what it was before step 1, less gas. Spending the allowance must not cost principal. Rehearse the version of this where all four deep audits are used before unlocking, because that is the case a fee-based model would get wrong.
Also rehearse upgrade if the forensic tier will be offered at launch: lock deep, spend one deep audit, upgrade, and confirm deepUsed survived, the expiry did not move, and one forensic slot appeared.
Steps 6 and 7 cannot run on the day the period opens. KAY9AccessVault.MIN_LOCK_DURATION is seven days and there is no way to shorten a live period, so a rehearsal that opens its period on day one renews and unlocks on day eight at the earliest. Set the period to the minimum through the timelock before locking (the governance batch in Rehearse.s.sol does), record expiresAt, and come back: script/rehearse-renew-unlock.sh runs the remaining steps.
Also rehearse setRequirement (requirementSchedule() / requirementExecute() in Rehearse.s.sol, with TIER and KAY9 from the environment): schedule a change through the timelock while a period is live, execute it after the delay, and confirm that the live period's lockedKay9 did not move, that unlock returns exactly that amount, and that the next lock or renew uses the new number.
What the testnet rehearsal does not prove
The launcher, the strategy, the fee splitter, the beneficiary vault and the compounding recipient live at different addresses on testnet and are freshly compiled rather than the deployed mainnet bytecode. The rehearsal therefore proves the whole flow and every KAY9 behaviour, but it does not prove the exact bytecode of those five dependencies. That gap is covered by the fork suite, which runs the same flow against the real deployed contracts at their real mainnet addresses.
There is no oracle to exercise. The one asymmetry worth rehearsing against a real deployment is the requirement change: setRequirement executed through the timelock changes what the next period locks and never what a live one already holds, and unlock returns exactly lockedKay9 regardless of any change. That is the vault's central safety property.
4. Mainnet deployment
4.1 Simulate
OWNER_SAFE=0x… TEAM_BENEFICIARY=0x… CREATOR_FEE_RECIPIENT=0x… \
TGE_TIMESTAMP=… UNLOCK_6M_TIMESTAMP=… UNLOCK_12M_TIMESTAMP=… \
AUDITORS=0x…,0x…,0x… AUDITOR_THRESHOLD=2 \
MAINNET_CONFIRM=I_AM_THE_OWNER \
forge script script/Deploy.s.sol:Deploy \
--rpc-url https://rpc.mainnet.chain.robinhood.com -vvv
The script refuses to run on chain 4663 without MAINNET_CONFIRM and refuses any project address that equals the deployer. Without --broadcast it only simulates and writes a dry-run file. Read the printed report, which lists every address, every role and every runtime bytecode hash, before going further.
4.2 Broadcast
<the same environment as above> \
forge script script/Deploy.s.sol:Deploy \
--rpc-url https://rpc.mainnet.chain.robinhood.com \
--broadcast --ledger -vvv
The order is fixed by what each constructor needs, and two steps in it are easy to get wrong:
TimelockController.KAY9Genesis, which in its own constructor deploys the token, the vesting contract and the liquidity lock, and funds vesting.KAY9AuditorRegistry.KAY9AccessVault, before the hub, becauseKAY9AuditHubtakes the vault's address in its own constructor. The vault cannot take the hub's address the same way — the two would each need the other to exist first — so the vault is deployed owned by the deployer and is pointed at the hub afterwards.KAY9Registry, constructed with the hub address predicted from the deployer's next nonce.KAY9AuditHub. The script asserts it landed at the address the registry was told to expect.accessVault.setAuditHub(auditHub), from the deployer, while it still owns the vault. Until this runs,requestAuditrevertsNotTheAuditHubfor everybody, because the vault'sauditHubis still the zero address.accessVault.transferOwnership(timelock), handing the vault to governance.
The vault's constructor sets requirementOf(1) to 5,000 KAY9 and requirementOf(2) to 10,000 KAY9; there is nothing to configure and no oracle to bind.
Step 8 is Ownable2Step, so it only proposes the timelock as owner. The transfer is not complete until the timelock calls acceptOwnership() on the vault, which is a scheduled and executed governance action like any other and therefore takes 48 hours. Until it completes, the deployer key is still the vault's owner. Schedule it immediately after deployment and do not treat the deployment as finished until §4.4 shows owner() is the timelock:
cast send <timelock> "schedule(address,uint256,bytes,bytes32,bytes32,uint256)" \
<accessVault> 0 $(cast calldata "acceptOwnership()") 0x0 0x0 172800 --rpc-url $RPC --ledger
# 48 hours later
cast send <timelock> "execute(address,uint256,bytes,bytes32,bytes32)" \
<accessVault> 0 $(cast calldata "acceptOwnership()") 0x0 0x0 --rpc-url $RPC --ledger
Deploy.s.sol prints the vault's address, its pending-owner instruction and its bytecode hash in the deployment report; copy them into §6 from the printed report, not from memory.
The pool key cannot be squatted.KAY9Genesisdeploys the KAY9 token in its constructor, which makes the migration pool key(ETH, KAY9, fee 10000, tickSpacing 200, InitializerHook)public and computable — but the key is hooked on Uniswap's canonicalInitializerHook(0xD462a559337859369EF271814851A18F496ba000), whoseauthorized()is the LBP strategy, soPoolManager.initializeon it reverts for everyone except the strategy's own migration. There is therefore no window in which a stranger can pre-initialize the pool and makeMigratorParams.validateHookbricklaunch(). The vault re-validates the hook in its constructor and refuses to deploy against anything else.
4.3 Verify on Blockscout
forge verify-contract <address> src/KAY9Token.sol:KAY9Token \
--chain-id 4663 \
--verifier blockscout \
--verifier-url https://robinhoodchain.blockscout.com/api/ \
--constructor-args $(cast abi-encode "constructor(address)" <genesis>)
Repeat for every contract. The constructor arguments are:
| Contract | cast abi-encode signature |
|---|---|
| KAY9Token | constructor(address) |
| KAY9TeamVesting | constructor(address,address,uint64,uint64,uint64) |
| KAY9LiquidityLock | constructor(address,address,address,address) |
| KAY9Genesis | constructor(address,address,uint64,uint64,uint64,address,address,address,address,address,address,address,address,address) (the last argument is the InitializerHook) |
| KAY9AuditorRegistry | constructor(address,address[],uint8) |
| KAY9AccessVault | constructor(address,address) (owner, KAY9) |
| KAY9Registry | constructor(address) |
| KAY9AuditHub | constructor(address,address,address,address) (owner, registry, auditor registry, access vault) |
| TimelockController | constructor(uint256,address[],address[],address) |
4.4 Post-deployment checks
cast call <token> "totalSupply()(uint256)" --rpc-url $RPC
cast call <token> "balanceOf(address)(uint256)" <genesis> --rpc-url $RPC # 910000000e18
cast call <token> "balanceOf(address)(uint256)" <vesting> --rpc-url $RPC # 90000000e18
cast call <genesis> "owner()(address)" --rpc-url $RPC # the owner address
cast call <auditHub> "owner()(address)" --rpc-url $RPC # the timelock
cast call <auditorRegistry> "owner()(address)" --rpc-url $RPC # the timelock
cast call <timelock> "getMinDelay()(uint256)" --rpc-url $RPC # 172800
cast call <registry> "auditHub()(address)" --rpc-url $RPC # the hub
# The access vault. The first two are the wiring; the rest is the published configuration.
cast call <accessVault> "auditHub()(address)" --rpc-url $RPC # the hub, not 0x0
cast call <accessVault> "owner()(address)" --rpc-url $RPC # the timelock once acceptOwnership has executed
cast call <accessVault> "pendingOwner()(address)" --rpc-url $RPC # the timelock until then, 0x0 afterwards
cast call <accessVault> "kay9()(address)" --rpc-url $RPC # the token
cast call <accessVault> "lockDuration()(uint64)" --rpc-url $RPC # 2592000 (30 days)
cast call <accessVault> "deepQuotaOf(uint8)(uint32)" 1 --rpc-url $RPC # 4
cast call <accessVault> "forensicQuotaOf(uint8)(uint32)" 1 --rpc-url $RPC # 0
cast call <accessVault> "deepQuotaOf(uint8)(uint32)" 2 --rpc-url $RPC # 4
cast call <accessVault> "forensicQuotaOf(uint8)(uint32)" 2 --rpc-url $RPC # 1
cast call <accessVault> "totalLocked()(uint256)" --rpc-url $RPC # 0
cast call <accessVault> "requirementOf(uint8)(uint256)" 1 --rpc-url $RPC # 5000000000000000000000 (5,000 KAY9)
cast call <accessVault> "requirementOf(uint8)(uint256)" 2 --rpc-url $RPC # 10000000000000000000000 (10,000 KAY9)
cast call <auditHub> "slaSeconds()(uint64)" --rpc-url $RPC # 21600 (6 hours)
cast call <lock> "feeSplitter()(address)" --rpc-url $RPC # 0xeFF1…
cast call <lock> "creatorFeeRecipient()(address)" --rpc-url $RPC
cast call <genesis> "poolHook()(address)" --rpc-url $RPC # 0xD462a559337859369EF271814851A18F496ba000
cast call <genesis> "poolHook()(address)" --rpc-url $RPC | xargs -I{} cast call {} "authorized()(address)" --rpc-url $RPC # the LBPStrategy
5. Mainnet launch
5.0 Optional: launch atomically with the genesis deployment
This used to be mandatory, because the pool key was hookless and squattable. It no longer is: the official pool is keyed on the canonical InitializerHook, which only the LBP strategy can initialize, so leaving a deployed KAY9Genesis unlaunched is safe. Doing both steps in one Safe transaction is still a reasonable shape if you want the launch to be a single reviewable artefact.
Derive the parameters first (5.1), then build one Safe transaction that does both steps:
MultiSendCallOnlycall 1:0x4e59b44847b379578588920cA78FbF26c0B4956C(the deterministic CREATE2 deployer) withsalt || KAY9Genesis creationCode || abi.encode(constructor args). The resulting address iscreate2(0x4e59…, salt, keccak256(initCode)); compute it withcast create2 --starts-with "" --init-code-hash <hash>or off-chain, and pin it.MultiSendCallOnlycall 2: that address,launch(params)from section 5.1.
The Safe is msg.sender for call 2 because MultiSend runs by delegatecall, so onlyOwner is satisfied by the owner the constructor set in call 1. Simulate the whole bundle before signing and check that launchCount() is 1 and auction() is the predicted address afterwards. If you deploy and launch separately instead, section 5.2's checks are the same, with to being the deployed KAY9Genesis.
START_DELAY_MINUTES must leave enough room for the Safe to collect signatures: startBlock is checked against block.number when the bundle executes, not when the script ran.
5.1 Derive and review the parameters
GENESIS=<genesis> FLOOR_FDV_USD=<n> GRADUATION_FDV_USD=<n> \
DURATION_HOURS=24 START_DELAY_MINUTES=<n> \
forge script script/Launch.s.sol:Launch \
--rpc-url https://rpc.mainnet.chain.robinhood.com -vvv
The script reads the live Chainlink ETH/USD price (display only, for the dollar figures it prints), derives the block window, the Q96 floor price snapped to the auction tick grid, the graduation threshold and the twelve-step convex emission schedule with a single large final block, asserts the schedule satisfies the auction's own invariants, prints the implied valuations in both wei and dollars, prints the predicted auction address, and writes script/output/launch-calldata.json. It never broadcasts.
5.2 Execute from the owner address
Create a Safe transaction to KAY9Genesis with value 0 and the data field from the JSON, or put that data into call 2 of the optional atomic bundle from section 5.0. Verify, before signing:
- the
toaddress is the deployedKAY9Genesis(or the CREATE2 address call 1 deploys it to) - the decoded
startBlock,endBlock,claimBlockandmigrationBlockmatch the printed UTC times - the decoded
floorPriceQ96andrequiredCurrencyRaisedmatch the printed valuations - the
predictedAuctionmatches whatpreviewLaunchreturns from a freshcast call
5.3 During and after the auction
cast call <auction> "clearingPrice()(uint256)" --rpc-url $RPC
cast call <auction> "currencyRaised()(uint256)" --rpc-url $RPC
cast call <auction> "isGraduated()(bool)" --rpc-url $RPC
cast call <genesis> "launchState()(uint8)" --rpc-url $RPC
After the migration block, anyone runs one transaction, the one the site's button sends:
cast send <genesis> "migrateAndSettle()" --rpc-url $RPC --ledger
It migrates, locks every position the migration minted and settles, so no other transaction can land between the migration and the recorded outcome. Send it as soon as the migration block arrives: until then LBPStrategy.migrate is callable by anyone directly, and a direct migration makes migrateAndSettle revert. If that happens, finish by hand:
cast send <liquidityLock> "track(uint256)" <tokenId> --rpc-url $RPC --ledger # each migration position
cast send <liquidityLock> "lock(uint256)" <tokenId> --rpc-url $RPC --ledger
cast send <genesis> "settle()" --rpc-url $RPC --ledger
If settle() or recover() reverts UnsoldNotSwept, the auction could not hand over its unsold supply, most likely because its own rounding left it a few wei of KAY9 short. Send the auction that dust and call again; nothing is lost while it waits.
There is no oracle to bind and no off-chain process to start: access locks work as soon as bidders hold KAY9. The owner's checklist after launch is short:
- Confirm
owner()on the vault is the timelock (§4.4). IfacceptOwnershiphas not executed yet, the deployer key still owns the vault. - Review the lock requirements against the clearing price. The vault shipped with
requirementOf(1) = 5,000 KAY9andrequirementOf(2) = 10,000 KAY9. If the auction's clearing price makes those numbers unreasonable in either direction, schedulesetRequirementthrough the timelock. The bounds are 1 KAY9 to 10,000,000 KAY9 with forensic never below deep, and a change reaches only periods opened or renewed after it executes:
# schedule (tier 1 = deep, tier 2 = forensic; amount in KAY9 wei)
cast send <timelock> "schedule(address,uint256,bytes,bytes32,bytes32,uint256)" \
<accessVault> 0 $(cast calldata "setRequirement(uint8,uint256)" 1 <kay9Wei>) \
0x0 0x0 172800 --rpc-url $RPC --ledger
# 48 hours later
cast send <timelock> "execute(address,uint256,bytes,bytes32,bytes32)" \
<accessVault> 0 $(cast calldata "setRequirement(uint8,uint256)" 1 <kay9Wei>) \
0x0 0x0 --rpc-url $RPC --ledger
cast call <accessVault> "requirementOf(uint8)(uint256)" 1 --rpc-url $RPC
- Say nothing about a dollar value. The site shows the requirement read from
requirementOfas an amount of KAY9, never converted, never hardcoded.
If the auction does not graduate:
cast send <auction> "checkpoint()" --rpc-url $RPC # records the end block; launchState reads 2 until then, 4 after
cast send <genesis> "markFailed()" --rpc-url $RPC # starts the 48 h cooldown
# 48 hours later, the owner may launch again with new pricing. The relaunch calls
# migrate(auction) on the strategy itself when nobody has, which returns the 455 M reserve;
# it reverts before the auction's migration block.
If the auction graduates but migration fails:
cast send <genesis> "recover()" --rpc-url $RPC # permissionless; builds and locks the pool
6. Final mainnet launch report
Fill this in after the launch and publish it. Every field is verifiable on-chain.
Addresses
| Item | Value |
|---|---|
| Chain | Robinhood Chain, id 4663 |
| Deployer | 0x… |
| Deployment block / tx | |
| KAY9Token | 0x… |
| KAY9TeamVesting | 0x… |
| KAY9Genesis | 0x… |
| KAY9LiquidityLock | 0x… |
| KAY9AuditorRegistry | 0x… |
| KAY9AccessVault | 0x… |
| KAY9Registry | 0x… |
| KAY9AuditHub | 0x… |
| TimelockController | 0x… |
| Predicted auction address | 0x… |
| Actual auction address | 0x… |
| Uniswap v4 pool id | 0x… |
| Migration LP position id | |
| Settlement LP position id |
Bytecode hashes
| Contract | Runtime codehash |
|---|---|
| KAY9Token | 0x… |
| KAY9TeamVesting | 0x… |
| KAY9Genesis | 0x… |
| KAY9LiquidityLock | 0x… |
| KAY9AuditorRegistry | 0x… |
| KAY9AccessVault | 0x… |
| KAY9Registry | 0x… |
| KAY9AuditHub | 0x… |
| TimelockController | 0x… |
Owners and beneficiaries
| Role | Address |
|---|---|
| Owner address (Genesis owner, timelock proposer and executor; a single account, not a Safe) | 0x… |
| Timelock admin | the timelock itself |
| Team vesting beneficiary | 0x… |
| Creator-fee recipient (beneficiary NFT holder) | 0x… |
KAY9AccessVault owner | the timelock, with acceptOwnership executed at block / tx |
| Recipient of any audit fee | none. There is no audit fee. |
Vesting
| Tranche | Amount | UTC date | Unix |
|---|---|---|---|
| TGE | 10,000,000 KAY9 | ||
| TGE + 6 calendar months | 40,000,000 KAY9 | ||
| TGE + 12 calendar months | 40,000,000 KAY9 |
Allocations
| Allocation | Amount | Share |
|---|---|---|
| Public fair auction | 455,000,000 | 45.5 % |
| Permanent liquidity reserve | 455,000,000 | 45.5 % |
| Team | 90,000,000 | 9 % |
| Total | 1,000,000,000 | 100 % |
Auction configuration
| Item | Value |
|---|---|
| Start block / UTC | |
| End block / UTC | |
| Claim block | |
| Migration block | |
| Duration | 24 h ≈ 864,000 blocks on the chain's own clock |
| Floor price Q96 | |
| Implied floor FDV (wei / USD) | |
| Auction price tick spacing Q96 | |
| Required currency raised (wei / USD) | |
| Implied graduation FDV | |
| ETH/USD at configuration (display only, from the Chainlink feed the launch script printed) | |
| Emission steps | 12 ramp steps plus one final block carrying 30 % |
| Validation hook | address(0), no whitelist |
| Protocol fee on the raise | 0 % (protocolFeeController is address(0)) |
Liquidity configuration
| Item | Value |
|---|---|
| Pool | ETH / KAY9, Uniswap v4 |
| LP fee | 10000 pips = 1 % |
| Tick spacing | 200 |
| Hook | 0xD462a559337859369EF271814851A18F496ba000 (canonical InitializerHook; address(0) only if the pool was rebuilt by recover()) |
| LP allocation | 100 % of the raise to liquidity, one bracket |
| Position plan | one full-range position, weight 1e7 |
| Position recipient | KAY9LiquidityLock |
| Terminal custodian | FeeSplitter 0xeFF166AAf189323c58dc27eD1206EB2C37FaACDf |
| Fee split | 40 % native to the beneficiary NFT holder; 60 % native and 100 % KAY9 compounded |
| Unsold supply | single-sided KAY9 position below the current tick, locked; below 1,000 KAY9 burned |
Audit protocol
| Item | Value |
|---|---|
| Deep access lock | 5,000 KAY9 (requirementOf(1)), or the value after any setRequirement executed since |
| Forensic access lock | 10,000 KAY9 (requirementOf(2)), or the value after any setRequirement executed since |
| Price oracle in the access path | none. The requirement is a stored number, changed only through the 48 h timelock |
| Access period length | 2,592,000 s (30 days) |
| Deep tier allowance | 4 deep, 0 forensic |
| Forensic tier allowance | 4 deep, 1 forensic |
| Audit fee | none. No fee, no escrow, no split, no burn |
| What a request spends | one quota unit, returned on dispute or expiry |
accessVault.auditHub() | the hub |
accessVault.owner() | the timelock |
| SLA | 21,600 s (6 hours) |
| Auditors | 0x…, 0x…, 0x… |
| Quorum | 2 of 3 |
| Auditor platforms, and which the owner controls | A: …; B: …; C: …. State plainly how many are inside owner-controlled accounts (docs/SECURITY.md §2.6) |
| EIP-712 domain | name KAY9AuditHub, version 1, chain id 4663, verifying contract the hub |
| Basic scan | runs in the visitor's browser; the convenience endpoint, if deployed, is not canonical |
Canonical Uniswap dependencies
Reproduce the table in CONTRACTS.md with a cast code check on each address at the deployment block.
Simulation, security and gas
| Item | Value |
|---|---|
| Offline tests | count and result at the release commit; must be green, and must include the access-model suite |
| Fork tests | count and result against Robinhood mainnet |
| Fork block used | |
| Invariant runs / calls | 64 runs, 4,096 calls (default profile); 128 runs, 16,384 calls (ci profile) |
| Static analysis | see SECURITY.md section 5 |
| Gas snapshot | .gas-snapshot at the release commit |
| Deployment gas used | |
| Launch transaction gas used |
Known risks
Reproduce section 6 of SECURITY.md, with any launch-specific additions.