Choice's Solidity for Injective EVM: the pieces PancakeSwap Infinity does not have, plus the deploy orchestration and the address book that every other v2 repo reads.
Plan and decision log: choice_v2/CHOICE_V2_EVM_PLAN.md.
| Path | |
|---|---|
src/fees/ |
ChoiceFeeController - IProtocolFeeController with Choice's tier policy, a permissionless harvest and the treasury / burn-auction split |
src/launchpad/ |
InfinitySettler (IGraduationSettler: seeds a full-range CL pool and calls onSettled, all inside triggerGraduation), PositionLocker (holds the seed NFT forever; permissionless collect splits LP fees creator / launchpad) and LaunchPoolGuardHook (only a settler may create a graduation pool) |
src/router/ |
ChoiceRouter - holds the intermediate token across a Choice -> Pumex handoff so one end-to-end minimumReceive can be enforced. Routes inside one deployment do NOT go through it and execute REFUSES them: UniversalRouter's INFI_SWAP already runs a whole split, multi-hop route in a single Vault lock. It locks both Vaults itself rather than calling Pumex's UniversalRouter, which is pausable by a Safe outside Choice. There is no orderbook leg - an EVM 0x65 fill cannot be atomic (plan M5, 2026-09-05). execute reverts NotCrossVault until a second vault is allowlisted with setVault |
src/rfq/ |
RfqSettlement - settles EIP-712 quotes signed by ONE off-chain market maker (an issuer, a desk) against inventory it parked here, atomically, with per-token payout caps per day, single-use nonces, signer rotation and pause. RfqPoolArb - one CL pool swap plus one such fill in a single transaction, reverting unless the starting currency comes back ahead: what keeps a pool in an issued asset at the issuer's mark. Not part of the protocol deployment and not in the address book; script/tools/rfq-sign-quote.sh is the reference quote signer |
src/stock/ |
Hedged stock vaults. StockVault - an ERC4626 cash vault for ONE stock token that is the hook of its one stock/cash CL pool: it fills the whole swap at a signed Pyth Pro price (the newer of the stored one and one handed in as hookData), in the cash (the stock's USD feed over the cash's own cashFeedId, as the perp is marked), plus a spread computed at each fill, refuses on a stale price in session, fills on the close at a wider spread when the MarketCalendar says the market is shut, and keeps a 0x65 perp short that tracks its target inventory (market or post-only maker orders). A short the precompile cannot read back (it reverts on a negative funding entry) or one gone with no cover of the vault's (liquidated, settled, or on a paused market) stops share pricing; coverUnreadable (a reduce-only buy that needs no read) and the owner's acceptNav are the ways out. A fixing lane fills committed requests at the one signed update stamped at a tick after each boundary. Deposits and instant withdrawals settle the short's funding first and run only while the market is open; larger exits queue (ERC-7540 request side) and settle in cash or, as the backstop, in kind. The issuer restocks by LIMIT memo orders in both directions (the memo built on chain from an owner-set template; refunds and payments close an order without a halt; one past its deadline stays open until a leg arrives or the owner writes it off) or, optionally, signed EIP-712 quotes. Prices are per token: the feed's per-share price over the vault's tokens-per-share ratio, which StockRatios announces on a split and only the owner accepts, the vault halted in between. A vault is one address over several bodies sharing one ERC-7201 slot: the vault, a module chain through its fallback (StockPublic then StockAdmin) and external libraries (StockPricing, StockHedge, StockOps, StockRestock, StockFixing, StockQueue, StockQuotes). PriceStore verifies Pyth Pro updates against a pinned signer itself (OraclePriceStore adds Injective's own relayed price from 0x67, dated back by the relay lag, so vaults need no subscription); StockRegistry caps each issuer's exposure across vaults; StockFactory deploys every vault from one pinned code hash. Not audited; on mainnet only as test-cap rehearsals |
script/ |
one ordered deploy run per network, wrapping the forks' numbered scripts |
deployments/ |
the address book. Nothing else is authoritative |
lib/ |
the three Infinity forks as submodules, pinned by commit |
The forks' src/ is never edited, so upstream's audits keep describing the bytecode we
deploy. Everything Choice-specific lives here. See each fork's INJECTIVE_FORK.md.
Foundry is pinned to v1.5.1 — foundryup --install v1.5.1. CI installs that exact
version, because forge fmt output changes between releases and an unpinned toolchain makes
the format gate disagree with your machine. Bumping it is a deliberate PR: move the pin in
.github/workflows/ci.yml and commit the reformat with it.
git submodule update --init --recursive
forge build
forge fmt --check
forge test --isolateCompiler settings mirror the Infinity forks exactly (solc 0.8.26, via_ir, 25,666 runs,
cancun). They are pinned, not knobs: diverging changes the bytecode our contracts are built
with relative to the audited protocol they link against.
While the forks live only on disk, submodules point at ../forks/*. Run
./script/tools/submodule-urls.sh org before pushing.
Injective RPCs can return null for a mined tx's receipt, and forge --resume has hung for
ten minutes on a real deploy.
- 🔴 Broadcast WITHOUT
--slow. This file said the opposite until 2026-09-05 and it was wrong:--slowwaits for a receipt Injective never serves, so it strands the run after its FIRST transaction. Without it every transaction is broadcast before the receipt polling fails, so they all land — the run then EXITS NON-ZERO with "Failure on receiving a receipt" for each. That is the expected ending, not a failure. - 🔴 Pass an explicit, generous
--gas-limiton every write.eth_estimateGasunder-reports on Injective and the shortfall is silent:initializePoolestimated 156k, reverted at 156k with no receipt to explain it, and landed at 1.5M. Use 3-5x plus a floor. - Use the k8s testnet RPC or (mainnet) sentry / our own node. Never polkachu, which serves no receipts at all.
- Never
--resume. If a run dies mid-way, replaybroadcast/<script>/<chainId>/run-latest.jsononecast sendat a time after checking the nonce. - Confirm every contract with
eth_getCode, never with a receipt. A cheap extra check: the runtime sizesforge build --sizesprints must equal the deployed code lengths. - Update
deployments/injective_<network>.jsonin the same PR as the broadcast.
NETWORK=injective_testnet forge script script/08_VerifyOwnership.s.sol:VerifyOwnership \
-vv --rpc-url $RPC_URLRead-only, broadcasts nothing, exits non-zero if the deploy is not actually finished. It is here because the one irreversible step has no other guard.
02_DeployFeeControllers deploys the fee controllers through CREATE3, so Ownable(msg.sender)
makes the factory's one-shot proxy child the owner and the backrun's transferOwnership only
sets pendingOwner - ProtocolFeeController is Ownable2Step. Until the timelock calls
acceptOwnership, the controller is owned by a proxy child that can never be called again: a
live contract holding protocol revenue that nobody can point at a different treasury, split or
sink. Nothing fails if the step is skipped, until somebody needs to change one of those.
The script checks every contract that should be behind the timelock, checks the two pool
managers are behind their PoolManagerOwner contracts, and prints the Safe → timelock
schedule / execute calldata for anything still only pending. A key absent from the book is
skipped, so it is runnable part way through a deploy; a key that is present must be right.
Two things it treats as warnings on testnet and failures on 1776:
- the create3 factory owned by an EOA. Salts are not namespaced by
msg.sender, so whoever the factory whitelists can deploy any bytecode at any unused salt - and the locker and the guard hook are both born pointing at the settler's predicted address. Move the factory to the Safe before the first deploy and revoke the whitelist once the last salt is consumed. - a timelock delay under 24h. The executor set is
[address(0)], the open role, so anybody may execute an operation that has sat out its delay. The delay is the only barrier there is.
The create3 factory is deployed from a dedicated nonce-0 EOA, never through the Arachnid
CREATE2 deployer: Create3Factory's constructor owns and whitelists to msg.sender, so a
CREATE2 deploy hands both to a stub that can call nothing and bricks the factory permanently.
Plan D2.
A launchpad launch graduates onto a Choice v2 CL pool in the same transaction that fills the
curve. LaunchpadCore.triggerGraduation moves both legs to InfinitySettler, which
initialises the pool at exactly realPair / poolTokenAmount, mints one full-range position to
PositionLocker, and calls LaunchpadCore.onSettled. There is no keeper crank and no
PendingSettlement window: unlike the CosmWasm Phase3Settler, whose seed lands in another VM
where the EVM cannot see it, a CL mint either happened in this transaction or the transaction
does not exist.
Afterwards the seed position never moves. PositionLocker.collect(launchId) is permissionless
and pulls only fees - the plan hard-codes a liquidity delta of zero - splitting them
creator / launchpad treasury by the launch's own creatorFeeShareBps. That LP fee is the
launch's ENTIRE post-graduation revenue: a graduate pays Choice no protocol fee at all.
🔴 Graduation costs 1,595,080 gas, measured on testnet — not the ~900k
test_graduationGasBudget pins. The test is not wrong about what it measures; it cannot
measure the rest. Its mocks are plain ERC20s, while the real launch token and quote are MTS
bank ERC20s whose transfers run through the 0x64 precompile, and the core moves BOTH legs
inside triggerGraduation before the settler is even called. No forked local EVM can price
that, because 0x64 has no code to fork. Treat the unit budget as a floor on our half and
size the sender off 1.6M. Injective's eth_estimateGas under-reports too, so whatever
sends triggerGraduation needs an explicit, generous limit rather than an estimate — the
pad keeper's 5M is comfortable.
The launchpad and Choice are separate projects that share no money, and the obvious wiring for
that - "harvest only the graduates' protocol fee" - is not expressible.
ProtocolFees.protocolFeesAccrued is ONE global mapping(Currency => uint256) across every
pool in the manager, swept in full by the single authorised controller, so a graduate's fee and
a wINJ/USDC pool's fee are indistinguishable by the time anyone can harvest them.
So the problem is dissolved rather than solved: graduates carry protocol fee 0, and the global bucket then holds only Choice's own revenue by construction. The separation becomes provable from the pool key instead of enforced by procedure (plan A0, tokenomics D30/D31).
| LP fee | 10000 pips - the WHOLE 1.00% tier, not 6722 |
| Protocol fee | 0, set inside the graduation transaction |
| Tick spacing | 200 |
🔑 The trader pays the same 1%. 6722 is getLPFeeFromTotalFee(10000): the LP leg sized to
composite to 1% alongside a protocol fee. calculateSwapFee(3299, 6722) is 9,999 pips and
calculateSwapFee(0, 10000) is 10,000 - one pip in a million, upward, to exactly the
advertised tier. Only the split moves.
🔴 Zeroing it needs a contract change, which is why ChoiceFeeController carries
zeroLaunchPoolProtocolFee. ProtocolFeeController.protocolFeeForPool is override
without virtual, so the formula cannot be subclassed, and the inherited setProtocolFee
is onlyOwner - a timelock call per graduation is not viable when graduation is permissionless.
The new function is permissionless and gated on key.hooks == launchPoolGuardHook, so it
can only ever reach a pool an allowlisted settler created; the only choice a caller has is
when a graduate stops paying Choice, and the only direction is down. InfinitySettler.settle
calls it right after initialising the pool, so a graduate never charges the fee for even one
block, and it stays open afterwards as the repair path for a pool initialised outside settle.
⛔ ChoiceFeeController.setBurnSink is never pointed at the launchpad's burn sink. The
launchpad's revenue reaches its own sink only through pad-owned contracts
(LaunchpadCore.treasury, PositionLocker.launchpadTreasury). That un-made call is the other
half of the wall.
🔴 Four links, and a graduation walks all of them. 08_VerifyOwnership checks each and
prints the Safe -> timelock payload for anything missing; run it at the end of every deploy.
clPoolManager.protocolFeeControlleris Choice's controller,clFeeController.launchPoolGuardHookis the guard hook,positionLocker.settleris this settler,launchPoolGuardHook.isInitializer(settler).
2 is the one that is easy to miss: the fee controllers are deployed in script 02 and the guard
hook does not exist until script 05, so the gate ships unset and a timelock call turns it
on. While it is unset every graduation reverts - deliberately. Fail-closed is right here,
because a graduate that quietly paid Choice's protocol fee would put the launchpad's revenue
into a bucket nobody can unpick afterwards, and the reverted graduation leaves the launch in
CurveFilled with every token still in the core.
Initialising an Infinity pool is free, permissionless, and sets the price with no liquidity
behind it - and a launch token's address is public from bindLaunchToken onward. Anyone could
open the pool a graduation was going to use, at a price of their choosing, and take the
difference from the seed on the first arb. Refusing to seed a mispriced pool is a wedge, not a
defence: there are only four canonical tiers, so camping all of them costs four times the gas
of camping one.
LaunchPoolGuardHook closes it: beforeInitialize reverts unless the caller is an allowlisted
settler, so nobody else can create the pool at any price, funded or not. It registers
beforeInitialize and nothing else, which Hooks.validateHookConfig enforces at
initialize - so the hook is called exactly once in a pool's life and can never tax a swap,
block a withdrawal or freeze a pool afterwards, even if it broke. Infinity carries hook
permissions in PoolKey.parameters rather than in the hook's address bits, so no address
mining is involved. A griefer can still open a hookless pool for the same pair; that is a
different PoolKey, it holds no liquidity, and the graduation ignores it.
🔴 The settler initialises through CLPoolManager.initialize, never
CLPositionManager.initializePool. Two reasons, both load-bearing: the hook is handed the
msg.sender of the pool-manager call, so the position manager would mask the settler; and
initializePool wraps the call in a try/catch that swallows every error, so a rejection
would come back as type(int24).max instead of a revert.
Deploy with script/05_DeployLaunchpadSettler.s.sol. All three contracts are born owned by the
timelock — the locker and the hook are constructed already pointing at the settler, whose
CREATE3 address is known before it exists — so there is no ownership handoff and no
pending-owner window. One step is not ours: the launchpad admin must call
setSeederFactory(<settler>) on its core. Only launches created after that call graduate onto
v2; the pad snapshots the settler per launch, so everything in flight keeps the CosmWasm path.
✅ This is live on testnet 1439 and has been walked end to end (2026-09-05). The addresses
are in deployments/injective_testnet.json; the pad admin has flipped setSeederFactory, so
every new testnet launch now graduates here. Launch #13 filled its curve to its exact 10,950
USDC target and graduated under ONE transaction hash — pool initialised at
realPair / poolTokenAmount, one full-range position minted to the locker and registered,
onSettled called. The pool's sqrtPriceX96 was 0 one block earlier and the exact target in
that block, which is the whole atomicity claim in two reads. A swap each way then accrued
fees; collect paid creator 7000 / launchpad 3000 bps in both currencies, and harvest paid
Choice 3299 pips of each swap's input, to the pip. Both were called from an address that is
neither the creator nor an owner, which is what makes them permissionless rather than merely
documented as such. meta.firstGraduation in the address book carries the poolId, tokenId and
transaction: with no receipts served on Injective, those are the only durable handles on it.
creator and creatorFeeShareBps are not on any getter the deployed LaunchpadCore exposes;
its launches mapping is internal and LaunchpadViews decodes raw storage. So the settler
reads them with extsload at fixed word offsets. Three canaries make a wrong layout fail
closed rather than pay a decoded-from-garbage address - the state word must read
PendingSettlement, the settler word must be the settler itself, and the share must be a legal
bps - and script/05 refuses to deploy unless two more decoded fields agree with the core's
own getters on a real launch.
🔴 If the launchpad redeploys its core, re-check the layout. test/mocks/MockLaunchpadCore.sol
carries the Launch struct verbatim so the compiler derives the packing, and the offsets were
last verified on 2026-09-05 against the live testnet core 0xb03f…a71d, field by field against
LaunchpadViews at 0x60e1…156d.
GPL-2.0-or-later, following Infinity. The full text is in LICENSE; NOTICE records the upstream copyright, the two deliberately-MIT interfaces, and where the complete corresponding source for every deployed contract lives.