feat: add Liquidity Hub (USDT, USDC, U) mainnet vip - #744
Conversation
- Ship the whole onboarding as one proposal instead of testnet's five. Inline it would be 110 commands (96 ACM grants + 14 wiring), over GovernorBravo's proposalMaxOperations of 100, so the grants are pre-seeded as one AuxiliaryCommandsAggregator batch and run in three commands. - Batch only the grants. Every batch call targets the ACM, so the aggregator holds no Hub permission. Wiring stays inline because its targets are the Hub and registry themselves, and acceptOwnership cannot be batched at all: Ownable2Step checks msg.sender against pendingOwner, which is the timelock. - Apply the asymmetric permission model, deliberately not the testnet one: Normal Timelock gets the full governance set, Fast-Track only the risk and ops levers, Critical nothing, Operator tighten-only plus reallocate. permissions.ts gains three fast-track sets; the existing sets are untouched so the testnet proposals are unchanged. - Register the FRV yield group but leave it unwired and off both outer queues. No fixed-rate vault instance exists for USDT on BNB Chain yet, and an empty group reports zero assets, so the withdraw queue coverage guard allows omitting it. - Leave the Hub stack addresses, the operator account, the batch index and the launch caps as marked placeholders. The stack is not deployed yet, so the simulation fails at validateTargetAddresses until it is.
- Ship one Hub per asset (USDT, USDC, U), each with Core, Flux, and FRV yield groups; the registry, adapters, and beacons are shared once per chain. - Set the mainnet permission model: the Normal Timelock holds full governance, a Guardian multisig holds emergency containment only (pause everywhere, emergencyReallocate, FRV forceRemoveResource), and the Operator keeper gains the raise-cap levers. Critical and Fast-Track receive nothing on the Hub stack. - Pre-seed the 233 ACM grants as three per-asset batches replayed by three executeBatch commands, since they exceed both GovernorBravo's 100-operation limit and the per-transaction gas cap. The aggregator holds DEFAULT_ADMIN_ROLE only transiently. - Fill every address from the live deployment, verified on-chain, and align the ABIs and fork simulation to the deployed contracts.
- All three assets in one proposal cannot be created at all: propose() needs ~23M gas against the 16,777,216 per-tx cap, because GovernorBravo copies every target and calldata into storage. proposalMaxOperations was never the binding constraint. - Split by asset into part 1 (USDT, USDC) and part 2 (U). The three Hub stacks are disjoint contract sets, so this is packaging only: neither part configures or depends on anything the other touches. - Part 1 carries the one-time HubRegistry acceptOwnership; part 2 re-grants addHub alone so it is authorised by its own grants rather than by part 1 having landed first. Re-granting a held role writes nothing and emits nothing. - Each part pins its own aggregator batch base. Batches are append-only and the two parts are provisioned at different times, so a shared base would silently point part 2 at the wrong slot. - FRV now sits last in each outer withdraw queue instead of being omitted: an omitted registered group can be made "funded" with a 1 wei transfer, permanently blocking any later queue reorder. - Commands and simulation assertions are shared by both parts, so what is proposed and what is asserted cannot drift.
There was a problem hiding this comment.
Pull request overview
Adds a two-part BNB Chain mainnet onboarding for the Liquidity Hub stacks for USDT, USDC, and U, including batched ACM permission provisioning via the AuxiliaryCommandsAggregator plus simulations that validate the full end-to-end launch behavior.
Changes:
- Introduces shared builders for ACM grants/batches, per-stack wiring + bootstrap deposit commands, and shared proposal metadata for both parts.
- Adds a one-off Hardhat script to seed and verify the on-chain aggregator batches at fixed indices prior to proposing.
- Adds comprehensive fork simulations (shared harness + per-part runners) and the required ABIs to validate permissions, ownership/registry state, wiring, bootstrap seeding, and key behavioral invariants.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| vips/vip-680/provisionAcmBatches.ts | Hardhat script to seed/verify ACM grant batches in AuxiliaryCommandsAggregator with index-drift protection. |
| vips/vip-680/permissions.ts | Defines role-string sets for governance/operator/guardian across Hub, sources, and HubRegistry. |
| vips/vip-680/commands.ts | Shared command/batch builders and shared voter-facing metadata for both proposal parts. |
| vips/vip-680/bscmainnet-part-1.ts | Mainnet proposal (part 1) for USDT+USDC plus one-time HubRegistry ownership acceptance. |
| vips/vip-680/bscmainnet-part-2.ts | Mainnet proposal (part 2) for U, including re-grant of addHub and its own proposal metadata. |
| vips/vip-680/addresses/bscmainnet.ts | Mainnet address book and cap constants for all three deployed Hub stacks and shared infra. |
| simulations/vip-680/shared.ts | Shared fork test harness asserting batch integrity, permissions, ownership/registry state, wiring, bootstrap, and E2E behavior. |
| simulations/vip-680/bscmainnet-part-1.ts | Fork simulation runner for part 1; seeds batches on fork when not present on-chain. |
| simulations/vip-680/bscmainnet-part-2.ts | Fork simulation runner for part 2; executes part 1 as setup then validates full end state. |
| simulations/vip-680/abi/YieldGroupFRV.json | ABI used by simulations to interact with FRV yield group contracts accurately. |
| simulations/vip-680/abi/AuxiliaryCommandsAggregator.json | ABI used by simulations for batch seeding, reading, and verification. |
| simulations/vip-680/abi/AccessControlManager.json | ABI used by simulations for role assertions and event counting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
trumpgpt-bot
left a comment
There was a problem hiding this comment.
Overall: this is an exceptionally thorough, well-engineered VIP — I found no correctness or security blockers. It reads like senior-level work: the proposals, the seeding script, and the simulation all rebuild grants/commands from the same commands.ts/permissions.ts source, and the sim exercises the real end-to-end path (deposit → operator reallocate → cascading withdraw → full exit → pause asymmetry → FRV donation-griefing) rather than just asserting hasRole.
What I verified independently on BNB Chain mainnet (chain.* + a Tenderly trace):
- Addresses & metadata: all three assets (USDT/USDC/U) are 18-dec; every Hub
asset()andpendingOwner()(== Normal Timelock); USDT stack’s vToken.underlying / fToken.asset / core|flux|frvhub()+asset()+accessControlManager()all bind correctly; HubRegistrypendingOwner==Timelock, ACM==0x4788…3555. - REGULAR is required: only the Normal Timelock holds
DEFAULT_ADMIN_ROLEon the ACM (Fast-Track does not) — confirmed. - Aggregator:
batchCount()== 2 ==ACM_BATCH_INDEX_BASE_PART_1, owner == Normal Timelock. - Role strings: every string in
permissions.tsmatches the ABI canonical signature; the 49/21/7 = 77-per-asset split checks out. A Tenderly trace ofaddHub(address)confirms it is ACM-gated (callsisAllowedToCall(caller,"addHub(address)")), notonlyOwner— so part 2’s re-grant design and its independence-from-part-1 claim are sound, and the role string is exactly what the contract checks. - Operator
0x83f4…a7Ais a live 3-of-N Safe and is the same keeper multisig used as WHITELISTED_EXECUTOR/RISK_PARAMETER_SENDER in VIP-592/598. - No regressions: the
permissions.tsrewrite only adds to the Operator/Guardian sets consumed by the new mainnetcommands.ts; the already-executed testnet files consume only the unchanged Governance sets +HUB_FULL, and the trimmed ACM ABI still carries every member the sims use.
Pre-propose gating (all correctly documented by the author, not defects — flagging as a merge checklist): the batch bases (2/4) are provisional and the batches are not yet seeded, so the "is comparing against REAL mainnet batches" test is skip()ed by design. Before proposing, provisionAcmBatches.ts must run per part, ACM_BATCH_INDEX_BASE_PART_{1,2} and both BLOCK_NUMBERs re-pinned past the seeding block, and — before proposing part 2 on-chain — the testVip("VIP-680 part 1 (setup…)") line dropped from bscmainnet-part-2.ts (else the re-run’s acceptOwnership() reverts). Could not run the fork sims in review.
Two minor inline notes below; neither blocks.
Summary
Files
Execution order (per proposal)
grantRole(DEFAULT_ADMIN_ROLE, aggregator)→executeBatch(i)per asset →revokeRole(...)HubRegistry.acceptOwnership()(part 1 only), thenaddHub(hub)per asset0x…dEaDOrder is load-bearing: the grant sandwich first (all wiring is ACM-gated),
addResourcebefore inner queues andaddYieldGroupbefore outer queues (both reject unregistered entries), bootstrap last (depositroutes through the outer queue).Wiring (per asset)
reallocateOuter deposit
[Core, Flux], outer withdraw[Flux, Core, FRV]. Fees launch at 0/0/0.FRV is listed last in the withdraw queue, not omitted. Omitting it is a griefing vector:
setOuterWithdrawQueuerejects a queue dropping a registered group withtotalAssets() > 0, andtotalAssets()counts idle balance, so 1 wei donated to an omitted FRV would permanently block queue reordering. Listing it costs nothing.Bootstrap — the deployed
decimalsOffsetof 6 makes first-deposit inflation non-griefing; seeding in the same tx additionally guarantees no external first depositor. Shares are burned, so "never zero-supply" is an on-chain property rather than policy. 30 tokens total, unrecoverable by design.Transient
DEFAULT_ADMIN_ROLEexecution — why, and the seeding steps234 grants inline exceed both
proposalMaxOperationsand the gas cap; batching brings part 1 to 37 commands. Aggregator0x528A428748dfE73DFcc844176B401475D1831057(live since VIP-628) replays pre-stored(target, calldata)pairs, so the proposal lends itDEFAULT_ADMIN_ROLEand revokes it in the same transaction.Part 2 needs it too, for a different reason: inline it is 94 commands, which does fit
proposalMaxOperations— butpropose()measured 22,954,977 gas (136.8% of cap) and failed beforeexecute()was reached. The 6-command margin is not reclaimable.Only grants are batched. Wiring targets the Hubs and registry directly; batching it would give a shared upgradeable contract standing Hub governance, and
acceptOwnership()cannot be batched at all (Ownable2Stepchecksmsg.sender).authorizedBatcherswallet, only after the VIP is final:batchCount() == the part's base, uses the indexedaddBatchoverload (revertsInvalidBatchIndexon drift), reads every batch back and deep-compares...._PART_1 = 2,..._PART_2 = 4); anything appended between the two runs shifts part 2 alone. Any code change after seeding invalidates the stored batches — re-run and update the base.REGULAR— only the Normal Timelock holdsDEFAULT_ADMIN_ROLEon the ACM.getBatch(2..4).addHubso it is authorised by its own batch rather than by part 1 having landed. No-op once part 1 is in (OZ guards_grantRolewithif (!hasRole(...))) — ~8k gas, no event.removeHubis not re-granted.Permission matrix — 77 grants per asset (49 Gov, 21 Operator, 7 Guardian)
Identical per asset. Critical and Fast-Track Timelocks get nothing.
Hub
addYieldGroup/removeYieldGroupraiseYieldGroupCap/lowerYieldGroupCapsetOuterDepositQueue/setOuterWithdrawQueuereallocateemergencyReallocatepauseHub/pauseYieldGroupunpauseHub/unpauseYieldGroupraiseMaxWithdrawalSizelowerMaxWithdrawalSizesetManagementFeeBps/setPerformanceFeeBps/setRedeemFeeBps/setFeeRecipientsweepYield sources
addResource/removeResource/updateResourceAdaptersetInnerDepositQueue/setInnerWithdrawQueuepauseResourceunpauseResource/sweepraiseResourceCap/lowerResourceCapsetBlocksPerYearforceRemoveResourceHubRegistry —
addHubto Gov in both parts,removeHubto Gov in part 1 only.Two deliberate departures from the shipment plan's tables, resolved against the deployed contracts:
pauseHub()— perHub.sol's(Operator, Guardian, or VIP)and the README; only the plan's table leaves that cell blank.reallocate— the plan's table says yes, its own prose says no. Gov holdsemergencyReallocate, which also works while paused.Operator and Guardian are multisigs, not timelocks — no governance delay. Neither holds any unpause, so neither can undo a governance-ordered pause; the Guardian set is a strict subset of Gov. Role strings are copied verbatim from
_checkAccessAllowed(...)(role =keccak256(contract, roleString)).Ownership & registry
HubRegistry.acceptOwnership()— part 1 only; cannot be repeated. Not a prerequisite foraddHub, which is ACM-gated, so part 2 does not depend on part 1 having landed.Hub.acceptOwnership()per asset, retiring the deployer's owner key._hub/_accessControlManagerbindings — the sim's on-chain check of all 36 is load-bearing.Simulation
Covers: batch deep-compare against the builder, upgrade authority, the full permission matrix, pre-VIP absence of every grant, post-VIP ownership / registration / wiring / bootstrap, an end-to-end deposit → cascading withdraw, and the FRV donation-griefing scenario.
Follow-ups before/after proposing
112783065predates the seeding, so the sim seeds on its own fork and one marker test reports the batches as fork-local. AfterprovisionAcmBatches.ts, re-pinBLOCK_NUMBERpast the printed block so it deep-compares the real stored batches.testVip("VIP-680 part 1 (setup…)")line fromsimulations/vip-680/bscmainnet-part-2.ts— otherwise it re-runs part 1 andacceptOwnership()reverts.