Skip to content

docs/DEPLOYMENT.md

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

MainnetTestnet
Chain id466346630
RPChttps://rpc.mainnet.chain.robinhood.comhttps://rpc.testnet.chain.robinhood.com
Explorerhttps://robinhoodchain.blockscout.comhttps://explorer.testnet.chain.robinhood.com
Faucethttps://faucet.testnet.chain.robinhood.com
Gas tokenETHETH
Block timeChain's own ~0.10 s, but block.number ~12 s, so 4 h ≈ 1,200 blockssame

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

VariableMeaning
OWNER_SAFEThe project owner Safe. Owns KAY9Genesis and is proposer and executor on the timelock. Must not be the deployer.
TREASURYA project treasury address. Deploy.s.sol still requires it and refuses the deployer, but no contract consumes it any more: the audit protocol has no fee, no split and no treasury share. Supply an address the owner controls, and treat the requirement as a leftover to be removed from the script rather than as a role.
TEAM_BENEFICIARYThe initial team vesting beneficiary. Must not be the deployer.
CREATOR_FEE_RECIPIENTReceives the LP fee beneficiary NFTs, and therefore 40 % of native LP fees. Must not be the deployer.
TGE_TIMESTAMPThe token generation event, as a unix timestamp.
UNLOCK_6M_TIMESTAMPSix calendar months after the TGE. Produce it with ComputeVesting.
UNLOCK_12M_TIMESTAMPTwelve calendar months after the TGE. Produce it with ComputeVesting.
AUDITORSComma-separated operator addresses, for example 0xa…,0xb…,0xc….
AUDITOR_THRESHOLDThe quorum. 2 at launch.
SCANNERSOptional, DeployWatchdog.s.sol only. Comma-separated addresses authorised to commit basic-scan batches. Auditors may always commit, so this may be empty; a dedicated key is an operational convenience. A scanner can only ever append, and cannot alter or remove a committed batch.
EXISTING_TIMELOCKOptional, 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_REGISTRYOptional, 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_FEEDOptional override of the Chainlink feed. Defaults to the address book entry.
INITIALIZER_HOOKOptional 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

VariableMeaning
MAINNET_CONFIRMMust 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

VariableMeaning
GENESISThe deployed KAY9Genesis address.
FLOOR_FDV_USDThe floor valuation in whole US dollars.
GRADUATION_FDV_USDThe graduation valuation in whole US dollars.
DURATION_HOURSThe auction length in hours. 4 for the planned launch.
START_DELAY_MINUTESHow long after the transaction the auction starts.
LAUNCH_SALTOptional. Defaults to 1. Changing it changes the predicted auction address.

Fork testing only

VariableMeaning
ROBINHOOD_RPC_URLEnables the fork suite. Absent means the suite skips itself.
ROBINHOOD_FORK_BLOCKOptional 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.

Confirm the jurisdiction before anything is broadcast. KAY9 does not name an operating jurisdiction, and this document will not invent one. Before a mainnet launch the owner must confirm which jurisdiction they are operating from and obtain professional legal and tax review for it, covering at minimum: whether the token sale mechanism is permissible there, whether risk reports published about third-party assets carry liability or a regulated-advice classification, whether holding depositors' KAY9 in KAY9AccessVault is a custody or deposit-taking activity there, and what the disclosure obligations are. None of that is a contract property, so no test can catch it and no invariant can protect against it. It is a launch gate, and it belongs here for the same reason the bytecode hashes do: it has to be checked once, deliberately, before the irreversible step.

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 oracle, 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…              # optional

# 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 is permissionless and consumes no quota — works immediately. That is the mechanism behind beta deep and forensic reports: see docs/ACCESS_MODEL.md §8.1.

The report it prints ends with three things that must be acted on:

The scan registry's ownership is proposed, not transferred. Ownable2Step needs the new owner to accept, so until the timelock executes acceptOwnership() the deploying key still owns it. Queue and execute that before announcing anything, and verify with scanRegistry.owner() == <timelock>.

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.

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/dayIndexing everythingIndexing 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 those from the same upstream sources the mainnet contracts were built from, plus a MockV3Aggregator.

forge script script/Testnet.s.sol:Testnet \
  --rpc-url https://rpc.testnet.chain.robinhood.com \
  --broadcast -vvv

Then rehearse the launch itself with cast, using the printed addresses:

# 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. Bind the oracle to the new pool, through the timelock, then poke it.
cast send <pricing> "poke()" --rpc-url ...
cast call <pricing> "pricingStatus()" --rpc-url ...

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.

The oracle must be live before any of it works: bind the pool, poke until pricingStatus().available is true, and only then start. quoteLock reverting is the correct behaviour before that point, not a failure to debug.

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. Quote, approve, lock a deep period, and check the quota that came with it.
cast call $VAULT "quoteLock(uint8)(uint256,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,uint256,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; requotes 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.

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.

Testnet also has no Chainlink feed, so the oracle is exercised against MockV3Aggregator. Feed staleness and round-completeness behaviour is covered by unit tests instead. Use the mock to rehearse one more thing that no other step covers: make the feed stale, confirm quoteLock and lock revert, and confirm that unlock still returns the principal while the oracle is unavailable. That asymmetry is the vault's central safety property, and testnet is the only place it can be exercised against a real deployment.


4. Mainnet deployment

4.1 Simulate

OWNER_SAFE=0x… TREASURY=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:

  1. TimelockController.
  2. KAY9Genesis, which in its own constructor deploys the token, the vesting contract and the liquidity lock, and funds vesting.
  3. KAY9AuditorRegistry.
  4. KAY9Pricing, which takes both USD access targets: 100e8 for deep and 500e8 for forensic.
  5. KAY9AccessVault, before the hub, because KAY9AuditHub takes 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.
  6. KAY9Registry, constructed with the hub address predicted from the deployer's next nonce.
  7. KAY9AuditHub. The script asserts it landed at the address the registry was told to expect.
  8. accessVault.setAuditHub(auditHub), from the deployer, while it still owns the vault. Until this runs, requestAudit reverts NotTheAuditHub for everybody, because the vault's auditHub is still the zero address.
  9. accessVault.transferOwnership(timelock), handing the vault to governance.

Step 9 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 deployment report but does not currently include KAY9AccessVault in its address, role or bytecode-hash listings. Take the vault's address from the broadcast file and record it by hand in §6 until the script is updated.

The pool key cannot be squatted. KAY9Genesis deploys 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 canonical InitializerHook (0xD462a559337859369EF271814851A18F496ba000), whose authorized() is the LBP strategy, so PoolManager.initialize on 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 make MigratorParams.validateHook brick launch(). 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:

Contractcast abi-encode signature
KAY9Tokenconstructor(address)
KAY9TeamVestingconstructor(address,address,uint64,uint64,uint64)
KAY9LiquidityLockconstructor(address,address,address,address)
KAY9Genesisconstructor(address,address,uint64,uint64,uint64,address,address,address,address,address,address,address,address,address) (the last argument is the InitializerHook)
KAY9AuditorRegistryconstructor(address,address[],uint8)
KAY9Pricingconstructor(address,address,address,address,uint256,uint256) (owner, PoolManager, KAY9, feed, deep target E8, forensic target E8)
KAY9AccessVaultconstructor(address,address,address) (owner, KAY9, pricing)
KAY9Registryconstructor(address)
KAY9AuditHubconstructor(address,address,address,address) (owner, registry, auditor registry, access vault)
TimelockControllerconstructor(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 Safe
cast call <auditHub> "owner()(address)" --rpc-url $RPC                      # the timelock
cast call <pricing>  "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> "pricing()(address)" --rpc-url $RPC                  # the oracle
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 <pricing> "usdTarget(uint8)(uint256)" 1 --rpc-url $RPC             # 10000000000  ($100)
cast call <pricing> "usdTarget(uint8)(uint256)" 2 --rpc-url $RPC             # 50000000000  ($500)
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:

  1. MultiSendCallOnly call 1: 0x4e59b44847b379578588920cA78FbF26c0B4956C (the deterministic CREATE2 deployer) with salt || KAY9Genesis creationCode || abi.encode(constructor args). The resulting address is create2(0x4e59…, salt, keccak256(initCode)); compute it with cast create2 --starts-with "" --init-code-hash <hash> or off-chain, and pin it.
  2. MultiSendCallOnly call 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=4 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 price, 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 Safe

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 to address is the deployed KAY9Genesis (or the CREATE2 address call 1 deploys it to)
  • the decoded startBlock, endBlock, claimBlock and migrationBlock match the printed UTC times
  • the decoded floorPriceQ96 and requiredCurrencyRaised match the printed valuations
  • the predictedAuction matches what previewLaunch returns from a fresh cast 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:

cast send <lbpStrategy> "migrate(address)" <auction> --rpc-url $RPC --ledger
cast send <liquidityLock> "lock(uint256)" <tokenId> --rpc-url $RPC --ledger
cast send <genesis> "settle()" --rpc-url $RPC --ledger

Then bind the oracle to the pool through the timelock, wait 48 hours, execute, and start the keeper:

# schedule
cast send <timelock> "schedule(address,uint256,bytes,bytes32,bytes32,uint256)" \
  <pricing> 0 $(cast calldata "configurePool((address,address,uint24,int24,address))" \
  "(0x0000000000000000000000000000000000000000,<token>,10000,200,0x0000000000000000000000000000000000000000)") \
  0x0 0x0 172800 --rpc-url $RPC --ledger
# 48 hours later
cast send <timelock> "execute(address,uint256,bytes,bytes32,bytes32)" … --rpc-url $RPC --ledger

If the auction does not graduate:

cast send <lbpStrategy> "migrate(address)" <auction> --rpc-url $RPC   # runs the recovery branch
cast send <genesis> "markFailed()" --rpc-url $RPC                     # starts the 48 h cooldown
# 48 hours later, the owner may launch again with new pricing.

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

ItemValue
ChainRobinhood Chain, id 4663
Deployer0x…
Deployment block / tx
KAY9Token0x…
KAY9TeamVesting0x…
KAY9Genesis0x…
KAY9LiquidityLock0x…
KAY9AuditorRegistry0x…
KAY9Pricing0x…
KAY9AccessVault0x…
KAY9Registry0x…
KAY9AuditHub0x…
TimelockController0x…
Predicted auction address0x…
Actual auction address0x…
Uniswap v4 pool id0x…
Migration LP position id
Settlement LP position id

Bytecode hashes

ContractRuntime codehash
KAY9Token0x…
KAY9TeamVesting0x…
KAY9Genesis0x…
KAY9LiquidityLock0x…
KAY9AuditorRegistry0x…
KAY9Pricing0x…
KAY9AccessVault0x…
KAY9Registry0x…
KAY9AuditHub0x…
TimelockController0x…

Owners and beneficiaries

RoleAddress
Owner Safe (Genesis owner, timelock proposer and executor)0x…
Timelock adminthe timelock itself
Team vesting beneficiary0x…
Creator-fee recipient (beneficiary NFT holder)0x…
KAY9AccessVault ownerthe timelock, with acceptOwnership executed at block / tx
Recipient of any audit feenone. There is no audit fee.

Jurisdiction

ItemValue
Jurisdiction the owner confirms they operate from
Professional legal review obtained, by whom, on what date
Professional tax review obtained, by whom, on what date
Points specifically reviewedsale mechanism; publication of risk reports about third-party assets; holding depositors' KAY9 in KAY9AccessVault; disclosure obligations

Vesting

TrancheAmountUTC dateUnix
TGE10,000,000 KAY9
TGE + 6 calendar months40,000,000 KAY9
TGE + 12 calendar months40,000,000 KAY9

Allocations

AllocationAmountShare
Public fair auction455,000,00045.5 %
Permanent liquidity reserve455,000,00045.5 %
Team90,000,0009 %
Total1,000,000,000100 %

Auction configuration

ItemValue
Start block / UTC
End block / UTC
Claim block
Migration block
Duration4 h ≈ 1,200 blocks
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
Emission steps12 ramp steps plus one final block carrying 30 %
Validation hookaddress(0), no whitelist
Protocol fee on the raise0 % (protocolFeeController is address(0))

Liquidity configuration

ItemValue
PoolETH / KAY9, Uniswap v4
LP fee10000 pips = 1 %
Tick spacing200
Hookaddress(0)
LP allocation100 % of the raise to liquidity, one bracket
Position planone full-range position, weight 1e7
Position recipientKAY9LiquidityLock
Terminal custodianFeeSplitter 0xeFF166AAf189323c58dc27eD1206EB2C37FaACDf
Fee split40 % native to the beneficiary NFT holder; 60 % native and 100 % KAY9 compounded
Unsold supplysingle-sided KAY9 position below the current tick, locked; below 1,000 KAY9 burned

Oracle configuration

ItemValue
Pool bound atblock / tx
TWAP window1,800 s
Minimum observations10
Maximum observation gap300 s
Minimum pool liquidity1e15
Maximum deviation5,000 bps
Maximum feed age90,000 s
Chainlink ETH/USD0x78F3556b67E17Df817D51Ef5a990cDaF09E8d3A9, 8 decimals, 86,400 s heartbeat

Audit protocol

ItemValue
Deep access USD target100.00 USD (100e8)
Forensic access USD target500.00 USD (500e8)
Access period length2,592,000 s (30 days)
Deep tier allowance4 deep, 0 forensic
Forensic tier allowance4 deep, 1 forensic
Audit feenone. No fee, no escrow, no split, no burn
What a request spendsone quota unit, returned on dispute or expiry
accessVault.auditHub()the hub
accessVault.owner()the timelock
SLA21,600 s (6 hours)
Auditors0x…, 0x…, 0x…
Quorum2 of 3
Auditor platforms, and which the owner controlsA: ; B: ; C: . State plainly how many are inside owner-controlled accounts (docs/SECURITY.md §2.6)
EIP-712 domainname KAY9AuditHub, version 1, chain id 4663, verifying contract the hub
Basic scanruns 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

ItemValue
Offline testscount and result at the release commit; must be green, and must include the access-model suite
Fork testscount and result against Robinhood mainnet
Fork block used
Invariant runs / calls64 runs, 4,096 calls (default profile); 128 runs, 16,384 calls (ci profile)
Static analysissee 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.