From 4a8056a9a3e5735f2dd00939736c58fbeceb20c7 Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Thu, 23 Jul 2026 19:07:38 +0530 Subject: [PATCH] feat: add Liquidity Hub (USDT) mainnet vip - 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. --- .../abi/AuxiliaryCommandsAggregator.json | 502 ++++++++++++++++++ simulations/vip-680/bscmainnet.ts | 411 ++++++++++++++ vips/vip-680/addresses/bscmainnet.ts | 145 +++++ vips/vip-680/bscmainnet.ts | 256 +++++++++ vips/vip-680/permissions.ts | 61 ++- vips/vip-680/utils/seedAcmBatch.ts | 129 +++++ 6 files changed, 1500 insertions(+), 4 deletions(-) create mode 100644 simulations/vip-680/abi/AuxiliaryCommandsAggregator.json create mode 100644 simulations/vip-680/bscmainnet.ts create mode 100644 vips/vip-680/addresses/bscmainnet.ts create mode 100644 vips/vip-680/bscmainnet.ts create mode 100644 vips/vip-680/utils/seedAcmBatch.ts diff --git a/simulations/vip-680/abi/AuxiliaryCommandsAggregator.json b/simulations/vip-680/abi/AuxiliaryCommandsAggregator.json new file mode 100644 index 000000000..19ad93417 --- /dev/null +++ b/simulations/vip-680/abi/AuxiliaryCommandsAggregator.json @@ -0,0 +1,502 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "BatchNotFound", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "batchIndex", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "callIndex", + "type": "uint256" + } + ], + "name": "CallFailed", + "type": "error" + }, + { + "inputs": [], + "name": "EmptyCalls", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidArrayLength", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expected", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "actual", + "type": "uint256" + } + ], + "name": "InvalidBatchIndex", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "NotAllowedToBatchCommands", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "address", + "name": "calledContract", + "type": "address" + }, + { + "internalType": "string", + "name": "methodSignature", + "type": "string" + } + ], + "name": "Unauthorized", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddressNotAllowed", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "authorized", + "type": "bool" + } + ], + "name": "AuthorizedBatcherUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "BatchAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "BatchExecuted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldAccessControlManager", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAccessControlManager", + "type": "address" + } + ], + "name": "NewAccessControlManager", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "accessControlManager", + "outputs": [ + { + "internalType": "contract IAccessControlManagerV8", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + } + ], + "name": "addAuthorizedBatchers", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "internalType": "struct AuxiliaryCommandsAggregator.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "name": "addBatch", + "outputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "internalType": "struct AuxiliaryCommandsAggregator.Call[]", + "name": "calls", + "type": "tuple[]" + }, + { + "internalType": "uint256", + "name": "expectedIndex", + "type": "uint256" + } + ], + "name": "addBatch", + "outputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorizedBatchers", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "batchCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "batches", + "outputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "executeBatch", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "index", + "type": "uint256" + } + ], + "name": "getBatch", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "internalType": "struct AuxiliaryCommandsAggregator.Call[]", + "name": "calls", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "accessControlManager_", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pendingOwner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + } + ], + "name": "removeAuthorizedBatchers", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "accessControlManager_", + "type": "address" + } + ], + "name": "setAccessControlManager", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/simulations/vip-680/bscmainnet.ts b/simulations/vip-680/bscmainnet.ts new file mode 100644 index 000000000..730bfd44b --- /dev/null +++ b/simulations/vip-680/bscmainnet.ts @@ -0,0 +1,411 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { ethers } from "hardhat"; +import { expectEvents, initMainnetUser } from "src/utils"; +import { forking, testVip } from "src/vip-framework"; + +import { + ACM, + ACM_BATCH_INDEX, + ADAPTER_CORE_V1, + ADAPTER_FLUX, + AUX_COMMANDS_AGGREGATOR, + CORE_ABSOLUTE_CAP, + CORE_PERCENTAGE_CAP_BPS, + CORE_SOURCE_USDT, + CRITICAL_TIMELOCK, + DEFAULT_ADMIN_ROLE, + FAST_TRACK_TIMELOCK, + FLUX_ABSOLUTE_CAP, + FLUX_PERCENTAGE_CAP_BPS, + FLUX_SOURCE_USDT, + FRV_ABSOLUTE_CAP, + FRV_PERCENTAGE_CAP_BPS, + FRV_SOURCE_USDT, + FUSDT_FLUX, + GUARDIAN, + HUB_REGISTRY, + HUB_USDT, + NORMAL_TIMELOCK, + OPERATOR, + USDT, + VUSDT_CORE, +} from "../../vips/vip-680/addresses/bscmainnet"; +import vip680Bscmainnet, { buildAcmBatch } from "../../vips/vip-680/bscmainnet"; +import { + CORE_FLUX_FAST_TRACK, + CORE_FLUX_GOVERNANCE, + FRV_FAST_TRACK, + FRV_GOVERNANCE, + HUB_FAST_TRACK, + HUB_GOVERNANCE, + HUB_REGISTRY_GOVERNANCE, + REALLOCATE, +} from "../../vips/vip-680/permissions"; +import ACCESS_CONTROL_MANAGER_ABI from "./abi/AccessControlManager.json"; +import AUX_AGGREGATOR_ABI from "./abi/AuxiliaryCommandsAggregator.json"; +import HUB_ABI from "./abi/Hub.json"; +import HUB_REGISTRY_ABI from "./abi/HubRegistry.json"; +import YIELD_GROUP_ABI from "./abi/YieldGroup.json"; + +// --------------------------------------------------------------------------------------------------- +// TODO(deploy): bump to a bscmainnet block AFTER the Liquidity Hub stack is deployed. +// +// This simulation cannot pass before that, by construction: testVip's "can be proposed" step runs +// validateTargetAddresses(), which requires bytecode at every target, and the Hub-stack addresses in +// addresses/bscmainnet.ts are still zero placeholders. Expect it red until the deploy lands — that is +// the mechanical expression of "the stack is not deployed yet", not a defect in this file. +// +// The value below is a real, recent block rather than 0 on purpose. forking() wraps its callback in +// try/catch, so a block the RPC rejects would abort registration and the file would report ZERO tests +// while exiting 0 — a silent pass. With a live block the fork connects and the failure surfaces as +// named assertion errors instead. +// --------------------------------------------------------------------------------------------------- +const BLOCK_NUMBER = 111658139; + +// The exact ACM role id giveCallPermission(contract, sig, account) grants: +// keccak256(abi.encodePacked(contract, functionSig)). Asserting hasRole on this specific role is immune +// to the isAllowedToCall wildcard fallback (keccak256(address(0), sig)), so "not granted" checks cannot +// be masked by a pre-existing wildcard. That matters most for sweep(address,address), which is a role +// string shared by the Hub and every YieldGroup. +const roleId = (contract: string, sig: string) => + ethers.utils.solidityKeccak256(["address", "string"], [contract, sig]); + +const addr = (a: string) => ethers.utils.getAddress(a); + +// An address in the aggregator's authorizedBatchers allowlist (verified on-chain, seeded by VIP-628). +// Impersonated below to seed the batch on the fork, since the real chain is only seeded once the VIP +// is final. +const AUTHORIZED_BATCHER = "0x9b0A3EAE7f174937d31745B710BbeA68e9D1BEf7"; + +// The batch the aggregator will replay, and the same list flattened to (contract, sig, account) +// triples for the role assertions. Derived from buildAcmBatch() — the one source of truth the +// proposal, the seed script and this simulation all share, so the sim cannot drift from what +// executes. Every batch entry targets the ACM; the Hub-stack contract is params[0]. +const ACM_BATCH = buildAcmBatch(); +const GRANTS = ACM_BATCH.map(c => ({ contract: c.params[0], sig: c.params[1], account: c.params[2] })); +const grantsFor = (account: string) => GRANTS.filter(g => addr(g.account) === addr(account)); + +// Encode the batch exactly as the seed script does, so the fork seeding and the on-chain deep-compare +// both exercise the same bytes. +const encodeBatch = () => + ACM_BATCH.map(c => ({ + target: c.target, + data: new ethers.utils.Interface([`function ${c.signature}`]).encodeFunctionData( + c.signature.slice(0, c.signature.indexOf("(")), + c.params, + ), + })); + +// Everything the Normal Timelock gets that the Fast-Track timelock deliberately does NOT: add/remove +// yield groups and resources, the four fee setters, sweep, updateResourceAdapter, setBlocksPerYear, +// forceRemoveResource and the whole registry. Computed as a set difference so it stays correct if the +// sets in permissions.ts change. +const WITHHELD_FROM_FAST_TRACK: [string, string][] = [ + ...HUB_GOVERNANCE.filter(s => !HUB_FAST_TRACK.includes(s)).map((s): [string, string] => [HUB_USDT, s]), + ...CORE_FLUX_GOVERNANCE.filter(s => !CORE_FLUX_FAST_TRACK.includes(s)).map((s): [string, string] => [ + CORE_SOURCE_USDT, + s, + ]), + ...CORE_FLUX_GOVERNANCE.filter(s => !CORE_FLUX_FAST_TRACK.includes(s)).map((s): [string, string] => [ + FLUX_SOURCE_USDT, + s, + ]), + ...FRV_GOVERNANCE.filter(s => !FRV_FAST_TRACK.includes(s)).map((s): [string, string] => [FRV_SOURCE_USDT, s]), + ...HUB_REGISTRY_GOVERNANCE.map((s): [string, string] => [HUB_REGISTRY, s]), +]; + +const OUTER_QUEUE = [FLUX_SOURCE_USDT, CORE_SOURCE_USDT]; + +forking(BLOCK_NUMBER, async () => { + let acm: Contract; + let hub: Contract; + let registry: Contract; + let core: Contract; + let flux: Contract; + let frv: Contract; + let aggregator: Contract; + + before(async () => { + acm = await ethers.getContractAt(ACCESS_CONTROL_MANAGER_ABI, ACM); + hub = await ethers.getContractAt(HUB_ABI, HUB_USDT); + registry = await ethers.getContractAt(HUB_REGISTRY_ABI, HUB_REGISTRY); + core = await ethers.getContractAt(YIELD_GROUP_ABI, CORE_SOURCE_USDT); + flux = await ethers.getContractAt(YIELD_GROUP_ABI, FLUX_SOURCE_USDT); + frv = await ethers.getContractAt(YIELD_GROUP_ABI, FRV_SOURCE_USDT); + aggregator = await ethers.getContractAt(AUX_AGGREGATOR_ABI, AUX_COMMANDS_AGGREGATOR); + + // Seed the batch on the fork if the real chain has not been seeded yet. Once the production seed + // transaction exists and BLOCK_NUMBER is pinned after it, batchCount() is already past + // ACM_BATCH_INDEX and this is a no-op — the deep-compare below then validates the REAL batch. + const batchCount: number = (await aggregator.batchCount()).toNumber(); + if (batchCount === ACM_BATCH_INDEX) { + const batcher = await initMainnetUser(AUTHORIZED_BATCHER, ethers.utils.parseEther("1")); + await aggregator.connect(batcher)["addBatch((address,bytes)[],uint256)"](encodeBatch(), ACM_BATCH_INDEX); + } + }); + + describe("Aggregator batch", () => { + it("the seeded batch is exactly buildAcmBatch(), call for call", async () => { + const expected = encodeBatch(); + const stored: { target: string; data: string }[] = await aggregator.getBatch(ACM_BATCH_INDEX); + expect(stored.length).to.equal(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(addr(stored[i].target)).to.equal(addr(expected[i].target), `batch call ${i} target`); + expect(stored[i].data).to.equal(expected[i].data, `batch call ${i} calldata`); + } + }); + + it("every batch call targets the ACM, so the aggregator needs no Hub permission", () => { + for (const c of ACM_BATCH) { + expect(addr(c.target)).to.equal(addr(ACM)); + expect(c.signature).to.equal("giveCallPermission(address,string,address)"); + } + }); + + it("the Normal Timelock can call executeBatch", async () => { + expect(await acm.hasRole(roleId(AUX_COMMANDS_AGGREGATOR, "executeBatch(uint256)"), NORMAL_TIMELOCK)).to.equal( + true, + ); + }); + }); + + describe("Permission matrix shape", () => { + it("grants 96 permissions, split 51 governance / 27 fast-track / 18 operator", () => { + expect(GRANTS.length).to.equal(96); + expect(grantsFor(NORMAL_TIMELOCK).length).to.equal(51); + expect(grantsFor(FAST_TRACK_TIMELOCK).length).to.equal(27); + expect(grantsFor(OPERATOR).length).to.equal(18); + }); + + it("contains no duplicate (contract, sig, account) entry", () => { + const keys = GRANTS.map(g => `${addr(g.contract)}|${g.sig}|${addr(g.account)}`); + expect(new Set(keys).size).to.equal(GRANTS.length); + }); + + it("grants nothing to the Critical Timelock or the Guardian", () => { + expect(grantsFor(CRITICAL_TIMELOCK).length).to.equal(0); + // Only holds while OPERATOR is not itself the Guardian; skip the check if it is. + if (addr(OPERATOR) !== addr(GUARDIAN)) { + expect(grantsFor(GUARDIAN).length).to.equal(0); + } + }); + + it("never grants the operator-only reallocate role to a timelock", () => { + const reallocateGrantees = GRANTS.filter(g => g.sig === REALLOCATE).map(g => addr(g.account)); + expect(reallocateGrantees).to.deep.equal([addr(OPERATOR)]); + }); + }); + + describe("Pre-VIP state", () => { + it("Hub and HubRegistry are pending-owned by the Normal Timelock (not yet accepted)", async () => { + expect(addr(await hub.pendingOwner())).to.equal(addr(NORMAL_TIMELOCK)); + expect(addr(await registry.pendingOwner())).to.equal(addr(NORMAL_TIMELOCK)); + expect(addr(await hub.owner())).to.not.equal(addr(NORMAL_TIMELOCK)); + expect(addr(await registry.owner())).to.not.equal(addr(NORMAL_TIMELOCK)); + }); + + it("every contract is already bound to the canonical ACM", async () => { + // The sources have NO owner and NO setAccessControlManager — _accessControlManager is written + // once at init with no setter anywhere, so a wrong value here is unrepairable by governance and + // must block the proposal. + for (const c of [hub, registry, core, flux, frv]) { + expect(addr(await c.accessControlManager())).to.equal(addr(ACM)); + } + }); + + it("every source is bound to this Hub and to USDT", async () => { + for (const src of [core, flux, frv]) { + expect(addr(await src.hub())).to.equal(addr(HUB_USDT)); + expect(addr(await src.asset())).to.equal(addr(USDT)); + } + expect(addr(await hub.asset())).to.equal(addr(USDT)); + }); + + it("nobody holds any of the roles this proposal grants", async () => { + for (const g of GRANTS) { + expect(await acm.hasRole(roleId(g.contract, g.sig), g.account)).to.equal( + false, + `pre ${g.contract} ${g.sig} ${g.account}`, + ); + } + }); + + it("the aggregator does not hold DEFAULT_ADMIN_ROLE", async () => { + expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, AUX_COMMANDS_AGGREGATOR)).to.equal(false); + }); + + it("Hub is unregistered, unwired and holds no yield groups", async () => { + expect(await registry.isHub(HUB_USDT)).to.equal(false); + expect(addr(await registry.hubForAsset(USDT))).to.equal(addr(ethers.constants.AddressZero)); + expect(await hub.registeredYieldGroups()).to.deep.equal([]); + for (const src of [core, flux, frv]) { + expect(await src.resources()).to.deep.equal([]); + } + }); + }); + + testVip("VIP-680 Liquidity Hub (USDT) onboarding on BNB Chain", await vip680Bscmainnet(), { + callbackAfterExecution: async txResponse => { + // 96 permission grants (each giveCallPermission calls grantRole internally) + the one + // DEFAULT_ADMIN_ROLE grant lent to the aggregator = 97 RoleGranted; that admin role is taken + // back in the same transaction = 1 RoleRevoked. PermissionGranted is emitted only by + // giveCallPermission, so it counts the permissions alone. + await expectEvents( + txResponse, + [ACCESS_CONTROL_MANAGER_ABI], + ["RoleGranted", "RoleRevoked", "PermissionGranted"], + [97, 1, 96], + ); + // Hub + HubRegistry each complete their Ownable2Step transfer. expectEvents counts by event + // name across the supplied ABIs without filtering by emitting address, so one ABI catches both. + await expectEvents(txResponse, [HUB_ABI], ["OwnershipTransferred", "YieldGroupAdded"], [2, 3]); + await expectEvents(txResponse, [HUB_REGISTRY_ABI], ["HubAdded"], [1]); + // Core and Flux only — FRV gets no resource in this proposal. + await expectEvents(txResponse, [YIELD_GROUP_ABI], ["ResourceAdded"], [2]); + + // Normative indexer requirement: HubAdded must land at a lower log index than every + // YieldGroupAdded, so the HubAdded handler can seed each Hub's yield-group set. + const receipt = await txResponse.wait(); + const topicHubAdded = new ethers.utils.Interface(HUB_REGISTRY_ABI).getEventTopic("HubAdded"); + const topicYieldGroupAdded = new ethers.utils.Interface(HUB_ABI).getEventTopic("YieldGroupAdded"); + const hubAddedIdx = receipt.logs.filter(l => l.topics[0] === topicHubAdded).map(l => l.logIndex); + const yieldGroupAddedIdx = receipt.logs.filter(l => l.topics[0] === topicYieldGroupAdded).map(l => l.logIndex); + expect(hubAddedIdx.length).to.equal(1); + expect(yieldGroupAddedIdx.length).to.equal(3); + expect(hubAddedIdx[0]).to.be.lessThan(Math.min(...yieldGroupAddedIdx)); + }, + }); + + describe("Post-VIP permissions", () => { + it("every one of the 96 grants landed", async () => { + for (const g of GRANTS) { + expect(await acm.hasRole(roleId(g.contract, g.sig), g.account)).to.equal( + true, + `post ${g.contract} ${g.sig} ${g.account}`, + ); + } + }); + + it("the aggregator no longer holds DEFAULT_ADMIN_ROLE", async () => { + expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, AUX_COMMANDS_AGGREGATOR)).to.equal(false); + }); + + it("Fast-Track holds none of the shape-changing roles reserved to the Normal Timelock", async () => { + for (const [c, s] of WITHHELD_FROM_FAST_TRACK) { + expect(await acm.hasRole(roleId(c, s), FAST_TRACK_TIMELOCK)).to.equal(false, `fast-track ${c} ${s}`); + } + }); + + it("the Normal Timelock does NOT hold the operator-only reallocate role", async () => { + expect(await acm.hasRole(roleId(HUB_USDT, REALLOCATE), NORMAL_TIMELOCK)).to.equal(false); + expect(await acm.hasRole(roleId(HUB_USDT, REALLOCATE), FAST_TRACK_TIMELOCK)).to.equal(false); + }); + + it("the Operator holds nothing outside its own set", async () => { + const held = new Set(grantsFor(OPERATOR).map(g => `${addr(g.contract)}|${g.sig}`)); + const everythingElse = GRANTS.filter(g => !held.has(`${addr(g.contract)}|${g.sig}`)); + for (const g of everythingElse) { + expect(await acm.hasRole(roleId(g.contract, g.sig), OPERATOR)).to.equal( + false, + `operator ${g.contract} ${g.sig}`, + ); + } + }); + + it("the Critical Timelock holds nothing at all", async () => { + for (const g of GRANTS) { + expect(await acm.hasRole(roleId(g.contract, g.sig), CRITICAL_TIMELOCK)).to.equal( + false, + `critical ${g.contract} ${g.sig}`, + ); + } + }); + }); + + describe("Post-VIP ownership and registration", () => { + it("Hub and HubRegistry are now owned by the Normal Timelock, with no pending transfer", async () => { + expect(addr(await hub.owner())).to.equal(addr(NORMAL_TIMELOCK)); + expect(addr(await registry.owner())).to.equal(addr(NORMAL_TIMELOCK)); + expect(addr(await hub.pendingOwner())).to.equal(addr(ethers.constants.AddressZero)); + expect(addr(await registry.pendingOwner())).to.equal(addr(ethers.constants.AddressZero)); + }); + + it("Hub is registered in the HubRegistry, both directions", async () => { + expect(await registry.isHub(HUB_USDT)).to.equal(true); + expect((await registry.getHubsCount()).toNumber()).to.equal(1); + expect(addr(await registry.hubForAsset(USDT))).to.equal(addr(HUB_USDT)); + expect(addr(await registry.assetForHub(HUB_USDT))).to.equal(addr(USDT)); + }); + }); + + describe("Post-VIP wiring", () => { + it("all three yield groups are registered, unpaused, at their configured caps", async () => { + expect((await hub.registeredYieldGroups()).map(addr)).to.have.members( + [FLUX_SOURCE_USDT, CORE_SOURCE_USDT, FRV_SOURCE_USDT].map(addr), + ); + const expected: [string, string, number][] = [ + [CORE_SOURCE_USDT, CORE_ABSOLUTE_CAP, CORE_PERCENTAGE_CAP_BPS], + [FLUX_SOURCE_USDT, FLUX_ABSOLUTE_CAP, FLUX_PERCENTAGE_CAP_BPS], + [FRV_SOURCE_USDT, FRV_ABSOLUTE_CAP, FRV_PERCENTAGE_CAP_BPS], + ]; + for (const [src, absoluteCap, pctBps] of expected) { + const cfg = await hub.yieldGroupConfig(src); + expect(cfg.registered).to.equal(true); + expect(cfg.paused).to.equal(false); + expect(cfg.absoluteCap.toString()).to.equal(absoluteCap.toString()); + expect(cfg.percentageCapBps).to.equal(pctBps); + } + }); + + it("Hub outer queues route [Flux, Core] for deposits and withdrawals", async () => { + const want = OUTER_QUEUE.map(addr); + expect((await hub.outerDepositQueue()).map(addr)).to.deep.equal(want); + expect((await hub.outerWithdrawQueue()).map(addr)).to.deep.equal(want); + }); + + it("Core and Flux each hold their resource behind the right adapter, with matching inner queues", async () => { + const cases: [Contract, string, string][] = [ + [core, VUSDT_CORE, ADAPTER_CORE_V1], + [flux, FUSDT_FLUX, ADAPTER_FLUX], + ]; + for (const [src, resource, adapter] of cases) { + expect((await src.resources()).map(addr)).to.deep.equal([addr(resource)]); + const [registered, paused, boundAdapter] = await src.resourceConfig(resource); + expect(registered).to.equal(true); + expect(paused).to.equal(false); + expect(addr(boundAdapter)).to.equal(addr(adapter)); + expect((await src.innerDepositQueue()).map(addr)).to.deep.equal([addr(resource)]); + expect((await src.innerWithdrawQueue()).map(addr)).to.deep.equal([addr(resource)]); + } + }); + + it("FRV is registered on the Hub but left unwired and off both outer queues", async () => { + expect(await frv.resources()).to.deep.equal([]); + expect((await frv.innerDepositQueue()).length).to.equal(0); + expect((await frv.innerWithdrawQueue()).length).to.equal(0); + expect((await hub.outerDepositQueue()).map(addr)).to.not.include(addr(FRV_SOURCE_USDT)); + expect((await hub.outerWithdrawQueue()).map(addr)).to.not.include(addr(FRV_SOURCE_USDT)); + }); + + it("the empty FRV group reports zero assets, so the withdraw-queue coverage guard allows omitting it", async () => { + // YieldGroupBase.totalAssets() iterates an empty resource list and returns 0 rather than + // reverting. A reverting view would be treated as funded and would have made + // setOuterWithdrawQueue([Flux, Core]) revert with WithdrawQueueOmitsFundedYieldGroup. + expect((await frv.totalAssets()).toString()).to.equal("0"); + }); + + it("the Hub advertises deposit capacity through the wired route", async () => { + // maxDeposit walks the whole live path: Hub outer queue -> source inner queue -> adapter. + expect(await hub.maxDeposit(NORMAL_TIMELOCK)).to.be.gt(0); + }); + + it("fees launch disabled and the Hub is unpaused", async () => { + const fees = await hub.feeBps(); + expect(fees.managementBps).to.equal(0); + expect(fees.performanceBps).to.equal(0); + expect(await hub.redeemFeeBps()).to.equal(0); + expect(await hub.hubPaused()).to.equal(false); + }); + }); +}); diff --git a/vips/vip-680/addresses/bscmainnet.ts b/vips/vip-680/addresses/bscmainnet.ts new file mode 100644 index 000000000..d75694b20 --- /dev/null +++ b/vips/vip-680/addresses/bscmainnet.ts @@ -0,0 +1,145 @@ +import { NETWORK_ADDRESSES } from "src/networkAddresses"; + +// =================================================================================================== +// VIP-680 [BNB Chain Mainnet] — Liquidity Hub (USDT) address book. +// +// STATUS: the Hub stack is NOT deployed on BNB Chain mainnet yet. venus-liquidity-hub/deployments/ +// holds only bsctestnet/, and deploy/config/bscmainnet.json still ships 0x0 for acm / governance / +// fluxLendingResolver / feeRecipient. Every address in the "Hub stack" sections below is therefore a +// TODO placeholder that must be filled from venus-liquidity-hub/deployments/bscmainnet/.json +// once the deploy lands. Everything outside those sections is live and was verified on-chain. +// +// The fork simulation cannot pass while the placeholders are zero: testVip's "can be proposed" step +// runs validateTargetAddresses(), which requires bytecode at every target. That is expected, not a +// defect — see simulations/vip-680/bscmainnet.ts. +// +// Verified live on bscmainnet (2026-07-23): +// - USDT.decimals() == 18 (NOTE: testnet USDT is 6-decimal; do not carry testnet amounts over) +// - vUSDT.underlying() == USDT +// - fUSDT.asset() == USDT, decimals 18, totalAssets ~2.98e24 +// - Comptroller.treasuryPercent() == 0 -> AdapterCoreV1.validateRegistration accepts vUSDT +// (a non-zero per-redeem treasury fee would block Core registration) +// - Of the four governance accounts, only NORMAL_TIMELOCK holds DEFAULT_ADMIN_ROLE on the ACM, +// so this proposal must be REGULAR +// - AuxiliaryCommandsAggregator: batchCount() == 2, owner() == NORMAL_TIMELOCK, and both +// NORMAL_TIMELOCK and FAST_TRACK_TIMELOCK hold executeBatch(uint256). CRITICAL_TIMELOCK's grants +// on it were revoked by VIP-645, which does not affect this proposal. +// =================================================================================================== + +const { ACCESS_CONTROL_MANAGER, NORMAL_TIMELOCK, FAST_TRACK_TIMELOCK, CRITICAL_TIMELOCK, GUARDIAN } = + NETWORK_ADDRESSES.bscmainnet; + +// --------------------------------------------------------------------------------------------------- +// Governance / access control (source: @venusprotocol/governance-contracts, via NETWORK_ADDRESSES). +// --------------------------------------------------------------------------------------------------- +export const ACM = ACCESS_CONTROL_MANAGER; // 0x4788629ABc6cFCA10F9f969efdEAa1cF70c23555 +export { NORMAL_TIMELOCK, FAST_TRACK_TIMELOCK }; +// Granted nothing by this proposal. Exported so the simulation can assert they stay empty-handed. +export { CRITICAL_TIMELOCK, GUARDIAN }; + +// TODO(ops): the Operator account is not decided yet. It plays the tighten-only role (lower caps, +// pause, reorder queues) plus the operator-exclusive `reallocate`. Candidates are the Guardian +// multisig (NETWORK_ADDRESSES.bscmainnet.GUARDIAN, 0x1C2CAc6ec528c20800B2fe734820D87b581eAA6B) or a +// dedicated operator account. Fill before running utils/seedAcmBatch.ts. +export const OPERATOR = "0x0000000000000000000000000000000000000000"; + +// --------------------------------------------------------------------------------------------------- +// ACM batching — AuxiliaryCommandsAggregator. +// +// The 96 ACM grants do not fit inline: 96 + 14 wiring commands = 110, above GovernorBravo's +// proposalMaxOperations of 100 (a hard revert in propose()), and far above the 16,777,216 per-tx gas +// cap. Pre-seeding them as one aggregator batch collapses them to three proposal commands. +// +// Every call in the batch targets the ACM itself — `giveCallPermission(contract, sig, account)` — so +// the aggregator never touches the Hub or the registry directly and needs no Hub permission. It only +// needs DEFAULT_ADMIN_ROLE on the ACM, which the VIP lends it for exactly one command and takes back +// in the same atomic transaction. +// +// The wiring commands stay inline on purpose. Their targets ARE the Hub and the registry, so batching +// them would require granting a shared, upgradeable contract standing Hub governance; and +// `acceptOwnership()` could not be batched at all, since Ownable2Step checks msg.sender against +// pendingOwner (the Timelock), which the aggregator is not. +// +// Source: governance-contracts/contracts/Utils/AuxiliaryCommandsAggregator.sol (develop). +// Address: deployments/bscmainnet/AuxiliaryCommandsAggregator.json, brought into service by VIP-628. +// --------------------------------------------------------------------------------------------------- +export const AUX_COMMANDS_AGGREGATOR = "0x528A428748dfE73DFcc844176B401475D1831057"; +export const DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +// The batch slot this VIP's grants occupy. Shared by the proposal, the seed script and the +// simulation so all three agree on one value. +// +// TODO(ops): re-verify immediately before seeding. `batchCount()` read 2 on 2026-07-23, and batches +// are append-only, so any batch seeded by another VIP in the meantime shifts this. The seed script +// asserts `batchCount() == ACM_BATCH_INDEX` and uses the indexed `addBatch(calls, expectedIndex)` +// overload, which reverts `InvalidBatchIndex` rather than silently landing in the wrong slot — bump +// this constant and re-run if it trips. +export const ACM_BATCH_INDEX = 2; + +// --------------------------------------------------------------------------------------------------- +// Hub stack — CALLED by the VIP. +// TODO(deploy): fill every address below from venus-liquidity-hub/deployments/bscmainnet/.json. +// --------------------------------------------------------------------------------------------------- +export const HUB_REGISTRY = "0x0000000000000000000000000000000000000000"; // HubRegistry.json +export const HUB_USDT = "0x0000000000000000000000000000000000000000"; // Hub_USDT.json +export const CORE_SOURCE_USDT = "0x0000000000000000000000000000000000000000"; // CoreSource_USDT.json +export const FRV_SOURCE_USDT = "0x0000000000000000000000000000000000000000"; // FRVSource_USDT.json +export const FLUX_SOURCE_USDT = "0x0000000000000000000000000000000000000000"; // FluxSource_USDT.json +export const ADAPTER_CORE_V1 = "0x0000000000000000000000000000000000000000"; // AdapterCoreV1.json +export const ADAPTER_FRV = "0x0000000000000000000000000000000000000000"; // AdapterFRV.json +export const ADAPTER_FLUX = "0x0000000000000000000000000000000000000000"; // AdapterFlux.json + +// Ownable2Step contracts whose ownership is transferred to governance at deploy and must be accepted +// by the VIP. Until acceptOwnership() runs, the deployer EOA is still the LIVE owner and can repoint +// setAccessControlManager (onlyOwner), so accepting early retires that key. +export const OWNERSHIP_ACCEPT_TARGETS = [HUB_REGISTRY, HUB_USDT]; + +// --------------------------------------------------------------------------------------------------- +// Resources registered inside each source — CALLED by the VIP (addResource + inner queues). +// Live mainnet markets, verified on-chain. Cross-checked against +// venus-liquidity-hub/tests/foundry/fork/base/ForkAddresses.sol:74-97, which the repo's own mainnet +// fork suite binds against. +// --------------------------------------------------------------------------------------------------- +export const VUSDT_CORE = "0xfD5840Cd36d94D7229439859C0112a4185BC0255"; // @venusprotocol/venus-protocol core vUSDT +export const FUSDT_FLUX = "0xA5b8FCa32E5252B0B58EAbf1A8c79d958F8EE6A2"; // Fluid fUSDT fToken (ERC-4626) + +// Underlying asset shared by the Hub and every resource above. 18 decimals on mainnet. +export const USDT = "0x55d398326f99059fF775485246999027B3197955"; + +// NOTE: there is no FRV vault instance for USDT on mainnet — only the controller +// (0x6D9e91cB766259af42619c14c994E694E57e6E85), the loan-vault implementation and the position token. +// ForkAddresses.sol:88 confirms mainnet vault instances are created on-fork. The FRV YieldGroup is +// therefore registered by this VIP but left with no resource and kept out of both outer queues; a +// follow-up VIP wires it once a vault exists. + +// --------------------------------------------------------------------------------------------------- +// Hub stack — REFERENCE ONLY (not called by the VIP; single-step Ownable, owned by governance at +// deploy, so no acceptOwnership). Recorded so the simulation can assert their owners. +// TODO(deploy): fill from venus-liquidity-hub/deployments/bscmainnet/.json. +// --------------------------------------------------------------------------------------------------- +export const HUB_BEACON = "0x0000000000000000000000000000000000000000"; +export const CORE_BEACON = "0x0000000000000000000000000000000000000000"; +export const FRV_BEACON = "0x0000000000000000000000000000000000000000"; +export const FLUX_BEACON = "0x0000000000000000000000000000000000000000"; +export const HUB_REGISTRY_PROXY_ADMIN = "0x0000000000000000000000000000000000000000"; +export const MIGRATOR = "0x0000000000000000000000000000000000000000"; // immutable, permissionless — no wiring + +// --------------------------------------------------------------------------------------------------- +// Cap constants for Hub.addYieldGroup(source, absoluteCap, percentageCapBps). +// --------------------------------------------------------------------------------------------------- +// The Hub rejects type(uint256).max as InvalidCap; type(uint128).max is the canonical "no ceiling". +export const ABSOLUTE_CAP_UNBOUNDED = "340282366920938463463374607431768211455"; // type(uint128).max +// 10_000 bps disables the percentage-of-TVL cap dimension, leaving only the absolute cap binding. It +// is also required for a fresh Hub: at TVL 0 any pct < 100% collapses the effective cap to zero and +// the first deposit could not land. +export const PERCENTAGE_CAP_DISABLED = 10_000; + +// TODO(risk): mainnet launch caps are not decided. The values below mirror the testnet policy +// (uncapped) so the proposal is complete and reviewable; replace them with the risk team's numbers +// before proposing. Amounts are in USDT units — 18 decimals on mainnet, NOT the 6 used on testnet. +export const CORE_ABSOLUTE_CAP = ABSOLUTE_CAP_UNBOUNDED; +export const CORE_PERCENTAGE_CAP_BPS = PERCENTAGE_CAP_DISABLED; +export const FLUX_ABSOLUTE_CAP = ABSOLUTE_CAP_UNBOUNDED; +export const FLUX_PERCENTAGE_CAP_BPS = PERCENTAGE_CAP_DISABLED; +export const FRV_ABSOLUTE_CAP = ABSOLUTE_CAP_UNBOUNDED; +export const FRV_PERCENTAGE_CAP_BPS = PERCENTAGE_CAP_DISABLED; diff --git a/vips/vip-680/bscmainnet.ts b/vips/vip-680/bscmainnet.ts new file mode 100644 index 000000000..346fff933 --- /dev/null +++ b/vips/vip-680/bscmainnet.ts @@ -0,0 +1,256 @@ +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +import { + ACM, + ACM_BATCH_INDEX, + ADAPTER_CORE_V1, + ADAPTER_FLUX, + AUX_COMMANDS_AGGREGATOR, + CORE_ABSOLUTE_CAP, + CORE_PERCENTAGE_CAP_BPS, + CORE_SOURCE_USDT, + DEFAULT_ADMIN_ROLE, + FAST_TRACK_TIMELOCK, + FLUX_ABSOLUTE_CAP, + FLUX_PERCENTAGE_CAP_BPS, + FLUX_SOURCE_USDT, + FRV_ABSOLUTE_CAP, + FRV_PERCENTAGE_CAP_BPS, + FRV_SOURCE_USDT, + FUSDT_FLUX, + HUB_REGISTRY, + HUB_USDT, + NORMAL_TIMELOCK, + OPERATOR, + VUSDT_CORE, +} from "./addresses/bscmainnet"; +import { + CORE_FLUX_FAST_TRACK, + CORE_FLUX_GOVERNANCE, + CORE_FLUX_OPERATOR, + FRV_FAST_TRACK, + FRV_GOVERNANCE, + FRV_OPERATOR, + HUB_FAST_TRACK, + HUB_GOVERNANCE, + HUB_OPERATOR, + HUB_REGISTRY_GOVERNANCE, +} from "./permissions"; + +// --------------------------------------------------------------------------------------------------- +// VIP-680 — BNB Chain Mainnet. Liquidity Hub (USDT) onboarding, in ONE proposal. +// +// Testnet needed five proposals because each ACM grant was its own proposal command. Inline, mainnet +// would be 96 grants + 14 wiring = 110 commands — above GovernorBravo's proposalMaxOperations of 100 +// (a hard revert in propose()) and far above the per-tx gas cap. Pre-seeding the grants as one +// AuxiliaryCommandsAggregator batch collapses them to three commands, so the entire onboarding — +// permissions, ownership, registration and wiring — lands atomically in 17. Nothing is deferred +// except the FRV resource, which cannot exist yet (see below). +// +// Only the grants are batched. The wiring stays inline because its targets ARE the Hub and the +// registry: batching it would mean granting a shared, upgradeable contract standing Hub governance, +// and `acceptOwnership()` could not be batched at all (Ownable2Step checks msg.sender against +// pendingOwner, which is the Timelock, not the aggregator). Keeping it inline also leaves the +// substance of the proposal readable on-chain instead of behind a batch index. +// +// This proposal MUST be REGULAR. Of the four governance accounts, only the Normal Timelock holds +// DEFAULT_ADMIN_ROLE on the ACM, and commands [0] and [2] need it. A fast-track or critical variant +// would revert on the grantRole. +// +// Permission model — asymmetric, and deliberately NOT the testnet one: +// Normal Timelock full Governance set (loosen and tighten, everything) +// Fast-Track risk/ops levers only, both directions; no add/remove, no fees, no adapter +// repointing, no sweep, no registry +// Critical nothing +// Operator tighten-only plus the operator-exclusive `reallocate` +// The testnet Guardian's full-Governance grant is a testnet-only deviation and is not mirrored here. +// +// Ordering is load-bearing and must not be reshuffled: +// - the aggregator sandwich runs first, because every wiring command below is ACM-gated; +// - addHub precedes every addYieldGroup, so HubAdded lands at a lower log index than any +// YieldGroupAdded (indexers seed each Hub's yield-group set from that ordering); +// - each addResource precedes its inner-queue setters (the setters reject unregistered resources); +// - each addYieldGroup precedes the outer-queue setters (they reject unregistered groups). +// +// FRV: no FRV vault instance exists for USDT on mainnet — only the controller and implementation. The +// FRV YieldGroup is registered here so it is discoverable and capped from day one, but it gets no +// resource and is kept out of both outer queues. That is safe: YieldGroupBase.totalAssets() iterates +// an empty resource list and returns 0 rather than reverting, so the group counts as unfunded and the +// Hub's withdraw-queue coverage guard permits omitting it. A follow-up proposal adds the resource, +// its inner queues and its outer-queue slot once a vault is created. +// --------------------------------------------------------------------------------------------------- + +export interface AcmCommand { + target: string; + signature: string; + params: [string, string, string]; +} + +// Every entry targets the ACM itself; the Hub-stack contract is an ARGUMENT, not the target. That is +// what keeps the aggregator free of any Hub permission — it only ever calls the ACM. +const grants = (contract: string, sigs: string[], account: string): AcmCommand[] => + sigs.map(sig => ({ + target: ACM, + signature: "giveCallPermission(address,string,address)", + params: [contract, sig, account] as [string, string, string], + })); + +// The exact 96 ACM calls the aggregator batch performs. Exported because three consumers must agree +// on it byte for byte: the seed script that stores it on the aggregator, the simulation that +// deep-compares it against on-chain `getBatch()`, and this proposal — which references it only by +// index, so nothing else would catch a drift. +export const buildAcmBatch = (): AcmCommand[] => [ + // --- Normal Timelock: full Governance set across the stack (51) ----------------------------------- + ...grants(HUB_USDT, HUB_GOVERNANCE, NORMAL_TIMELOCK), + ...grants(CORE_SOURCE_USDT, CORE_FLUX_GOVERNANCE, NORMAL_TIMELOCK), + ...grants(FLUX_SOURCE_USDT, CORE_FLUX_GOVERNANCE, NORMAL_TIMELOCK), + ...grants(FRV_SOURCE_USDT, FRV_GOVERNANCE, NORMAL_TIMELOCK), + ...grants(HUB_REGISTRY, HUB_REGISTRY_GOVERNANCE, NORMAL_TIMELOCK), + + // --- Fast-Track Timelock: risk/ops levers, both directions (27) ----------------------------------- + ...grants(HUB_USDT, HUB_FAST_TRACK, FAST_TRACK_TIMELOCK), + ...grants(CORE_SOURCE_USDT, CORE_FLUX_FAST_TRACK, FAST_TRACK_TIMELOCK), + ...grants(FLUX_SOURCE_USDT, CORE_FLUX_FAST_TRACK, FAST_TRACK_TIMELOCK), + ...grants(FRV_SOURCE_USDT, FRV_FAST_TRACK, FAST_TRACK_TIMELOCK), + + // --- Operator: tighten-only plus `reallocate` (18) ------------------------------------------------ + ...grants(HUB_USDT, HUB_OPERATOR, OPERATOR), + ...grants(CORE_SOURCE_USDT, CORE_FLUX_OPERATOR, OPERATOR), + ...grants(FLUX_SOURCE_USDT, CORE_FLUX_OPERATOR, OPERATOR), + ...grants(FRV_SOURCE_USDT, FRV_OPERATOR, OPERATOR), +]; + +// Inner queues: one resource each. FRV is intentionally absent — it has no resource yet. +const CORE_ONLY = [VUSDT_CORE]; +const FLUX_ONLY = [FUSDT_FLUX]; + +// Outer queues: Flux first, Core last. Core is the uncapped backstop and would otherwise swallow every +// deposit before Flux received any. Same order for deposits and withdrawals, so the deposit-queue +// subset-of-withdraw-queue operator invariant holds by construction. +const OUTER_QUEUE = [FLUX_SOURCE_USDT, CORE_SOURCE_USDT]; + +export const vip680Bscmainnet = () => { + const meta = { + version: "v2", + title: "VIP-680 Liquidity Hub (USDT) — onboard on BNB Chain", + description: `#### Summary + +Onboards the **Liquidity Hub (USDT)** on BNB Chain in a single proposal: it provisions the access +control across the whole stack, accepts the Hub's and the registry's pending ownership, registers the +Hub in the **HubRegistry**, and wires the **Core** and **Flux** yield sources end to end. + +The Hub is a per-asset ERC-4626 allocator vault. A lender deposits USDT and the Hub routes it across +Venus yield families under a governance-set policy, returning a yield-bearing share token whose +exchange rate rises as yield accrues. Fees launch disabled at 0/0/0. + +#### Access-control model + +The Hub uses an asymmetric permission model. **Governance** can both loosen and tighten; the +**Operator** can only tighten (lower caps, pause, reorder queues) plus the operator-exclusive +\`reallocate\`. This proposal provisions three tiers: + +- **Normal Timelock** — the full Governance role set on the Hub, on the Core, FRV and Flux yield + sources, and on the HubRegistry. +- **Fast-Track Timelock** — risk and operational levers only, in both directions: raise/lower the + yield-group and per-resource caps, raise/lower the per-transaction withdrawal cap, reorder the + inner and outer queues, pause and unpause the Hub, a yield group or a resource, and + \`emergencyReallocate\`. It receives no ability to add or remove a yield group or resource, change + fees, repoint a resource adapter, sweep, or touch the registry. +- **Operator** — the tighten-only set plus \`reallocate\`. + +The Critical Timelock receives no permissions in this proposal. + +#### Actions (one atomic transaction, in order) + +1. Grant \`DEFAULT_ADMIN_ROLE\` on the AccessControlManager to the AuxiliaryCommandsAggregator, execute + the pre-seeded batch of 96 permission grants, then revoke that role again in the same transaction. + Every call in the batch targets the AccessControlManager, so the aggregator holds no permission on + the Hub or the registry at any point. +2. Accept the pending ownership of the **HubRegistry**, then register **Hub_USDT** in it, so the + \`HubAdded\` event precedes every \`YieldGroupAdded\`. +3. Accept the pending ownership of **Hub_USDT**. Ownership of both contracts was transferred to + governance at deploy time and left pending; accepting it retires the deployer's owner key, which + gates \`setAccessControlManager\`. +4. On the **Core** source, register **vUSDT** behind its adapter and set the inner deposit and + withdraw queues. +5. On the **Flux** source, register the **Fluid fUSDT** fToken behind its adapter and set the inner + deposit and withdraw queues. +6. Register the **Flux**, **Core** and **FRV** yield sources on the Hub, then point the Hub's outer + deposit and withdraw queues at **[Flux, Core]**. + +#### Notes + +- Queue order sends deposits to Flux first, with Core last as the backstop. +- The **FRV** yield source is registered but left unwired: no fixed-rate vault instance exists for + USDT on BNB Chain yet, so there is nothing to register as a resource. It is kept out of both outer + queues while it holds no funds, and a follow-up proposal will wire it once a vault is created. +- The Migrator needs no permissions — it is immutable and permissionless. +- Fees launch at 0/0/0; the machinery exists for governance to enable them later.`, + forDescription: "Execute this proposal", + againstDescription: "Do not execute this proposal", + abstainDescription: "Indifferent to execution", + }; + + return makeProposal( + [ + // 1. Execute the pre-seeded batch of all 96 ACM grants, lending the aggregator + // DEFAULT_ADMIN_ROLE for exactly one command and taking it back in the same transaction. + // The batch contents are buildAcmBatch() above; the proposal names only the index, so + // ACM_BATCH_INDEX must be confirmed against on-chain getBatch() before proposing. + { + target: ACM, + signature: "grantRole(bytes32,address)", + params: [DEFAULT_ADMIN_ROLE, AUX_COMMANDS_AGGREGATOR], + }, + { target: AUX_COMMANDS_AGGREGATOR, signature: "executeBatch(uint256)", params: [ACM_BATCH_INDEX] }, + { + target: ACM, + signature: "revokeRole(bytes32,address)", + params: [DEFAULT_ADMIN_ROLE, AUX_COMMANDS_AGGREGATOR], + }, + + // 2. Take the registry, then register the Hub (must precede every addYieldGroup below). + { target: HUB_REGISTRY, signature: "acceptOwnership()", params: [] }, + { target: HUB_REGISTRY, signature: "addHub(address)", params: [HUB_USDT] }, + + // 3. Take the Hub, retiring the deployer's owner key. + { target: HUB_USDT, signature: "acceptOwnership()", params: [] }, + + // 4. Core source: register vUSDT behind AdapterCoreV1, then set inner queues. + { target: CORE_SOURCE_USDT, signature: "addResource(address,address)", params: [VUSDT_CORE, ADAPTER_CORE_V1] }, + { target: CORE_SOURCE_USDT, signature: "setInnerDepositQueue(address[])", params: [CORE_ONLY] }, + { target: CORE_SOURCE_USDT, signature: "setInnerWithdrawQueue(address[])", params: [CORE_ONLY] }, + + // 5. Flux source: register the Fluid fUSDT fToken behind AdapterFlux, then set inner queues. + { target: FLUX_SOURCE_USDT, signature: "addResource(address,address)", params: [FUSDT_FLUX, ADAPTER_FLUX] }, + { target: FLUX_SOURCE_USDT, signature: "setInnerDepositQueue(address[])", params: [FLUX_ONLY] }, + { target: FLUX_SOURCE_USDT, signature: "setInnerWithdrawQueue(address[])", params: [FLUX_ONLY] }, + + // 6. Register the three sources on the Hub (queue members first), then point the outer queues at + // them. FRV is registered but omitted from the queues: it holds no funds and has no resource, + // so the withdraw-queue funded-coverage guard is satisfied. + { + target: HUB_USDT, + signature: "addYieldGroup(address,uint256,uint16)", + params: [FLUX_SOURCE_USDT, FLUX_ABSOLUTE_CAP, FLUX_PERCENTAGE_CAP_BPS], + }, + { + target: HUB_USDT, + signature: "addYieldGroup(address,uint256,uint16)", + params: [CORE_SOURCE_USDT, CORE_ABSOLUTE_CAP, CORE_PERCENTAGE_CAP_BPS], + }, + { + target: HUB_USDT, + signature: "addYieldGroup(address,uint256,uint16)", + params: [FRV_SOURCE_USDT, FRV_ABSOLUTE_CAP, FRV_PERCENTAGE_CAP_BPS], + }, + { target: HUB_USDT, signature: "setOuterDepositQueue(address[])", params: [OUTER_QUEUE] }, + { target: HUB_USDT, signature: "setOuterWithdrawQueue(address[])", params: [OUTER_QUEUE] }, + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip680Bscmainnet; diff --git a/vips/vip-680/permissions.ts b/vips/vip-680/permissions.ts index 2aec5cd51..7c5addce9 100644 --- a/vips/vip-680/permissions.ts +++ b/vips/vip-680/permissions.ts @@ -101,6 +101,56 @@ export const FRV_OPERATOR = [ "pauseResource(address)", ]; +// --------------------------------------------------------------------------------------------------- +// Fast-Track sets (mainnet). A middle tier between Governance and Operator: every risk/ops lever, in +// BOTH directions (raise and lower, pause and unpause), but nothing that changes the shape of the +// stack. Withheld from the fast lane and reserved to the Normal Timelock: add/remove YieldGroup, +// add/remove/forceRemove Resource, updateResourceAdapter, the four fee setters, sweep, +// setBlocksPerYear, and the registry entirely. +// +// `updateResourceAdapter` repoints a resource's delegatecall target and is the highest-risk function +// in the stack — it stays governance-only. This mirrors the mainnet convention in VIP-627, which kept +// setOracle / setComptroller / setLiquidationAdapter off the fast lane while granting it the risk +// parameters, and VIP-258, which gave the fast lanes both the pause AND the resume side of each +// reversible lever. +// --------------------------------------------------------------------------------------------------- + +// `emergencyReallocate` is the only fund-mover here. It is included because it is the sole route that +// still works while the Hub is paused (the Operator's `reallocate` reverts once paused), so without it +// recovering a paused Hub needs a full 48h Normal proposal. It is net-zero and confined to registered +// routes, so it cannot move value out of the Hub. +export const HUB_FAST_TRACK = [ + "raiseYieldGroupCap(address,uint256,uint16)", + "lowerYieldGroupCap(address,uint256,uint16)", + "raiseMaxWithdrawalSize(uint256)", + "lowerMaxWithdrawalSize(uint256)", + "setOuterDepositQueue(address[])", + "setOuterWithdrawQueue(address[])", + "pauseHub()", + "unpauseHub()", + "pauseYieldGroup(address)", + "unpauseYieldGroup(address)", + "emergencyReallocate((address,address,uint256)[],(address,address,uint256)[])", +]; + +// Core & Flux: the per-resource cap in both directions, inner queues, and resource pause/unpause. +export const CORE_FLUX_FAST_TRACK = [ + "raiseResourceCap(address,uint256)", + "lowerResourceCap(address,uint256)", + "setInnerDepositQueue(address[])", + "setInnerWithdrawQueue(address[])", + "pauseResource(address)", + "unpauseResource(address)", +]; + +// FRV has no per-resource cap, so its fast-track set is the queue + pause subset only. +export const FRV_FAST_TRACK = [ + "setInnerDepositQueue(address[])", + "setInnerWithdrawQueue(address[])", + "pauseResource(address)", + "unpauseResource(address)", +]; + // --------------------------------------------------------------------------------------------------- // HubRegistry — the only two gated functions. // --------------------------------------------------------------------------------------------------- @@ -117,7 +167,10 @@ export const giveCallPermission = (acm: string, contract: string, sig: string, a params: [contract, sig, account], }); -// NOTE for a future mainnet VIP: the mainnet Guardian must receive ONLY the Operator sets -// (HUB_OPERATOR on the Hub, CORE_FLUX_OPERATOR on Core/Flux, FRV_OPERATOR on FRV — no registry, no -// Governance). HUB_FULL / the full-Governance Guardian grant in bsctestnet-guardian.ts is a -// testnet-only deviation and must not be mirrored to mainnet. +// NOTE on the testnet/mainnet split. bsctestnet-guardian.ts grants the Guardian HUB_FULL plus the +// full Governance set on every source — a testnet-only deviation, made to speed up iteration, that is +// deliberately NOT mirrored to mainnet. bscmainnet.ts applies the asymmetric model instead: the +// Normal Timelock holds the Governance sets, the Fast-Track timelock holds the *_FAST_TRACK sets, and +// the Operator holds only HUB_OPERATOR / CORE_FLUX_OPERATOR / FRV_OPERATOR (no registry, no +// Governance). Likewise the *_FAST_TRACK sets above are mainnet-only — on testnet the fast lanes hold +// the full Governance set for parity with the Normal Timelock. diff --git a/vips/vip-680/utils/seedAcmBatch.ts b/vips/vip-680/utils/seedAcmBatch.ts new file mode 100644 index 000000000..976eb7742 --- /dev/null +++ b/vips/vip-680/utils/seedAcmBatch.ts @@ -0,0 +1,129 @@ +import hre, { ethers } from "hardhat"; + +import { ACM_BATCH_INDEX, AUX_COMMANDS_AGGREGATOR, OPERATOR } from "../addresses/bscmainnet"; +import { buildAcmBatch } from "../bscmainnet"; + +// --------------------------------------------------------------------------------------------------- +// Seeds VIP-680's 96 ACM grants as one batch on the bscmainnet AuxiliaryCommandsAggregator. +// +// npx hardhat run vips/vip-680/utils/seedAcmBatch.ts --network bscmainnet +// +// Run this ONCE, by a human, from a wallet in the aggregator's `authorizedBatchers` allowlist, and +// only AFTER the VIP is final. Batches are append-only: seeding early means the batch becomes dead +// weight and every later batch shifts the index the proposal encodes. +// +// The batch contents come from buildAcmBatch() in ../bscmainnet.ts — the single source of truth the +// proposal, this script and the simulation all read, so they cannot drift apart. +// --------------------------------------------------------------------------------------------------- + +const AGGREGATOR_ABI = [ + "function authorizedBatchers(address) view returns (bool)", + "function batchCount() view returns (uint256)", + "function addBatch((address target, bytes data)[] calls, uint256 expectedIndex) returns (uint256)", + "function getBatch(uint256 index) view returns ((address target, bytes data)[])", + "event BatchAdded(uint256 index)", +]; + +const ZERO = ethers.constants.AddressZero; + +async function main() { + if (hre.network.name !== "bscmainnet") { + throw new Error(`seedAcmBatch: expected --network bscmainnet, got "${hre.network.name}"`); + } + + const commands = buildAcmBatch(); + + // Refuse to burn a batch slot on placeholders. Every Hub-stack address and the Operator account + // must be filled in addresses/bscmainnet.ts first. + if (OPERATOR === ZERO) { + throw new Error("seedAcmBatch: OPERATOR is still the zero address — decide it and fill it in"); + } + const unresolved = commands.filter(c => c.params[0] === ZERO || c.params[2] === ZERO); + if (unresolved.length > 0) { + throw new Error( + `seedAcmBatch: ${unresolved.length} of ${commands.length} entries still point at the zero ` + + "address. Fill the TODO(deploy) placeholders in addresses/bscmainnet.ts from the " + + "deployments registry first.", + ); + } + + // Encode {target, signature, params} into the aggregator's {target, data} Call struct. + const calls = commands.map(c => ({ + target: c.target, + data: new ethers.utils.Interface([`function ${c.signature}`]).encodeFunctionData( + c.signature.slice(0, c.signature.indexOf("(")), + c.params, + ), + })); + + const [signer] = await ethers.getSigners(); + const aggregator = new ethers.Contract(AUX_COMMANDS_AGGREGATOR, AGGREGATOR_ABI, signer); + + if (!(await aggregator.authorizedBatchers(signer.address))) { + throw new Error( + `seedAcmBatch: ${signer.address} is not in the aggregator's authorizedBatchers allowlist. ` + + "Seed from a whitelisted batcher wallet.", + ); + } + + const batchCount: number = (await aggregator.batchCount()).toNumber(); + if (batchCount !== ACM_BATCH_INDEX) { + throw new Error( + `seedAcmBatch: index drift — batchCount() is ${batchCount} but ACM_BATCH_INDEX is ` + + `${ACM_BATCH_INDEX}. Set ACM_BATCH_INDEX = ${batchCount} in addresses/bscmainnet.ts, make ` + + "sure the VIP re-encodes executeBatch with it, then re-run.", + ); + } + + const byAccount = commands.reduce>((acc, c) => { + acc[c.params[2]] = (acc[c.params[2]] ?? 0) + 1; + return acc; + }, {}); + + console.log(`Aggregator: ${AUX_COMMANDS_AGGREGATOR}`); + console.log(`Batcher: ${signer.address}`); + console.log(`Target index: ${ACM_BATCH_INDEX}`); + console.log(`Calls: ${calls.length}`); + console.table(byAccount); + + // Indexed overload — reverts InvalidBatchIndex if another batch landed between the check above and + // this transaction, instead of silently occupying a slot the proposal does not reference. + const tx = await aggregator.addBatch(calls, ACM_BATCH_INDEX); + console.log(`\nTransaction hash: ${tx.hash}`); + const receipt = await tx.wait(); + + const event = receipt.events?.find((e: { event: string }) => e.event === "BatchAdded"); + if (!event) { + throw new Error("seedAcmBatch: BatchAdded event not found in receipt"); + } + const index: number = event.args?.index.toNumber(); + if (index !== ACM_BATCH_INDEX) { + throw new Error(`seedAcmBatch: batch landed at ${index}, expected ${ACM_BATCH_INDEX}`); + } + + // Read the stored batch back and deep-compare, so the index the VIP encodes is proven to hold + // exactly these calls. + console.log(`Verifying stored batch at index ${index}...`); + const stored: { target: string; data: string }[] = await aggregator.getBatch(index); + if (stored.length !== calls.length) { + throw new Error(`seedAcmBatch: stored ${stored.length} calls, expected ${calls.length}`); + } + for (let i = 0; i < calls.length; i++) { + const sameTarget = ethers.utils.getAddress(stored[i].target) === ethers.utils.getAddress(calls[i].target); + if (!sameTarget || stored[i].data !== calls[i].data) { + throw new Error( + `seedAcmBatch: mismatch at call ${i}\n stored: ${stored[i].target} ${stored[i].data}\n expected: ${calls[i].target} ${calls[i].data}`, + ); + } + } + + console.log(`All ${calls.length} calls match.`); + console.log(`\nACM_BATCH_INDEX = ${index} is confirmed. The VIP can be proposed.`); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + });