Deployment
The protocol deploys across two chains, in an order that is not negotiable.
The wallets
Three distinct roles, never the same key.
| Wallet | What it can do |
|---|---|
| Protocol owner | Upgrade every module but the tokens, the liquidity lock and the remote vault deployer; wire the factory; register baskets; set the write-once addresses; change the protocol's settings; set the airdrop's exclusion lists and register each stock's OFT; move assets out in an emergency, at once; move out what a module holds by mistake (rescue); start or cancel the end mode, and recover the liquidity once its 30 days have run |
| Keeper | Trigger conversions, bridge batches and the airdrop: open cycles, send stocks, place held-aside stocks. Since 2026-10-05 also pay what the hook and the lock keep for a recipient and collect LP fees, calls open to anyone |
| Deployer | Deploy the contracts. It owns the factory until the protocol owner accepts the ownership, and administers the remote hub until the first bridge batch names the protocol owner there |
The scripts take their signer from the forge command line or from a raw
DEPLOYER_PRIVATE_KEY in the environment. DeployEthereumRail, DeployProtocol,
DeployRemote and DeployBridge accept either; LaunchProtocol, RegisterBaskets and
CreateMarket read only DEPLOYER_PRIVATE_KEY.
Every broadcast runs with --slow --skip-simulation, since the tenth audit loop. Without
them forge gives each transaction the gas its own simulation counted, at the prices before
Ethereum's Glamsterdam upgrade, and a contract creation needs four to seven times that after
it: every creation would run out of gas. With them, forge takes the node's estimate for each
transaction, once the previous one is mined. A dry run's gas figures are no budget either:
under Glamsterdam, DeployProtocol takes about 240 million gas and each market's launch 15 to
24 million.
The order
Three constraints set it. Every Ethereum module takes the factory's address at construction, so the factory comes first, and its owner wires the rest into it afterwards. The Ethereum rail's router and oracle, like the bridge hub, are bound to the factory, so they come after the protocol. The two hubs pin each other by predicted address.
DeployProtocol: the factory first, then the hook, mined for all 14 v4 permissions, the lock, the holding recorder, the deployers, the vault implementation, the lens, the swap router and the airdrop contract,AirdropDistributor, all wired into the factory. Ownership then goes to the protocol owner, who must accept itDeployEthereumRail, given the factory's address: the oracle and the ETH → USDC router on Ethereum, which the owner sets on the factoryDeployBridge --sig "predict()": prints the addresses the bridge hub and its adapter will getDeployRemoteon Robinhood Chain: the remote hub first, pinned to those predicted addresses, then the stock router, the oracle and the mirror vault implementation, which the hub's admin wires into it, with the airdrop route and the stock adapters when they are given (below). Since 2026-10-06 it sets the remote hub's two gas policies for the airdrop deliveries on Ethereum in every run, before the airdrop route (below), and the oracle's two guards, and refuses to start without a decision on the sequencer check:SEQUENCER_UPTIME_FEED, Chainlink's L2 sequencer uptime feed on Robinhood Chain, orSEQUENCER_CHECK_OFF=true, the check off by choice, never both, withSEQUENCER_GRACE_PERIOD(3,600 seconds by default) only beside a feed. Chainlink publishes no such feed for Robinhood Chain, so a mainnet run today setsSEQUENCER_CHECK_OFF=true, and StockFun's owner sets the feed later (setSequencerUptimeFeed) if one is published. The script then turns each stock's oracle pause on (setOraclePauseCheck), after the hub, the router and the oracle, so no predicted address movesDeployBridge: the bridge hub and its adapter, at the predicted addresses; the owner names the adapter on the hub, once (setAdapter). Since 2026-10-05 the script stops before deploying anything when the factory already names a hub (since 2026-10-06 its first check, before the predicted addresses), and, when its signer owns the factory, maps the stocks before it names the hubsetBridgeHub— before the first market. Since 2026-10-05 it refuses a hub that cannot carry a basket already registered (UnmappedBridgeStock)addStockMappingon the bridge hub, for every basket stock- Register the baskets:
PlanBridgeBasketsprints the owner's calls. Since 2026-10-06 a basket holds at most five stocks: see Baskets LaunchProtocol:$STOCKFUN, whose whole supply goes into its locked position, its vault, theBuybackBurnerandsetBuybackWallet. It needs the airdrop contract named first, whichDeployProtocoldoes: since 2026-10-05 it lists the launch operator on$STOCKFUN's exclusion list before the mint, on the address the token will take. Since 2026-10-06 a run that stopped before the protocol market was named resumes with the token and the vault it left (--sig "resume(address,address)"), checked first, instead of minting a second$STOCKFUN; once the protocol market is named, the script no longer runs, and the remaining steps are done by handregisterStockOfton the airdrop contract, by the owner, for each stock's OFT on Ethereum
Each upgradeable module is deployed as two contracts, its implementation then its proxy.
predict() counts them: the bridge hub's proxy comes at the deployer's nonce + 1, its
adapter's at nonce + 3.
StockFun pairs no LayerZero peer: the USDG OFT's peers belong to its issuer, and the preflight only checks them.
The local and testnet scripts, LocalRun and DeployTestnetBridge, write their deployment
file only when they broadcast, since 2026-10-06, and DeployTestnetRail too since the ninth
audit loop: a dry run's addresses carry no code. On the testnet, DeployTestnetRail takes
the same three sequencer inputs, all optional (no feed leaves the check off, Chainlink
listing none for the testnet either), and turns each stock's oracle pause on; the Ethereum
scripts leave both guards off. A testnet keeper runs with KEEPER_REQUIRE_MARKET_OPEN,
KEEPER_AIRDROP_AFTER_SESSION and, since 2026-10-06, KEEPER_CONVERT_ONCE_PER_WINDOW set
to false, so that it converts at every pass: see The keeper. Since the seventh audit loop, on 2026-10-06, the
keeper checks each RPC's chain against its configuration before it starts: a testnet keeper
sets KEEPER_CHAIN_ID=11155111 and, with the bridge, KEEPER_REMOTE_CHAIN_ID=46630, and a
mainnet keeper KEEPER_CHAIN_ID=1 with 4663. Since the eighth audit loop both are
required: the keeper refuses to start without KEEPER_CHAIN_ID, or without
KEEPER_REMOTE_CHAIN_ID beside the bridge. The app's data Worker checks its endpoints'
chain the same way, and its public RPCs follow its two chain ids, so a testnet Worker needs
only those.
The LayerZero testnet run of 2026-10-06 deployed the protocol on Sepolia and on Robinhood
Chain's testnet (chain 46630) with the production scripts, or testnet wrappers that keep
their body, and its own test tokens, venues and adapters, in a testnet-only folder of the
contracts. Since the ninth audit loop the preflight checks that run too, given its two files,
with SEPOLIA_RPC_URL and ROBINHOOD_TESTNET_RPC_URL. What the run proved, and what it did
not, is in Testing and verification.
The airdrop
Since 2026-10-04 the airdrop contract deploys with the protocol. Its settings come from the environment:
| Script | Variable | Default | Role |
|---|---|---|---|
DeployProtocol |
AIRDROP_LZ_ENDPOINT |
None: the local rail only | LayerZero endpoint on Ethereum, for the stocks bought on Robinhood Chain |
DeployProtocol |
AIRDROP_REMOTE_EID |
30416 with an endpoint | Robinhood Chain's LayerZero endpoint id, the only source of a delivery |
DeployProtocol |
AIRDROP_CYCLE_LENGTH |
86,400 (24 hours) | Length of a window, in seconds, a whole number of hours |
DeployProtocol |
AIRDROP_CYCLE_OFFSET |
46,800 (13:00 UTC) | Where the windows end, in seconds after 00:00 UTC, a whole number of hours: before the US open all year |
DeployRemote |
AIRDROP_DISTRIBUTOR |
None | The airdrop contract on Ethereum the mirror vaults send to |
DeployRemote |
AIRDROP_RECEIVE_GAS, _MIN, _MAX |
650,000, 200,000, 1,500,000 | Since 2026-10-06: the lzReceive gas of each delivery on Ethereum, on top of what the stock's OFT enforces, when the keeper asks for the default, and the floor and ceiling of what it may ask for |
DeployRemote |
AIRDROP_COMPOSE_GAS, _MIN, _MAX |
1,250,000, 600,000, 4,000,000 | The gas of each delivery's call on the airdrop contract, lzCompose, the same way. Until 2026-10-06 one figure, 600,000, set every delivery |
DeployRemote |
STOCK_ADAPTERS |
None | Comma list, one LayerZero adapter per entry of STOCKS, zero for a stock without one |
These are starting values: the owner can change the schedule (setCycleSchedule) and the
LayerZero endpoint (setLayerZero) later. On DeployRemote, AIRDROP_DISTRIBUTOR and
STOCK_ADAPTERS are optional: the remote hub's admin can set them later (setAirdrop,
setStockAdapter). The two gas policies are set in every run, from the environment or the
hub's defaults, before the distributor, whose compose gas must fall within them; the admin can
change them later (setAirdropReceiveGas, setAirdropComposeGas). A value above uint128
stops the run. Then come the owner's steps:
setAirdropDistributoron the factory, done byDeployProtocol. The owner can name another one later; the vaults read it live, and a replaced distributor keeps every cycle claimable where it isregisterStockOfton the airdrop contract, for each stock's OFT on Ethereum, which must use the contract's own LayerZero endpointsetExclusions, only for a token that needs addresses excluded beside the burn address: no market token does by default;$STOCKFUN's list, with its launch operator, is set byLaunchProtocol
The stock adapters themselves, one per stock — the lockbox adapter on Robinhood Chain and
its OFT on Ethereum — are not in the repository for mainnet: they need LayerZero's oft-evm
package. DeployRemote takes their addresses. The LayerZero testnet run of 2026-10-06 used
test ones, LayerZero's OFTAdapter over test stocks and LayerZero's OFT for the wrapped
stocks, in its testnet-only folder.
Each delivery from Robinhood Chain runs on Ethereum in two calls: the stock OFT's
lzReceive, which mints the wrapped stock to the airdrop contract, then the airdrop
contract's lzCompose, which credits it. Since 2026-10-06 the keeper names the gas of both
on each send, chosen from simulations on Ethereum (see The keeper), and the
remote hub clamps each value into its policy, zero taking the default. The defaults cover
by 35 % and 30 % the heaviest cases measured on Sepolia after Ethereum's Glamsterdam upgrade:
there an lzReceive needs 184,702 gas into a balance the airdrop contract already holds, and 481,548 for a stock's very first
delivery; a compose 105,075 when the cycle already lists the stock, 433,645 when the cycle
the keeper opened does not yet, about 531,600 when the delivery is also the cycle's first
credit, and 962,154 when it opens the cycle itself. The heaviest delivery built in the
tests, an opening against sixteen excluded holders with long histories that also takes
along four held-aside stocks, needs about 2.8 million at Glamsterdam's prices, under the
compose ceiling. Before Glamsterdam, measured cold in the tests, a delivery into an open
cycle took about 85,000, one that opens a cycle against one excluded address about 275,000,
and the heaviest about 1,016,000. A delivery short of gas fails without losing anything: it
stays stored on LayerZero's endpoint, the wrapped stocks already on the airdrop contract when
only the compose failed, and anyone can run it again with more gas. Since 2026-10-06 the
keeper does it itself, within its own bound, and alerts a second failure.
Wiring the factory
The factory is initialized with its owner and its three wallets only; its settings start at their defaults. The owner names everything else afterwards:
setLaunchModules: the hook and the lock, once and for all; both must name this factory, and the lock this hooksetDeployers: the two deployers, which can be replacedsetVaultImplementation: the implementation behind the vaults of the markets created afterwardssetHoldingRecorder: the recorder that new market tokens report tosetAirdropDistributor: the airdrop contract the vaults hand their stocks to; it must name this factorysetTreasuryRouter,setTreasuryOracle,setSwapRouter,setBridgeHubandsetProtocolMarket
No market can be created until the lock, a vault implementation and a holding recorder are named.
The bridge hub and the swap router
setBridgeHub can only be set once. Missing it cannot be undone. Since 2026-10-05 it
refuses a hub that cannot translate every stock of the baskets already registered: map them
on the hub first.
setBridgeHub must be called before the first market is created. Every
TreasuryVault pins the bridge hub address at construction. A vault created while the
address is zero stays on the local rail forever and never sends anything to Robinhood
Chain.
setSwapRouter is not write-once: the owner can change it at any time. It no longer binds
any vault: vaults stopped reading it when the creator buyback was removed from the code, on
2026-09-28. It records the official swap router's address, which the preflight checks.
The hook and its mined address
A hook's address encodes its v4 permissions in its low bits: it is found by brute-forcing the CREATE2 salt. Since 2026-10-02 the address mined is the hook proxy's, with all 14 permission bits set. It depends on the proxy's bytecode and its constructor arguments, which carry the implementation's address. A new deployment must be re-mined; an upgrade keeps the address.
foundry.toml must carry bytecode_hash = "none" and evm_version = "cancun", or the
mined address will not match the deployed contract.
Upgrades
An upgrade is a call by the protocol owner on the module's proxy, naming the new
implementation; it takes effect at once. Before every upgrade,
contracts/script/check-storage-layouts.sh compares the new storage layout with the one
recorded in contracts/storage-layouts/, and fails on any change but an append. Since
2026-10-05 it compares every depth of every struct, sizes included, and refuses any change
to a struct that is the element of a storage array: only a struct that is a mapping's
value, or the last state variable, may grow, at its end. --write refreshes the records
after a deliberate change.
The remote hub's gas policies came with the ninth audit loop, on 2026-10-06. A hub
deployed before them and upgraded reads both policies as zero, and a mirror vault upgraded
to the matching code refuses every airdrop send and quote (AirdropGasNotSet) until both
are set. The order is therefore: upgrade the remote hub, set both policies
(setAirdropReceiveGas, setAirdropComposeGas), then name the new mirror vault
implementation and upgrade each mirror vault, and only then start a keeper of the ninth
loop, which asks the hub for the policies and sends with three arguments. A keeper of
before keeps sending on the hub's defaults meanwhile. A hub deployed with the new code
sets the defaults at initialization.
The USDG adapter's batch settings came with the tenth audit loop, on 2026-10-06: the compose
gas each market of a bridge batch adds, and the most markets one batch carries. An adapter
deployed before them and upgraded reads both as zero and refuses every batch and quote
(BatchGasNotSet) until they are set. So its upgrade carries them in the same transaction
(upgradeToAndCall with setBatchGas(400000, 17)), then the owner lowers its old compose
gas, 1,200,000, to the base every batch now needs (setSettings, 200,000), and only then
starts a keeper of the tenth loop, which reads the cap at every batch. A keeper of before
keeps working with the upgraded adapter while no more than 17 markets are ready at once. The
testnet's adapter was upgraded this way on 2026-10-06, and its next batch went through at its
new compose gas.
An upgrade that changes what the app's data service reads goes live before that service does. Since 2026-10-06 the Lens carries each vault's state and what the hook and the lock owe it, and the Worker and the app that read those fields cannot read an older Lens: upgrade the Lens first. The seventh audit loop changes neither the Lens nor the shape of what the Worker publishes (schema 7): its Worker and app deploy in either order. The eighth changes the shape (schema 8: each stock price says why it is missing, when the Robinhood Chain oracle holds it back), not the Lens, and its Worker and app still deploy in either order: an older app ignores the reason, and this app reads an older Worker without one. The ninth keeps schema 8.
The oracle of the Robinhood rail, like any TreasuryOracle, starts with its two guards off.
A replacement oracle named on the remote hub (setOracle) therefore starts with them off
too, and the hub's admin turns them on again for it, as the deployment script does: each
stock's oracle pause, and the sequencer feed if one was set. The mirror vaults already
initialized keep the oracle they were initialized with.
Settings
A setting is a call by the protocol owner on the module that holds it, or, on Robinhood
Chain, by the remote hub's admin; it takes effect at once and emits an event. Every module
starts with the defaults listed in Trust model. The bridge adapters start
with the deployment's gas: on the USDG rail COMPOSE_GAS, the part of a batch's last step
every batch needs, 200,000 by default in DeployBridge since the tenth audit loop (1,200,000
for the whole step until then), plus 400,000 for each market of the batch and at most 17
markets a batch (setBatchGas; the largest batch's gas at most 24,000,000), beside a Curve
bound of 30 basis points; on the canonical rail the gas of
both tickets and, since 2026-10-05, the bytes the deposit ticket's cost is computed on
(DEPOSIT_CALLDATA_LENGTH in DeployTestnetBridge; zero takes the default, 1,024). The
remote hub starts with the airdrop deliveries' two gas policies (above).
Before mainnet
- A full emergency-mode rehearsal: transfer, pause, unpause
- A first small airdrop on a verification vault, before any public opening. The LayerZero testnet run of 2026-10-06 ran StockFun's code end to end over LayerZero's testnet endpoints, DVN and executor; it proves neither Paxos's USDG pair, nor Robinhood's stocks and their adapters, nor real feeds and liquidity, nor mainnet's gas, fees and finality
- An up-to-date survey of price feeds on the remote chain
- Verification of the USDG OFT's LayerZero peers and of USDG's pause state, via preflight; since the eighth audit loop the preflight also checks the oracle's guards against its manifest, which must state the sequencer check, off today
- The size limit of the pathway Paxos's USDG takes to Robinhood Chain, read from LayerZero's
send library (
getExecutorConfig), and the cap on a bridge batch's markets set to match if it is not 10,000 bytes: at most (size − 392) ÷ 544 markets (since the tenth audit loop) - External review — the existing formal proofs do not cover the cross-chain rail