Skip to content

About

Choice v2 contracts: Foundry project over the Infinity forks, ChoiceFeeController + burn sinks, the deployments address book, and the v2 sdk. Live on Injective EVM testnet 1439.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

choice_v2_contracts

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.

What is here

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). ⚠️ Deployed on testnet 1439 but INERT there: only Choice's vault exists on that chain, so every route is single-deployment and 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

Layout rule

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.

Build

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 --isolate

Compiler 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.

Deploying to Injective

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: --slow waits 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-limit on every write. eth_estimateGas under-reports on Injective and the shortfall is silent: initializePool estimated 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, replay broadcast/<script>/<chainId>/run-latest.json one cast send at a time after checking the nonce.
  • Confirm every contract with eth_getCode, never with a receipt. A cheap extra check: the runtime sizes forge build --sizes prints must equal the deployed code lengths.
  • Update deployments/injective_<network>.json in the same PR as the broadcast.

Finish with 08_VerifyOwnership

NETWORK=injective_testnet forge script script/08_VerifyOwnership.s.sol:VerifyOwnership \
    -vv --rpc-url $RPC_URL

Read-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.

Launchpad graduation (M4)

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.

A graduate pays Choice nothing, and that is the separation

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.

  1. clPoolManager.protocolFeeController is Choice's controller,
  2. clFeeController.launchPoolGuardHook is the guard hook,
  3. positionLocker.settler is this settler,
  4. 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.

The pool is un-campable, and that needs a hook

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.

The one coupling to watch

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.

Licence

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.

About

Choice v2 contracts: Foundry project over the Infinity forks, ChoiceFeeController + burn sinks, the deployments address book, and the v2 sdk. Live on Injective EVM testnet 1439.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages