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); + });