feat: add Liquidity Hub (USDT, USDC, U) — VIP-650 / VIP-651 mainnet + BSC testnet wiring VPD-1566, 1588, 1653 - #733
Open
Debugger022 wants to merge 13 commits into
Open
feat: add Liquidity Hub (USDT, USDC, U) — VIP-650 / VIP-651 mainnet + BSC testnet wiring VPD-1566, 1588, 1653#733Debugger022 wants to merge 13 commits into
Debugger022 wants to merge 13 commits into
Conversation
- Grant the Hub's asymmetric ACM roles: the full governance set to the Normal timelock (which then runs the wiring) plus the Guardian operator set; a companion addendum grants governance to the Fast-Track and Critical timelocks. - Wire the Core yield source end-to-end (register vUSDT behind AdapterCoreV1, set inner and outer queues) so the USDT Hub routes deposits and withdrawals through the Core pool. - Split the grants into a main proposal plus bsctestnet-addendum.ts: all three timelocks' grants plus the wiring exceed BSC's per-tx propose gas cap in a single proposal. - Defer the FRV and Flux sources — neither has a concrete resource on testnet yet (no FRV vault instance; Flux adapter not deployed). - Add fork simulations for both proposals: pre/post state, the 40 and 58 role grants, and an end-to-end maxDeposit check.
- New proposal grants the Guardian the full Governance role set across the Hub stack (Hub, Core, FRV, Flux) so resources can be listed and reconfigured via multisig without a proposal per change - Deliberate testnet-only deviation from the asymmetric model where the Operator is tighten-only; kept in a bsctestnet file, not for mainnet - Factor the shared YieldGroupBase signatures into one base array; FRV uses YieldGroupFRV (adds forceRemoveResource, no cap setters or setBlocksPerYear) so it keeps a separate set - Update the main and addendum descriptions to reference the three proposals as one package
Debugger022
marked this pull request as ready for review
July 9, 2026 11:59
The Liquidity Hub (USDT) stack was redeployed on BNB Chain testnet (new HubRegistry, Hub, Core/FRV/Flux sources and adapters), so the previous VIP-680 targets stale addresses and predates the registry. Rewrite it to onboard the current deployment on-chain in one package. - Split into five REGULAR proposals: the full surface (ownership accepts, ~205 role grants, source wiring) exceeds the BSC per-tx propose gas cap in a single proposal. - main: accept Hub and registry ownership, grant the Normal Timelock the governance set across the stack. - wiring: register the Hub, then wire Core, FRV and Flux end to end (addHub before addYieldGroup, outer queue [FRV, Flux, Core]). - fast-track and critical: grant each timelock the governance set. - guardian: grant the Guardian full permissions (testnet only). - move addresses and ACM role strings into addresses.ts and permissions.ts; add the HubRegistry ABI, regenerate the Hub and YieldGroup ABIs, and drop the superseded addendum files.
…l + sim A separate proposal (bsctestnetHubregistryUpgrade), independent of the Liquidity Hub onboarding proposals, that upgrades the HubRegistry proxy to the implementation exposing `assetForHub(hub)` — the reverse of `hubForAsset`. Goes through the Normal-Timelock-owned ProxyAdmin; append-only change so a plain `upgrade` preserves registry storage. Adds HUB_REGISTRY_IMPL to addresses, a ProxyAdmin ABI, refreshes the HubRegistry ABI to include assetForHub. Fork sim (bsctestnet, block 119680000) passes 8/8: pre-VIP the getter reverts on the old impl; post-execution the proxy runs the new impl, emits Upgraded, and assetForHub resolves an unregistered hub to address(0).
- Move the address book to addresses/bsctestnet.ts so the upcoming mainnet VIP gets its own file - Keep permissions.ts shared and unprefixed: the role strings are literal contract function signatures, identical on mainnet, and the Operator sets it defines are unused on testnet and exist only for mainnet. Prefixing it would invite a copy that drifts - Pass the ACM into giveCallPermission so permissions.ts keeps no network coupling - Kebab-case bsctestnetHubregistryUpgrade.ts to match its siblings No change to the encoded proposals: same targets, signatures and params.
- 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.
- The registry proxy was redeployed behind the chain's shared DefaultProxyAdmin, so the previous proxy is dead. Nothing had been onboarded on it, so no state carries over. - Source DefaultProxyAdmin from NETWORK_ADDRESSES instead of a per-VIP literal, and assert the proxy's EIP-1967 admin slot points at it. The owner check alone only proved some ProxyAdmin is Timelock owned, not the one governing this proxy. - Re-pin both mainnet fork blocks past the new deployment and refresh the measured gas figures.
- Ran provisionAcmBatches.ts on BNB Chain, storing all 234 grants on
the AuxiliaryCommandsAggregator across indices 2..4:
2 79 calls, USDT + the registry's addHub/removeHub
0x9290a095b0079b2a33bc01639f3a7b4e34d32b47e8a29665fdcace97b26b33f2
3 77 calls, USDC
0x3489ad614e32fc3157cd3e0f0261bdccf13ba96c3ba3fdd2c44b78818a46d334
4 78 calls, U + the redundant addHub re-grant
0xccc34312a199b1593669a8732fd85350979264835179febbb574ab434909f554
Each batch was read back call-for-call after storing.
- Dropped the simulations' fork-seeding path now that there is real
state to read. A run that seeds its own batches and then deep-compares
them only proves the encoder is deterministic.
- Turned the skipped "comparing against REAL mainnet batches" test into
a hard assertion on batchCount(), so a fork block pinned before the
seeding transactions fails loudly instead of silently self-verifying.
- Re-pinned both fork blocks past the seeding transactions and refreshed
the measured gas figures.
Renumber the provisional vip-680 directory to the real proposal numbers: part 1 (USDT + USDC) is VIP-650, part 2 (U) is VIP-651. proposalCount() was 649 at preparation time. Strip inline backticks from the voter-facing description: the venus.io governance UI drops backticked spans, which would have blanked out every role name, function name and deployed address in the rendered text. The bsctestnet rehearsal files keep their original VIP-680 labels — they were already executed under those names; only their import paths moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: add Liquidity Hub (USDT, USDC, U) mainnet vips — VIP-650 and VIP-651
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Onboards the Liquidity Hub on BNB Chain, testnet rehearsal through to mainnet launch.
propose()gas cap. Prepared, not yet proposed.HubRegistryimplementation upgrade. All executed.Files
Mainnet — VIP-650 and VIP-651
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.ACM batches — stored on BNB Chain
The 234 grants are pre-seeded on the AuxiliaryCommandsAggregator
0x528A428748dfE73DFcc844176B401475D1831057and replayed by index. This has now been done, so the batch contents are frozen and readable before voting viagetBatch(2..4):addHub/removeHub0x9290a095…b33f20x3489ad61…46d334addHubre-grant0xccc34312…09f554batchCount()is now5.provisionAcmBatches.tsassertedbatchCount() == the part's basebefore each run, used the indexedaddBatchoverload (revertsInvalidBatchIndexon drift), and read every batch back call-for-call after storing it. The simulations independently deep-compare the stored bytes against the command builder on every run.One batch per asset because a single batch is the tightest transaction in the whole operation — 79 calls already costs 81.4% of the 16,777,216 per-tx cap, and all 156 of part 1's grants in one
addBatchwould need roughly 27M gas.Why the transient
DEFAULT_ADMIN_ROLE, and why part 2 needs it too234 grants inline exceed both
proposalMaxOperationsand the gas cap; batching brings part 1 to 37 commands. The aggregator (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)...._PART_1 = 2,..._PART_2 = 4). Both parts were seeded back-to-back so nothing could be appended in between and shift part 2. Any code change from here — a role string, an address, a cap — invalidates the stored batches and requires re-seeding at new slots.REGULAR— only the Normal Timelock holdsDEFAULT_ADMIN_ROLEon the ACM.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)). All 32 distinct strings were confirmed present byte-for-byte in the deployed runtime bytecode of the implementations behind the beacons and the registry proxy, with negative controls: FRV's implementation genuinely contains noraiseResourceCap/setBlocksPerYear, and Core/Flux genuinely contain noforceRemoveResource.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.Proposing
proposalCount()was 649 when these were prepared, so part 1 is VIP-650 and part 2 is VIP-651. Both areREGULAR: only the Normal Timelock holdsDEFAULT_ADMIN_ROLEon the ACM, which the grant/revoke sandwich needs.The two cannot be proposed in the same sitting.
GovernorBravoDelegate.propose()enforces one live proposal per proposer — it reverts while the caller's latest proposal isPendingorActive(lines 205-215). The REGULAR voting period is 192,384 blocks (~1 day at 0.45 s/block), so the proposer Safe can only submit part 2 once part 1 has reachedSucceededor later. Re-confirm part 2's proposalId before casting its vote: if any other proposer lands a proposal in that window, VIP-651 shifts and a pre-builtcastVote(651, 1)would target the wrong proposal.The voter-facing descriptions carry no inline code spans. The venus.io governance UI drops backticked text, which would otherwise blank out every role name, function name and address in the rendered proposal.
Simulation
Both are pinned at block
113736000, past the seeding transactions: part 1 — 89 passing, part 2 — 113 passing, nothing skipped.There is no fork-seeding fallback. If the batches are not on-chain at the fork block,
before()fails with the reason rather than writing its own copy — a suite that seeds its own batches and then deep-compares them proves only that the encoder is deterministic.Covers: batch deep-compare against the builder, upgrade authority (including the registry proxy's EIP-1967 admin slot), 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.
Measured gas, all within the 16,777,216 per-tx cap:
propose()queue()execute()Follow-up before proposing part 2
Nothing is outstanding for part 1. Once part 1 has executed on-chain,
simulations/vip-650/bscmainnet-part-2.tsneeds three changes — the block number alone is not enough, because part 1's effects will already be part of the pre-state:BLOCK_NUMBERpast part 1's execution block.testVip("VIP-650 part 1 (setup…)")line — otherwise it replays part 1 andacceptOwnership()reverts.registryAcceptedtotrueand narrowgrantsExpectedAbsentto the U stack's own grants, since the registry will be Timelock-owned and itsaddHubrole already granted.BNB Chain Testnet — executed
Onboards the Liquidity Hub (USDT) on BNB Chain Testnet: accepts ownership, grants every role, registers the Hub in the HubRegistry, and wires all three yield sources (Core, FRV, Flux). All executed on BNB Chain Testnet.
Proposals
The five permission/wiring proposals are split because the full surface (222 ops) is far over BSC's per-tx propose gas cap of 16,777,216 — the main proposal alone is 12.73M (75.85%). All
REGULAR. The sixth, 708, is an unrelated later addition: a single-op implementation upgrade.bsctestnet.tsacceptOwnership()on Hub and HubRegistrybsctestnet-wiring.tsaddHub→ per-sourceaddResource+ inner queues →addYieldGroup×3 → outer queuesbsctestnet-guardian.tsbsctestnet-fast-track.tsbsctestnet-critical.tsbsctestnet-hubregistry-upgrade.tsHubRegistryimplementation upgrade — adds theassetForHub(hub)reverse getter702 executes before 703 (the wiring needs 702's roles). The rest are independent. 708 is a later addition and targets the registry's ProxyAdmin
0x9f8413eEE33D434F6D4f40C83181f32A831c9ef7rather than the Hub stack; verified live — the proxy now runs impl0x4D2C18fB4520c2e4f7C754979e9a4F3BbC1BCe92andassetForHub(Hub_USDT)returns USDT.Two shared files keep addresses and role strings out of the call sites:
addresses/bsctestnet.ts(governance/ACM fromNETWORK_ADDRESSES, Hub stack inlined fromdeployments/bsctestnet/*.json) andpermissions.ts(role strings verbatim from each contract's_checkAccessAllowed).Configuration
Yield groups — registered in queue order, all uncapped per testnet policy:
0xA0Fb0fFeBdcB7F45A3Ec841cCE7F78B7CeBD0f820x9F6Edab0123188C852854D2D9601115168f52F7a0xeF0E85ab9A23F50EB4595CF7e2F5461feF7E7fc5type(uint128).max10000(off)0x044E572144bc08ed2D90E081EeEd7b5b6Cb010160x52217232e12A1c906aB8DEf58532a3618970D0250x15Dca35ae0b16BeceabAEC9Dea49630e8C601730type(uint128).max10000(off)0x11e39DC7b8b16BBDA8D9C2903dF741Ae9341Ec880xb7526572FFE56AB9D7489838Bf2E18e3323b441A0xDf669957448eCB23309eEFda4de230c62d22AE33type(uint128).max10000(off)type(uint128).maxis the canonical "no ceiling" — the Hub rejectstype(uint256).maxasInvalidCap.10000bps disables the percentage-of-TVL dimension.Queues
[FRVSource, FluxSource, CoreSource]. FRV first so deposits reach the vault under test; Core last because it's uncapped and would otherwise absorb everything.Core
0x5346f648029d1D1d1034e09e8AD7a115f5D7A1590x7cE6ADF754D0eC81A6CF8ACd9C7454F45077dc610xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c0xce10739590001705F7FF231611ba4A48B28203270x45f8a08F534f34A97187626E05d4b6648Eeaa9AASet at deploy, not by these VIPs
12(USDT 6 +decimalsOffset6)1e2400x8b293600C50D6fbdc6Ed4251cc75ECe29880276f70080000; FRV/Flux0— their adapters ignore the annualiserNotes
addHubfirst, soHubAddedprecedes everyYieldGroupAddedin the same tx; eachaddResourceprecedes its inner-queue setters; eachaddYieldGroupprecedes the outer-queue setters.