Skip to content

feat: add Liquidity Hub (USDT, USDC, U) mainnet vip - #744

Open
Debugger022 wants to merge 4 commits into
feat/VPD-1566from
feat/VPD-1653
Open

feat: add Liquidity Hub (USDT, USDC, U) mainnet vip#744
Debugger022 wants to merge 4 commits into
feat/VPD-1566from
feat/VPD-1653

Conversation

@Debugger022

@Debugger022 Debugger022 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Onboards Liquidity Hub for USDT, USDC, and U on BNB Chain.
  • Split into two VIPs to stay within the proposal gas limit.
  • Completes ownership acceptance, ACM permission setup, Hub registration, protocol wiring, and bootstrap deposits.
  • Adds simulations covering the complete onboarding flow.

Files

vips/vip-680/
  addresses/bscmainnet.ts   addresses + caps + batch bases (verified on-chain)
  permissions.ts            ACM role strings, verbatim from deployed contracts
  commands.ts               every command both proposals emit + shared description
  bscmainnet-part-1.ts      USDT + USDC + registry acceptOwnership
  bscmainnet-part-2.ts      U
  provisionAcmBatches.ts    one-off: stores grant batches on the aggregator
  bsctestnet*.ts            testnet rehearsals (already executed)
simulations/vip-680/
  shared.ts                 parameterised assertion harness used by both parts
  bscmainnet-part-{1,2}.ts  part 2 runs part 1 first, then asserts the full end state

Execution order (per proposal)

  1. grantRole(DEFAULT_ADMIN_ROLE, aggregator)executeBatch(i) per asset → revokeRole(...)
  2. HubRegistry.acceptOwnership() (part 1 only), then addHub(hub) per asset
  3. Per-asset wiring
  4. Bootstrap deposit — 10 tokens per asset from the Treasury, shares to 0x…dEaD

Order is load-bearing: the grant sandwich first (all wiring is ACM-gated), addResource before inner queues and addYieldGroup before outer queues (both reject unregistered entries), bootstrap last (deposit routes through the outer queue).

Wiring (per asset)

Source Absolute cap % cap Effect
Core 2,000,000,000 disabled (10,000 bps) binds on absolute; takes deposits immediately
Flux 7,000,000 2,000 bps % binds at launch; fills via Operator reallocate
FRV 5,000,000 3,000 bps config only — no live vault

Outer 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: setOuterWithdrawQueue rejects a queue dropping a registered group with totalAssets() > 0, and totalAssets() counts idle balance, so 1 wei donated to an omitted FRV would permanently block queue reordering. Listing it costs nothing.

Bootstrap — the deployed decimalsOffset of 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_ROLE execution — why, and the seeding steps

234 grants inline exceed both proposalMaxOperations and the gas cap; batching brings part 1 to 37 commands. Aggregator 0x528A428748dfE73DFcc844176B401475D1831057 (live since VIP-628) replays pre-stored (target, calldata) pairs, so the proposal lends it DEFAULT_ADMIN_ROLE and revokes it in the same transaction.

Part 2 needs it too, for a different reason: inline it is 94 commands, which does fit proposalMaxOperations — but propose() measured 22,954,977 gas (136.8% of cap) and failed before execute() 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 (Ownable2Step checks msg.sender).

  • Batches must be stored before the proposal is created, once per part, from an authorizedBatchers wallet, only after the VIP is final:
    VIP_PART=1 npx hardhat run vips/vip-680/provisionAcmBatches.ts --network bscmainnet
    VIP_PART=2 npx hardhat run vips/vip-680/provisionAcmBatches.ts --network bscmainnet
    The script asserts batchCount() == the part's base, uses the indexed addBatch overload (reverts InvalidBatchIndex on drift), reads every batch back and deep-compares.
  • Append-only. Each part pins its own base (..._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.
  • Both parts must be REGULAR — only the Normal Timelock holds DEFAULT_ADMIN_ROLE on the ACM.
  • Every stored call targets the ACM itself, so the aggregator never holds a permission on a Hub or the registry. Readable pre-vote via getBatch(2..4).
  • Part 2 re-grants addHub so it is authorised by its own batch rather than by part 1 having landed. No-op once part 1 is in (OZ guards _grantRole with if (!hasRole(...))) — ~8k gas, no event. removeHub is 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

Function Gov Operator Guardian
addYieldGroup / removeYieldGroup
raiseYieldGroupCap / lowerYieldGroupCap
setOuterDepositQueue / setOuterWithdrawQueue
reallocate
emergencyReallocate
pauseHub / pauseYieldGroup
unpauseHub / unpauseYieldGroup
raiseMaxWithdrawalSize
lowerMaxWithdrawalSize
setManagementFeeBps / setPerformanceFeeBps / setRedeemFeeBps / setFeeRecipient
sweep

Yield sources

Function On Gov Operator Guardian
addResource / removeResource / updateResourceAdapter all
setInnerDepositQueue / setInnerWithdrawQueue all
pauseResource all
unpauseResource / sweep all
raiseResourceCap / lowerResourceCap Core, Flux
setBlocksPerYear Core, Flux
forceRemoveResource FRV

HubRegistryaddHub to Gov in both parts, removeHub to Gov in part 1 only.

Two deliberate departures from the shipment plan's tables, resolved against the deployed contracts:

  • Guardian holds pauseHub() — per Hub.sol's (Operator, Guardian, or VIP) and the README; only the plan's table leaves that cell blank.
  • Gov is not granted reallocate — the plan's table says yes, its own prose says no. Gov holds emergencyReallocate, 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 for addHub, 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.
  • Yield sources have no owner and immutable _hub / _accessControlManager bindings — the sim's on-chain check of all 36 is load-bearing.
  • Part 1 registers the USDT and USDC Hubs, part 2 the U Hub. Registration is per-asset; the sim asserts part 2 does not displace either part-1 mapping.
  • Beacon / proxy-admin upgrade authority is the Normal Timelock throughout. Unchanged by this PR.

Simulation

npx hardhat test simulations/vip-680/bscmainnet-part-1.ts --fork bscmainnet
npx hardhat test simulations/vip-680/bscmainnet-part-2.ts --fork bscmainnet
# testnet (already executed): bsctestnet{,-wiring,-fast-track,-critical,-guardian,-hubregistry-upgrade}.ts --fork bsctestnet

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

  • Block 112783065 predates the seeding, so the sim seeds on its own fork and one marker test reports the batches as fork-local. After provisionAcmBatches.ts, re-pin BLOCK_NUMBER past the printed block so it deep-compares the real stored batches.
  • Once part 1 has executed and part 2's block is pinned past it, drop the testVip("VIP-680 part 1 (setup…)") line from simulations/vip-680/bscmainnet-part-2.ts — otherwise it re-runs part 1 and acceptOwnership() reverts.

- 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.
@Debugger022 Debugger022 self-assigned this Jul 29, 2026
- 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.
@Debugger022 Debugger022 changed the title feat: add Liquidity Hub (USDT) mainnet vip feat: add Liquidity Hub (USDT, USDC, U) mainnet vip Jul 29, 2026
@Debugger022
Debugger022 marked this pull request as ready for review July 30, 2026 04:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread vips/vip-680/bscmainnet-part-2.ts

@trumpgpt-bot trumpgpt-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and pendingOwner() (== Normal Timelock); USDT stack’s vToken.underlying / fToken.asset / core|flux|frv hub()+asset()+accessControlManager() all bind correctly; HubRegistry pendingOwner==Timelock, ACM==0x4788…3555.
  • REGULAR is required: only the Normal Timelock holds DEFAULT_ADMIN_ROLE on 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.ts matches the ABI canonical signature; the 49/21/7 = 77-per-asset split checks out. A Tenderly trace of addHub(address) confirms it is ACM-gated (calls isAllowedToCall(caller,"addHub(address)")), not onlyOwner — 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…a7A is 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.ts rewrite only adds to the Operator/Guardian sets consumed by the new mainnet commands.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.

Comment thread vips/vip-680/bscmainnet-part-2.ts
Comment thread vips/vip-680/addresses/bscmainnet.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants