diff --git a/simulations/vip-620/abi/TokenBuybackMigrationHelper.json b/simulations/vip-620/abi/TokenBuybackMigrationHelper.json new file mode 100644 index 000000000..c21ddaba3 --- /dev/null +++ b/simulations/vip-620/abi/TokenBuybackMigrationHelper.json @@ -0,0 +1,148 @@ +[ + { + "inputs": [], + "name": "AlreadyExecuted", + "type": "error" + }, + { + "inputs": [], + "name": "Execute1NotRun", + "type": "error" + }, + { + "inputs": [], + "name": "NotTimelock", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "contractAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "expected", + "type": "address" + }, + { + "internalType": "address", + "name": "actual", + "type": "address" + } + ], + "name": "PendingOwnerMismatch", + "type": "error" + }, + { + "anonymous": false, + "inputs": [], + "name": "Executed1", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "Executed2", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "ExecutedSwap", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "step", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "reason", + "type": "bytes" + } + ], + "name": "StepFailed", + "type": "event" + }, + { + "inputs": [], + "name": "DEFAULT_ADMIN_ROLE", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "execute1", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "execute2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "executeSwap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "executed1", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "executed2", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "executedSwap", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/simulations/vip-620/bscmainnet-part-1.ts b/simulations/vip-620/bscmainnet-part-1.ts new file mode 100644 index 000000000..f81b7bf0c --- /dev/null +++ b/simulations/vip-620/bscmainnet-part-1.ts @@ -0,0 +1,400 @@ +import { expect } from "chai"; +import { BigNumber } from "ethers"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { initMainnetUser } from "src/utils"; +import { forking, testVip } from "src/vip-framework"; + +import { + BORROW_MULTIPLIER, + BTCB_PRIME_CONVERTER, + ETH_PRIME_CONVERTER, + NEW_PRIME_SPEED_FOR_U, + NEW_PRIME_SPEED_FOR_USDT, + PRIME, + RISK_FUND_CONVERTER, + SUPPLY_MULTIPLIER, + USDC_PRIME_CONVERTER, + USDT_PRIME_CONVERTER, + U_MAX_DISTRIBUTION_SPEED, + VTREASURY, + VU, + WBNB_BURN_CONVERTER, + XVS_VAULT_CONVERTER, +} from "../../vips/vip-618/bscmainnet"; +import vip620, { + BUYBACKS, + CORE_TOKENS, + DEFAULT_PROXY_ADMIN, + MIGRATION_HELPER_V2, + NEW_RISK_FUND_V2_IMPL, + PRIME_LIQUIDITY_PROVIDER, + PROTOCOL_SHARE_RESERVE, + RISK_FUND_BUYBACK, + RISK_FUND_V2, + SHORTFALL, + TIMELOCK_OWNED_CONVERTERS, + U, + USDC, + USDC_TO_SWEEP, + USDT, + U_MIN_OUT, + U_PRIME_BUYBACK, + XVS_BUYBACK, +} from "../../vips/vip-620/bscmainnet-part-1"; +import ACM_ABI from "../vip-618/abi/AccessControlManager.json"; +import DEFAULT_PROXY_ADMIN_ABI from "../vip-618/abi/DefaultProxyAdmin.json"; +import ERC20_ABI from "../vip-618/abi/ERC20.json"; +import PSR_ABI from "../vip-618/abi/ProtocolShareReserve.json"; +import BUYBACK_ABI from "../vip-618/abi/TokenBuyback.json"; +import TOKEN_BUYBACK_MIGRATION_HELPER_ABI from "./abi/TokenBuybackMigrationHelper.json"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Fork block must be past the latest TokenBuybackMigrationHelper redeploy +// (protocol-reserve PR #164, commit 746fe99 — rebuilt after the USDT-leg +// drop) at BSC block 98038965, and past every PR #162 buyback redeploy +// (97999686 – 98000650). +const FORK_BLOCK = 98045598; + +const SHORTFALL_MIN_ABI = ["function auctionsPaused() view returns (bool)"]; +const CONVERTER_MIN_ABI = ["function conversionPaused() view returns (bool)"]; +const PRIME_MIN_ABI = [ + "function markets(address) view returns (uint256 supplyMultiplier, uint256 borrowMultiplier, uint256 rewardIndex, uint256 sumOfMembersScore, bool exists)", +]; +const PLP_MIN_ABI = [ + "function tokenDistributionSpeeds(address) view returns (uint256)", + "function maxTokenDistributionSpeeds(address) view returns (uint256)", + "function lastAccruedBlockOrSecond(address) view returns (uint256)", +]; +const OWNABLE_MIN_ABI = ["function owner() view returns (address)", "function pendingOwner() view returns (address)"]; + +const EXECUTE_BUYBACK_SIG = "executeBuyback(address,uint256,uint256,uint256,address,bytes,address)"; +const FORWARD_BASE_ASSET_SIG = "forwardBaseAsset(address,uint256)"; +const OPERATOR = "0x88ac9ca69A371f47798Df18e5C36449af44526a4"; +const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; + +// Per-converter recipient mapping for execute2 drain (asserted in part-2 sim). +// Snapshotted here too so part-1 can verify converter balances are untouched. +const DRAIN_BY_CONVERTER: { converter: string; recipient: string }[] = [ + { converter: RISK_FUND_CONVERTER, recipient: RISK_FUND_BUYBACK }, + { converter: USDT_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: USDC_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: BTCB_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: ETH_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: XVS_VAULT_CONVERTER, recipient: XVS_BUYBACK }, +]; + +const STALE_DESTINATIONS = new Set( + [VTREASURY, ...TIMELOCK_OWNED_CONVERTERS, WBNB_BURN_CONVERTER].map(a => a.toLowerCase()), +); + +const NEW_PSR_EXPECTED_ROWS: { schema: number; percentage: number; destination: string }[] = [ + { schema: 0, percentage: 1200, destination: BUYBACKS[4] }, + { schema: 0, percentage: 600, destination: BUYBACKS[5] }, + { schema: 0, percentage: 600, destination: BUYBACKS[6] }, + { schema: 0, percentage: 600, destination: BUYBACKS[7] }, + { schema: 0, percentage: 600, destination: BUYBACKS[8] }, + { schema: 0, percentage: 400, destination: BUYBACKS[9] }, + { schema: 0, percentage: 1000, destination: BUYBACKS[1] }, + { schema: 0, percentage: 1000, destination: BUYBACKS[2] }, + { schema: 0, percentage: 2000, destination: BUYBACKS[0] }, + { schema: 0, percentage: 2000, destination: BUYBACKS[3] }, + { schema: 1, percentage: 1800, destination: BUYBACKS[4] }, + { schema: 1, percentage: 900, destination: BUYBACKS[5] }, + { schema: 1, percentage: 900, destination: BUYBACKS[6] }, + { schema: 1, percentage: 900, destination: BUYBACKS[7] }, + { schema: 1, percentage: 900, destination: BUYBACKS[8] }, + { schema: 1, percentage: 600, destination: BUYBACKS[9] }, + { schema: 1, percentage: 2000, destination: BUYBACKS[0] }, + { schema: 1, percentage: 2000, destination: BUYBACKS[3] }, +]; + +forking(FORK_BLOCK, async () => { + const acm = new ethers.Contract(bscmainnet.ACCESS_CONTROL_MANAGER, ACM_ABI, ethers.provider); + const proxyAdmin = new ethers.Contract(DEFAULT_PROXY_ADMIN, DEFAULT_PROXY_ADMIN_ABI, ethers.provider); + const psr = new ethers.Contract(PROTOCOL_SHARE_RESERVE, PSR_ABI, ethers.provider); + const usdt = new ethers.Contract(USDT, ERC20_ABI, ethers.provider); + const usdc = new ethers.Contract(USDC, ERC20_ABI, ethers.provider); + const shortfall = new ethers.Contract(SHORTFALL, SHORTFALL_MIN_ABI, ethers.provider); + const helper = new ethers.Contract(MIGRATION_HELPER_V2, TOKEN_BUYBACK_MIGRATION_HELPER_ABI, ethers.provider); + const prime = new ethers.Contract(PRIME, PRIME_MIN_ABI, ethers.provider); + const plp = new ethers.Contract(PRIME_LIQUIDITY_PROVIDER, PLP_MIN_ABI, ethers.provider); + + const erc20 = (token: string) => new ethers.Contract(token, ERC20_ABI, ethers.provider); + const ownable = (a: string) => new ethers.Contract(a, OWNABLE_MIN_ABI, ethers.provider); + const converter = (addr: string) => new ethers.Contract(addr, CONVERTER_MIN_ABI, ethers.provider); + + let riskFundV2UsdtBalanceBefore: BigNumber; + let plpUsdcBalanceBefore: BigNumber; + let plpUsdtBalanceBefore: BigNumber; + let plpUBalanceBefore: BigNumber; + let timelockUsdcBalanceBefore: BigNumber; + const converterBalanceBefore = new Map(); + + before(async () => { + // Production pre-condition (enforced — no fork-only impersonation patch): + // every buyback proxy's pendingOwner must already point at + // MIGRATION_HELPER_V2 so helper.execute1() can accept ownership. The + // protocol-reserve PR #162 deploy script is responsible for calling + // transferOwnership(MIGRATION_HELPER_V2) on each proxy before this VIP is + // queued. The buybacks' current owner is the deployer EOA — NormalTimelock + // cannot transfer it from within the VIP — so this is an off-chain step. + // + // If this fails, fix the deploy script (preferred) or run an EOA tx that + // sets pendingOwner on each proxy. The "Pre-VIP state > pendingOwner" test + // below also asserts the same invariant; both surfaces exist on purpose. + for (const b of BUYBACKS) { + const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); + const pending: string = await buybackOwnable.pendingOwner(); + if (pending.toLowerCase() !== MIGRATION_HELPER_V2.toLowerCase()) { + throw new Error( + `pre-condition unmet: buyback ${b} pendingOwner=${pending}, expected ${MIGRATION_HELPER_V2}. ` + + `The buyback deploy script (protocol-reserve PR #162) must call ` + + `transferOwnership(${MIGRATION_HELPER_V2}) on every proxy before VIP-620 is queued.`, + ); + } + } + + riskFundV2UsdtBalanceBefore = await usdt.balanceOf(RISK_FUND_V2); + plpUsdcBalanceBefore = await usdc.balanceOf(PRIME_LIQUIDITY_PROVIDER); + plpUsdtBalanceBefore = await usdt.balanceOf(PRIME_LIQUIDITY_PROVIDER); + plpUBalanceBefore = await erc20(U).balanceOf(PRIME_LIQUIDITY_PROVIDER); + timelockUsdcBalanceBefore = await usdc.balanceOf(bscmainnet.NORMAL_TIMELOCK); + + for (const d of DRAIN_BY_CONVERTER) { + for (const t of CORE_TOKENS) { + const k = `${d.converter.toLowerCase()}:${t.toLowerCase()}`; + converterBalanceBefore.set(k, await erc20(t).balanceOf(d.converter)); + } + } + }); + + describe("Pre-VIP state (part 1)", () => { + it("each buyback proxy's pendingOwner is MIGRATION_HELPER_V2", async () => { + for (const b of BUYBACKS) { + const buyback = new ethers.Contract(b, BUYBACK_ABI, ethers.provider); + expect((await buyback.pendingOwner()).toLowerCase(), b).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("each timelock-owned converter is owned by NormalTimelock (pre-transfer)", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect((await ownable(c).owner()).toLowerCase(), c).to.equal(bscmainnet.NORMAL_TIMELOCK.toLowerCase()); + } + }); + + it("each timelock-owned legacy converter is not yet paused", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect(await converter(c).conversionPaused(), c).to.be.false; + } + }); + + it("helper has not yet executed phase 1, swap, or phase 2", async () => { + expect(await helper.executed1()).to.be.false; + expect(await helper.executedSwap()).to.be.false; + expect(await helper.executed2()).to.be.false; + }); + + it("helper does not yet hold DEFAULT_ADMIN_ROLE on ACM", async () => { + expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, MIGRATION_HELPER_V2)).to.be.false; + }); + + it("helper holds zero USDC and zero native BNB pre-VIP", async () => { + expect(await usdc.balanceOf(MIGRATION_HELPER_V2)).to.equal(0); + expect(await ethers.provider.getBalance(MIGRATION_HELPER_V2)).to.equal(0); + }); + + it("vU is not yet a Prime market", async () => { + const m = await prime.markets(VU); + expect(m.exists).to.be.false; + }); + + it("U is not yet initialized in PrimeLiquidityProvider", async () => { + expect(await plp.lastAccruedBlockOrSecond(U)).to.equal(0); + }); + + it("Shortfall auctions are not yet paused (set inside execute1)", async () => { + expect(await shortfall.auctionsPaused()).to.be.false; + }); + }); + + testVip("VIP-620 — non-drain migration & May Prime allocation", await vip620()); + + describe("Post-VIP state (part 1)", () => { + it("helper.executed1 and helper.executedSwap are true; executed2 still false", async () => { + expect(await helper.executed1()).to.be.true; + expect(await helper.executedSwap()).to.be.true; + expect(await helper.executed2()).to.be.false; + }); + + it("helper renounced DEFAULT_ADMIN_ROLE on ACM at end of execute1", async () => { + expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, MIGRATION_HELPER_V2)).to.be.false; + }); + + it("helper still owns each buyback (handback deferred to part 2)", async () => { + for (const b of BUYBACKS) { + expect((await ownable(b).owner()).toLowerCase(), b).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("helper still owns each timelock-owned legacy converter (handback deferred to part 2)", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect((await ownable(c).owner()).toLowerCase(), c).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("operator granted executeBuyback + forwardBaseAsset on each buyback", async () => { + for (const b of BUYBACKS) { + const buybackSigner = await initMainnetUser(b, ethers.utils.parseEther("1")); + expect(await acm.connect(buybackSigner).isAllowedToCall(OPERATOR, EXECUTE_BUYBACK_SIG)).to.be.true; + expect(await acm.connect(buybackSigner).isAllowedToCall(OPERATOR, FORWARD_BASE_ASSET_SIG)).to.be.true; + } + }); + + it("RiskFundV2 proxy upgraded to new implementation", async () => { + expect((await proxyAdmin.getProxyImplementation(RISK_FUND_V2)).toLowerCase()).to.equal( + NEW_RISK_FUND_V2_IMPL.toLowerCase(), + ); + }); + + it("RiskFundV2 USDT balance non-decreasing across upgrade", async () => { + const after = await usdt.balanceOf(RISK_FUND_V2); + expect(after).to.be.gte(riskFundV2UsdtBalanceBefore); + }); + + it("every timelock-owned legacy converter is paused", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect(await converter(c).conversionPaused(), c).to.be.true; + } + }); + + it("each timelock-owned converter still holds its pre-VIP balance for every core-pool token (drain deferred to part 2)", async () => { + for (const d of DRAIN_BY_CONVERTER) { + for (const t of CORE_TOKENS) { + const k = `${d.converter.toLowerCase()}:${t.toLowerCase()}`; + const before = converterBalanceBefore.get(k) ?? BigNumber.from(0); + const after = await erc20(t).balanceOf(d.converter); + expect(after, k).to.equal(before); + } + } + }); + + it("PSR has every new buyback row at the expected (schema, percentage)", async () => { + const rows: { schema: number; percentage: number; destination: string }[] = []; + for (let i = 0; ; i++) { + try { + const r = await psr.distributionTargets(i); + rows.push({ + schema: Number(r[0]), + percentage: Number(r[1]), + destination: String(r[2]).toLowerCase(), + }); + } catch { + break; + } + } + for (const expected of NEW_PSR_EXPECTED_ROWS) { + const found = rows.find( + r => r.schema === expected.schema && r.destination === expected.destination.toLowerCase(), + ); + expect(found, `missing PSR row schema=${expected.schema} dest=${expected.destination}`).to.not.be.undefined; + expect(found!.percentage, `PSR percentage mismatch schema=${expected.schema}`).to.equal(expected.percentage); + } + }); + + it("PSR no longer references any legacy converter or the VTreasury direct destination", async () => { + for (let i = 0; ; i++) { + try { + const r = await psr.distributionTargets(i); + expect(STALE_DESTINATIONS.has(String(r[2]).toLowerCase()), `stale PSR row: ${r[2]}`).to.be.false; + } catch { + break; + } + } + }); + + it("Shortfall auctions are paused (set inside execute1)", async () => { + expect(await shortfall.auctionsPaused()).to.be.true; + }); + }); + + describe("Post-VIP Prime allocation", () => { + it("vU is registered as a Prime market with the expected multipliers", async () => { + const m = await prime.markets(VU); + expect(m.exists, "exists").to.be.true; + expect(m.supplyMultiplier, "supplyMultiplier").to.equal(SUPPLY_MULTIPLIER); + expect(m.borrowMultiplier, "borrowMultiplier").to.equal(BORROW_MULTIPLIER); + }); + + it("U is initialized in PrimeLiquidityProvider", async () => { + expect(await plp.lastAccruedBlockOrSecond(U)).to.be.gt(0); + }); + + it("PLP max distribution speed for U is set", async () => { + expect(await plp.maxTokenDistributionSpeeds(U)).to.equal(U_MAX_DISTRIBUTION_SPEED); + }); + + it("PLP distribution speeds set for USDT and U", async () => { + expect(await plp.tokenDistributionSpeeds(USDT), "USDT speed").to.equal(NEW_PRIME_SPEED_FOR_USDT); + expect(await plp.tokenDistributionSpeeds(U), "U speed").to.equal(NEW_PRIME_SPEED_FOR_U); + }); + + it("PLP USDC balance decreased by exactly USDC_TO_SWEEP", async () => { + const after = await usdc.balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(plpUsdcBalanceBefore.sub(after)).to.equal(USDC_TO_SWEEP); + }); + + it("PLP USDT balance is unchanged (V3 multihop routes USDT through, doesn't deposit it)", async () => { + const after = await usdt.balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(after).to.equal(plpUsdtBalanceBefore); + }); + + it("PLP U balance increased by at least U_MIN_OUT (strict — soft-fail of executeSwap would surface here)", async () => { + const after = await erc20(U).balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(after.sub(plpUBalanceBefore)).to.be.gte(U_MIN_OUT); + }); + + it("Helper holds zero USDC after executeSwap", async () => { + expect(await usdc.balanceOf(MIGRATION_HELPER_V2)).to.equal(0); + }); + + it("NormalTimelock USDC balance is unchanged (swap succeeded — no leftover forwarded)", async () => { + // On soft-fail of executeSwap, helper forwards USDC_TO_SWEEP back to + // NormalTimelock. The strict U-min-out assertion above rules that out, + // so Timelock USDC delta should be exactly zero. + const after = await usdc.balanceOf(bscmainnet.NORMAL_TIMELOCK); + expect(after).to.equal(timelockUsdcBalanceBefore); + }); + }); + + describe("Post-VIP helper invariants (part 1)", () => { + it("helper IS owner of every buyback (handback deferred to part 2)", async () => { + for (const b of BUYBACKS) { + expect((await ownable(b).owner()).toLowerCase(), `${b} owner`).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("helper IS owner of every timelock-owned converter (drain happens in part 2)", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect((await ownable(c).owner()).toLowerCase(), `${c} owner`).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("calling helper.execute1() a second time reverts", async () => { + const helperWithExecute1 = new ethers.Contract(MIGRATION_HELPER_V2, ["function execute1()"], ethers.provider); + const timelockSigner = await initMainnetUser(bscmainnet.NORMAL_TIMELOCK, ethers.utils.parseEther("1")); + await expect(helperWithExecute1.connect(timelockSigner).execute1()).to.be.reverted; + }); + + it("calling helper.executeSwap() a second time reverts", async () => { + const helperWithExecuteSwap = new ethers.Contract( + MIGRATION_HELPER_V2, + ["function executeSwap()"], + ethers.provider, + ); + const timelockSigner = await initMainnetUser(bscmainnet.NORMAL_TIMELOCK, ethers.utils.parseEther("1")); + await expect(helperWithExecuteSwap.connect(timelockSigner).executeSwap()).to.be.reverted; + }); + }); +}); diff --git a/simulations/vip-620/bscmainnet-part-2.ts b/simulations/vip-620/bscmainnet-part-2.ts new file mode 100644 index 000000000..911579034 --- /dev/null +++ b/simulations/vip-620/bscmainnet-part-2.ts @@ -0,0 +1,246 @@ +import { expect } from "chai"; +import { BigNumber } from "ethers"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { initMainnetUser } from "src/utils"; +import { forking, testVip } from "src/vip-framework"; + +// Prefer vip-620/bscmainnet-part-1 for anything it exports; fall back to the +// frozen vip-618 only for what vip-620 does not re-export (legacy converters +// and the 9 swap routers). +import { + BTCB_PRIME_CONVERTER, + ETH_PRIME_CONVERTER, + ONEINCH_ROUTER, + PANCAKE_ROUTER, + PANCAKE_SMART_ROUTER, + PANCAKE_UNIVERSAL_ROUTER, + PANCAKE_V3_ROUTER, + RISK_FUND_CONVERTER, + UNIV2_SWAP_ROUTER_02, + UNIV3_SWAP_ROUTER_02, + UNIV4_SWAP_ROUTER, + UNI_UNIVERSAL_ROUTER, + USDC_PRIME_CONVERTER, + USDT_PRIME_CONVERTER, + XVS_VAULT_CONVERTER, +} from "../../vips/vip-618/bscmainnet"; +import vip620, { + BUYBACKS, + CORE_TOKENS, + MIGRATION_HELPER_V2, + RISK_FUND_BUYBACK, + TIMELOCK_OWNED_CONVERTERS, + U_PRIME_BUYBACK, + XVS_BUYBACK, +} from "../../vips/vip-620/bscmainnet-part-1"; +import vip621 from "../../vips/vip-620/bscmainnet-part-2"; +import ACM_ABI from "../vip-618/abi/AccessControlManager.json"; +import ERC20_ABI from "../vip-618/abi/ERC20.json"; +import TOKEN_BUYBACK_MIGRATION_HELPER_ABI from "./abi/TokenBuybackMigrationHelper.json"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Match part-1 sim's FORK_BLOCK. Must be past the latest helper redeploy +// (block 98038965, commit 746fe99) and the PR #162 buyback redeploys +// (97999686 – 98000650). +const FORK_BLOCK = 98045598; + +const BUYBACK_ROUTERS_ABI = ["function allowedRouters(address) view returns (bool)"]; +const OWNABLE_MIN_ABI = ["function owner() view returns (address)", "function pendingOwner() view returns (address)"]; +const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; + +const ALL_ROUTERS = [ + PANCAKE_ROUTER, + PANCAKE_V3_ROUTER, + PANCAKE_SMART_ROUTER, + PANCAKE_UNIVERSAL_ROUTER, + ONEINCH_ROUTER, + UNIV2_SWAP_ROUTER_02, + UNIV3_SWAP_ROUTER_02, + UNIV4_SWAP_ROUTER, + UNI_UNIVERSAL_ROUTER, +]; + +const DRAIN_BY_CONVERTER: { converter: string; recipient: string }[] = [ + { converter: RISK_FUND_CONVERTER, recipient: RISK_FUND_BUYBACK }, + { converter: USDT_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: USDC_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: BTCB_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: ETH_PRIME_CONVERTER, recipient: U_PRIME_BUYBACK }, + { converter: XVS_VAULT_CONVERTER, recipient: XVS_BUYBACK }, +]; + +forking(FORK_BLOCK, async () => { + const acm = new ethers.Contract(bscmainnet.ACCESS_CONTROL_MANAGER, ACM_ABI, ethers.provider); + const helper = new ethers.Contract(MIGRATION_HELPER_V2, TOKEN_BUYBACK_MIGRATION_HELPER_ABI, ethers.provider); + + const erc20 = (token: string) => new ethers.Contract(token, ERC20_ABI, ethers.provider); + const ownable = (a: string) => new ethers.Contract(a, OWNABLE_MIN_ABI, ethers.provider); + const buybackRouters = (b: string) => new ethers.Contract(b, BUYBACK_ROUTERS_ABI, ethers.provider); + + // Per-(token, recipient) snapshot taken AFTER part-1 executes (which leaves + // converter balances untouched) so that the post-part-2 delta isolates the + // drain. + const recipientBalanceAfterPart1 = new Map(); + const converterBalanceAfterPart1 = new Map(); + + before(async () => { + // Production pre-condition (enforced — no fork-only impersonation patch): + // every buyback proxy's pendingOwner must already point at + // MIGRATION_HELPER_V2. Mirrored here because part-1 (run below via testVip) + // re-enters helper.execute1() which calls acceptOwnership() on every buyback. + for (const b of BUYBACKS) { + const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); + const pending: string = await buybackOwnable.pendingOwner(); + if (pending.toLowerCase() !== MIGRATION_HELPER_V2.toLowerCase()) { + throw new Error( + `pre-condition unmet: buyback ${b} pendingOwner=${pending}, expected ${MIGRATION_HELPER_V2}. ` + + `The buyback deploy script (protocol-reserve PR #162) must call ` + + `transferOwnership(${MIGRATION_HELPER_V2}) on every proxy before VIP-620 is queued.`, + ); + } + } + }); + + // Apply part-1 via the full governance flow so part-2 runs against the same + // post-part-1 state that mainnet will see. testVip(part1) state persists into + // testVip(part2) because each testVip uses its own loadFixture function — the + // part-2 fixture's first snapshot is taken AFTER part-1 has executed. + // Capture post-part-1 balances in callbackAfterExecution so the post-part-2 + // delta isolates the drain. + testVip("VIP-620 (setup for part-2 sim)", await vip620(), { + callbackAfterExecution: async () => { + for (const d of DRAIN_BY_CONVERTER) { + for (const t of CORE_TOKENS) { + const recipientKey = `${t.toLowerCase()}:${d.recipient.toLowerCase()}`; + if (!recipientBalanceAfterPart1.has(recipientKey)) { + recipientBalanceAfterPart1.set(recipientKey, await erc20(t).balanceOf(d.recipient)); + } + const converterKey = `${d.converter.toLowerCase()}:${t.toLowerCase()}`; + converterBalanceAfterPart1.set(converterKey, await erc20(t).balanceOf(d.converter)); + } + } + }, + }); + + describe("Pre-VIP state (part 2, after part-1 has executed)", () => { + it("helper.executed1 and helper.executedSwap are true; executed2 is false", async () => { + expect(await helper.executed1()).to.be.true; + expect(await helper.executedSwap()).to.be.true; + expect(await helper.executed2()).to.be.false; + }); + + it("helper still owns every buyback and every timelock-owned converter", async () => { + for (const a of [...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS]) { + expect((await ownable(a).owner()).toLowerCase(), a).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + it("no router is allowlisted on any buyback yet (allowlist deferred to execute2)", async () => { + for (const b of BUYBACKS) { + for (const r of ALL_ROUTERS) { + expect(await buybackRouters(b).allowedRouters(r), `${b}/${r}`).to.be.false; + } + } + }); + + it("helper does NOT hold DEFAULT_ADMIN_ROLE on the ACM", async () => { + expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, MIGRATION_HELPER_V2)).to.be.false; + }); + + it("helper holds zero balance of every core-pool token and zero native BNB", async () => { + // After part-1's executeSwap, any leftover USDC has been forwarded back + // to NormalTimelock — helper should be empty heading into part-2's drain. + for (const t of CORE_TOKENS) { + expect(await erc20(t).balanceOf(MIGRATION_HELPER_V2), t).to.equal(0); + } + expect(await ethers.provider.getBalance(MIGRATION_HELPER_V2), "native BNB").to.equal(0); + }); + }); + + testVip("VIP-621 — router allowlist, drain, and hand back ownership", await vip621()); + + describe("Post-VIP state (part 2)", () => { + it("helper.executed2 is true; second execute2() reverts", async () => { + expect(await helper.executed2()).to.be.true; + const helperWithExecute2 = new ethers.Contract(MIGRATION_HELPER_V2, ["function execute2()"], ethers.provider); + const timelockSigner = await initMainnetUser(bscmainnet.NORMAL_TIMELOCK, ethers.utils.parseEther("1")); + await expect(helperWithExecute2.connect(timelockSigner).execute2()).to.be.reverted; + }); + + it("all three helper entrypoints revert on re-entry (AlreadyExecuted)", async () => { + const helperAllEntrypoints = new ethers.Contract( + MIGRATION_HELPER_V2, + ["function execute1()", "function executeSwap()"], + ethers.provider, + ); + const timelockSigner = await initMainnetUser(bscmainnet.NORMAL_TIMELOCK, ethers.utils.parseEther("1")); + await expect(helperAllEntrypoints.connect(timelockSigner).execute1(), "execute1").to.be.reverted; + await expect(helperAllEntrypoints.connect(timelockSigner).executeSwap(), "executeSwap").to.be.reverted; + }); + + it("every router is allowlisted on every buyback", async () => { + for (const b of BUYBACKS) { + for (const r of ALL_ROUTERS) { + expect(await buybackRouters(b).allowedRouters(r), `${b}/${r}`).to.be.true; + } + } + }); + + it("each timelock-owned converter has zero residual balance for every core-pool token", async () => { + for (const d of DRAIN_BY_CONVERTER) { + for (const t of CORE_TOKENS) { + expect(await erc20(t).balanceOf(d.converter), `${d.converter}/${t}`).to.equal(0); + } + } + }); + + it("recipient buybacks received the drained balances (delta >= pre-part-2 converter balance)", async () => { + // Aggregate expected inflow per (token, recipient) by summing every + // converter that maps to the same recipient (four PrimeConverters all + // flow to U_PRIME_BUYBACK). + const expectedInflow = new Map(); + for (const d of DRAIN_BY_CONVERTER) { + for (const t of CORE_TOKENS) { + const k = `${t.toLowerCase()}:${d.recipient.toLowerCase()}`; + const converterKey = `${d.converter.toLowerCase()}:${t.toLowerCase()}`; + const fromConverter = converterBalanceAfterPart1.get(converterKey) ?? BigNumber.from(0); + expectedInflow.set(k, (expectedInflow.get(k) ?? BigNumber.from(0)).add(fromConverter)); + } + } + + for (const [k, expected] of expectedInflow.entries()) { + const [token, recipient] = k.split(":"); + const before = recipientBalanceAfterPart1.get(k) ?? BigNumber.from(0); + const after = await erc20(token).balanceOf(recipient); + expect(after.sub(before), k).to.be.gte(expected); + } + }); + + it("NormalTimelock owns each buyback and each timelock-owned converter", async () => { + for (const a of [...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS]) { + expect((await ownable(a).owner()).toLowerCase()).to.equal(bscmainnet.NORMAL_TIMELOCK.toLowerCase()); + } + }); + + it("helper is neither owner nor pendingOwner of any of the 16 migrated contracts", async () => { + for (const t of [...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS]) { + expect((await ownable(t).owner()).toLowerCase(), `${t} owner`).to.not.equal(MIGRATION_HELPER_V2.toLowerCase()); + expect((await ownable(t).pendingOwner()).toLowerCase(), `${t} pendingOwner`).to.not.equal( + MIGRATION_HELPER_V2.toLowerCase(), + ); + } + }); + + it("helper holds zero balance of every core-pool token", async () => { + for (const t of CORE_TOKENS) { + expect(await erc20(t).balanceOf(MIGRATION_HELPER_V2), t).to.equal(0); + } + }); + + it("helper holds zero native BNB", async () => { + expect(await ethers.provider.getBalance(MIGRATION_HELPER_V2)).to.equal(0); + }); + }); +}); diff --git a/src/networkConfig.ts b/src/networkConfig.ts index 9d8d2433b..8ec82905b 100644 --- a/src/networkConfig.ts +++ b/src/networkConfig.ts @@ -1,4 +1,30 @@ -import { ProposalType } from "./types"; +import { ProposalType, SUPPORTED_NETWORKS } from "./types"; + +// Per-network per-tx gas cap (single transaction limit enforced by the +// chain's protocol rules). `governorBravo.execute(proposalId)` runs every +// command in a single tx, so any proposal whose gasUsed exceeds the +// destination cap is unexecutable on chain. +// +// Sources: +// - bscmainnet / bsctestnet: BSC Maxwell + Osaka hardforks introduced a +// hard per-tx cap of 2^24 = 16,777,216. +// - ethereum / sepolia: Fusaka hardfork ships EIP-7825 with the same +// 2^24 = 16,777,216 per-tx cap. +// - L2s (arbitrumone, opmainnet, basemainnet, opbnbmainnet, zksyncmainnet, +// unichainmainnet) currently have effective per-tx limits well above the +// L1 cap (driven by the L2 block gas limit, not a protocol per-tx rule). +// Left unset (no enforcement) until a concrete per-tx rule lands. +// +// Used as the fallback table for `resolvePerTxGasCap` in `src/utils.ts`, +// which prefers the EIP-8123 `eth_txGasLimitCap` RPC method when supported +// and falls back to this map otherwise. +export const PER_TX_GAS_CAP_2_24 = 16_777_216; +export const PER_TX_GAS_CAP_BY_NETWORK: Partial> = { + bscmainnet: PER_TX_GAS_CAP_2_24, + bsctestnet: PER_TX_GAS_CAP_2_24, + ethereum: PER_TX_GAS_CAP_2_24, + sepolia: PER_TX_GAS_CAP_2_24, +}; export const NETWORK_CONFIG = { bscmainnet: { diff --git a/src/utils.ts b/src/utils.ts index beef7f09f..84da07f85 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,7 @@ import { FORKED_NETWORK, config, ethers, network } from "hardhat"; import { EthereumProvider } from "hardhat/types"; import { NETWORK_ADDRESSES, ORACLE_BNB } from "./networkAddresses"; +import { PER_TX_GAS_CAP_BY_NETWORK } from "./networkConfig"; import { Command, LzChainId, @@ -18,6 +19,7 @@ import { REMOTE_MAINNET_NETWORKS, REMOTE_NETWORKS, REMOTE_TESTNET_NETWORKS, + SUPPORTED_NETWORKS, TokenConfig, } from "./types"; import OmnichainProposalSender_ABI from "./vip-framework/abi/OmnichainProposalSender_ABI.json"; @@ -76,6 +78,57 @@ export async function setForkBlock(_blockNumber: number) { }); } +// Resolved per-tx gas cap, cached per FORKED_NETWORK for the lifetime of the +// hardhat process. The fork doesn't change within a sim, so a single resolve +// per network is enough. +const _perTxGasCapCache = new Map(); + +/// Resolve the per-tx gas cap that the simulation should mirror. +/// +/// Resolution order: +/// 1. EIP-7825 `eth_txGasLimitCap` RPC (auto picks up future hardforks the +/// day a client ships it). Errors and null/undefined responses fall +/// through to step 2. +/// 2. Static `PER_TX_GAS_CAP_BY_NETWORK` map in `src/networkConfig.ts` +/// (current authoritative source — no client implements EIP-7825 yet). +/// 3. `Number.POSITIVE_INFINITY` — no enforcement (L2s today). +/// +/// Logged once per network when first resolved, so CI output records which +/// branch fired. +export const resolvePerTxGasCap = async (forkedNetwork: string | undefined): Promise => { + const key = forkedNetwork ?? ""; + const cached = _perTxGasCapCache.get(key); + if (cached !== undefined) return cached; + + let cap: number = Number.POSITIVE_INFINITY; + let source = "no enforcement"; + + try { + const raw = await ethers.provider.send("eth_txGasLimitCap", []); + if (raw !== null && raw !== undefined) { + const parsed = BigNumber.from(raw).toNumber(); + if (parsed > 0) { + cap = parsed; + source = "eth_txGasLimitCap (EIP-7825)"; + } + } + } catch { + // RPC method not supported (today's reality on every client). Drop to map. + } + + if (!Number.isFinite(cap)) { + const fromMap = PER_TX_GAS_CAP_BY_NETWORK[forkedNetwork as SUPPORTED_NETWORKS]; + if (fromMap !== undefined) { + cap = fromMap; + source = "PER_TX_GAS_CAP_BY_NETWORK (static)"; + } + } + + console.log(`[gas] per-tx cap for ${key}: ${Number.isFinite(cap) ? cap : "unbounded"} (source: ${source})`); + _perTxGasCapCache.set(key, cap); + return cap; +}; + export const getSourceChainId = (network: REMOTE_NETWORKS) => { if (REMOTE_MAINNET_NETWORKS.includes(network as string)) { return LzChainId.bscmainnet; diff --git a/src/vip-framework/index.ts b/src/vip-framework/index.ts index bcaf583ac..82c65f57b 100644 --- a/src/vip-framework/index.ts +++ b/src/vip-framework/index.ts @@ -18,6 +18,7 @@ import { initMainnetUser, mineBlocks, mineOnZksync, + resolvePerTxGasCap, setForkBlock, validateTargetAddresses, } from "../utils"; @@ -125,13 +126,40 @@ export const pretendExecutingVip = async (proposal: Proposal, sender: string = G const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar.start(proposal.signatures.length, 0); + // Each command runs as its own tx here (not bundled like `governorBravo.execute`), + // so the per-tx cap applies per command, not to the sum. Track per-cmd max and + // flag any individual command that exceeds the cap. + const cap = await resolvePerTxGasCap(FORKED_NETWORK); + let totalGas = BigNumber.from(0); + let maxCmdGas = BigNumber.from(0); + let maxCmdIdx = -1; for (let i = 0; i < proposal.signatures.length; ++i) { const txResponse = await executeCommand(impersonatedTimelock, proposal, i); + const receipt = await txResponse.wait(); + totalGas = totalGas.add(receipt.gasUsed); + if (receipt.gasUsed.gt(maxCmdGas)) { + maxCmdGas = receipt.gasUsed; + maxCmdIdx = i; + } + if (Number.isFinite(cap) && receipt.gasUsed.gt(cap)) { + console.warn( + `[gas] WARNING cmd[${i}] (${proposal.signatures[i]}) gasUsed=${receipt.gasUsed.toString()} ` + + `exceeds ${FORKED_NETWORK} per-tx cap ${cap}`, + ); + } txResponses.push(txResponse); bar.update(i + 1); } bar.stop(); + const maxSuffix = Number.isFinite(cap) + ? ` (${maxCmdGas.mul(10000).div(cap).toNumber() / 100}% of ${FORKED_NETWORK} per-tx cap ${cap})` + : ` (${FORKED_NETWORK} has no enforced per-tx cap)`; + console.log( + `[gas] pretendExecutingVip ${proposal.signatures.length} commands, ` + + `maxCmdGasUsed=${maxCmdGas.toString()} (cmd[${maxCmdIdx}])${maxSuffix}, ` + + `totalGasUsed=${totalGas.toString()}`, + ); return txResponses; }; @@ -224,7 +252,28 @@ export const testVip = (description: string, proposal: Proposal, options: Testin await mineUpTo((await ethers.provider.getBlockNumber()) + DELAY_BLOCKS[proposal.type || 0]); const blockchainProposal = await governorProxy.proposals(proposalId); await time.increaseTo(blockchainProposal.eta.toNumber()); - const tx = await governorProxy.connect(proposer).execute(proposalId); + + // Mirror the chain's protocol per-tx cap by capping tx.gasLimit. Hardhat + // fork does not enforce protocol per-tx caps (those are consensus rules, + // not tx fields), so without this an over-cap proposal passes simulation + // but OOGs on chain. Setting gasLimit = cap forces hardhat to revert + // out-of-gas exactly as mainnet would. Build the tx via + // `populateTransaction` so the override is applied at the raw-tx layer + // (passing it as a second arg to the contract method gets misinterpreted + // as a positional fn arg by ethers v5). + const cap = await resolvePerTxGasCap(FORKED_NETWORK); + const populated = await governorProxy.connect(proposer).populateTransaction.execute(proposalId); + if (Number.isFinite(cap)) { + populated.gasLimit = BigNumber.from(cap); + } + const tx = await proposer.sendTransaction(populated); + const receipt = await tx.wait(); + + const gasUsed = receipt.gasUsed.toString(); + const capSuffix = Number.isFinite(cap) + ? ` (${receipt.gasUsed.mul(10000).div(cap).toNumber() / 100}% of ${FORKED_NETWORK} per-tx cap ${cap})` + : ` (${FORKED_NETWORK} has no enforced per-tx cap)`; + console.log(`[gas] ${description} execute(proposalId) gasUsed=${gasUsed}${capSuffix}`); if (options.callbackAfterExecution) { await options.callbackAfterExecution(tx); @@ -312,14 +361,29 @@ export const testForkedNetworkVipCommands = (description: string, proposal: Prop await mineBlocks(); const feeData = await ethers.provider.getFeeData(); - const txnParams: { maxFeePerGas?: BigNumber } = {}; + const txnParams: { maxFeePerGas?: BigNumber; gasLimit?: number } = {}; if (feeData.maxFeePerGas) { // Sometimes the gas estimation is wrong with some networks like zksync txnParams.maxFeePerGas = feeData.maxFeePerGas.mul(15).div(10); } + // Mirror the chain's protocol per-tx cap by capping tx.gasLimit. Hardhat + // fork does not enforce protocol per-tx caps, so without this an over-cap + // remote payload passes simulation but OOGs on chain. + const cap = await resolvePerTxGasCap(FORKED_NETWORK); + if (Number.isFinite(cap)) { + txnParams.gasLimit = cap; + } + const tx = await executor.execute(proposalId, txnParams); + const receipt = await tx.wait(); + + const gasUsed = receipt.gasUsed.toString(); + const capSuffix = Number.isFinite(cap) + ? ` (${receipt.gasUsed.mul(10000).div(cap).toNumber() / 100}% of ${FORKED_NETWORK} per-tx cap ${cap})` + : ` (${FORKED_NETWORK} has no enforced per-tx cap)`; + console.log(`[gas] ${description} executor.execute(proposalId) gasUsed=${gasUsed}${capSuffix}`); if (options.callbackAfterExecution) { await options.callbackAfterExecution(tx); diff --git a/vips/vip-620/bscmainnet-part-1.ts b/vips/vip-620/bscmainnet-part-1.ts new file mode 100644 index 000000000..a8ea48061 --- /dev/null +++ b/vips/vip-620/bscmainnet-part-1.ts @@ -0,0 +1,249 @@ +import { ethers } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +// vip-618 is on-chain (proposed, execution failed) and its constants are +// frozen. VIP-620 imports only the values that survived the redeploy unchanged +// and redefines the buyback addresses and swap-budget constants locally below. +import { + BORROW_MULTIPLIER, + CORE_COMPTROLLER, + CORE_TOKENS, + DEFAULT_PROXY_ADMIN, + NEW_PRIME_SPEED_FOR_U, + NEW_PRIME_SPEED_FOR_USDT, + NEW_RISK_FUND_V2_IMPL, + PRIME, + PRIME_LIQUIDITY_PROVIDER, + PROTOCOL_SHARE_RESERVE, + RISK_FUND_V2, + SHORTFALL, + SUPPLY_MULTIPLIER, + TIMELOCK_OWNED_CONVERTERS, + U, + USDC, + USDT, + U_MAX_DISTRIBUTION_SPEED, + VU, +} from "../vip-618/bscmainnet"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// ===== New TokenBuyback proxies (PR #162 redeploy — supersedes vip-618) ===== +// vip-618 hard-codes the original proxy addresses; the redeploy from +// protocol-reserve PR #162 changed every one of them, so VIP-620 carries its +// own canonical list. Order is preserved (same index → same buyback role) so +// PSR-row indices in the sim line up across both VIPs. +export const RISK_FUND_BUYBACK = "0x0c71EFabD00329E839745ef23aB946d3ed24A805"; +export const USDT_PRIME_BUYBACK = "0xD721932C7CA41Eb5305867287010587a266346a8"; +export const U_PRIME_BUYBACK = "0xBC9fFBfb799B2d189669D3816E2B7273c69041bd"; +export const XVS_BUYBACK = "0x637E6246BBb0F9aBae9d764F5e1bB6347f028C12"; +export const U_TREASURY_BUYBACK = "0xec63411423D03327De19135446dDdA3055D2feA8"; +export const BTCB_TREASURY_BUYBACK = "0x1F306a0d929a7098a0A0b12248Ba97600AB79026"; +export const ETH_TREASURY_BUYBACK = "0x41954F0bf26959dF2e1B8302DEBf736B5b154B64"; +export const USDT_TREASURY_BUYBACK = "0xB3dDf13E8B6b8dE10F5826087C202b80F1D1b490"; +export const USDC_TREASURY_BUYBACK = "0xd7aC40f9bd9A1beb8E2d121b4446CF90417cf169"; +export const XVS_TREASURY_BUYBACK = "0x6D2d239c16453062cF145A7a5128A6a60710d236"; + +export const BUYBACKS: string[] = [ + RISK_FUND_BUYBACK, + USDT_PRIME_BUYBACK, + U_PRIME_BUYBACK, + XVS_BUYBACK, + U_TREASURY_BUYBACK, + BTCB_TREASURY_BUYBACK, + ETH_TREASURY_BUYBACK, + USDT_TREASURY_BUYBACK, + USDC_TREASURY_BUYBACK, + XVS_TREASURY_BUYBACK, +]; + +// ===== May 2026 swap-budget constants (retuned for single multihop) ===== +// PLP holds ~14.9k USDC at the snapshot block; ~4k is reserved for unclaimed +// user rewards so the VIP sweeps 10k into the V2 helper. PLP already holds +// ~25k USDT, so the helper runs a single USDC -> USDT -> U multihop instead +// of two legs — vip-618's 14,986 USDC / 7,418 U_MIN_OUT were sized for the +// dropped two-leg path. +export const USDC_TO_SWEEP = parseUnits("10000", 18); +// 1% slippage floor under the 9,996.60 U QuoterV2 read (2026-05-13). +export const U_MIN_OUT = parseUnits("9900", 18); + +// Re-export the address universe so simulations and downstream tooling have a +// single import surface for both halves of the migration. +export { + CORE_TOKENS, + DEFAULT_PROXY_ADMIN, + NEW_RISK_FUND_V2_IMPL, + PRIME_LIQUIDITY_PROVIDER, + PROTOCOL_SHARE_RESERVE, + RISK_FUND_V2, + SHORTFALL, + TIMELOCK_OWNED_CONVERTERS, + U, + USDC, + USDT, +}; + +// Latest TokenBuybackMigrationHelper redeploy (protocol-reserve PR #164, +// branch feat/VPD-1167, commit 746fe99 — rebuilt after 3beaa3e dropped the +// USDC -> USDT swap leg). The helper exposes three one-shot entrypoints — +// execute1(), executeSwap() and execute2() — each gated to NormalTimelock. +// Deployment: protocol-reserve/deployments/bscmainnet/TokenBuybackMigrationHelper.json +export const MIGRATION_HELPER_V2 = "0x296a3E00c07E306FB26976FdCa201b14933AffAD"; + +// AccessControl `DEFAULT_ADMIN_ROLE` (OZ AccessControl) — the admin role on the +// AccessControlManager. Granting it to the helper lets execute1() self-grant +// the transient ACM permissions it needs (PSR rewire, pauseConversion per +// converter, Shortfall.pauseAuctions) and renounce them at the end of the call. +const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; + +const HELPER_EXECUTE1_SIG = "execute1()"; +const HELPER_EXECUTE_SWAP_SIG = "executeSwap()"; + +export const VIP_NUMBER = "vip-620"; + +export const vip620 = () => { + const meta = { + version: "v2", + title: "VIP-620 [BNB Chain] TokenBuyback Migration Part 1 & May Prime Allocation", + description: `#### Summary + +Replaces VIP-618 (unexecutable on-chain because its single helper.execute() exceeds BSC's Osaka per-tx gas cap of 16,777,216). The migration is split into two proposals — VIP-620 (this VIP) and VIP-621: + +- **Part 1 (VIP-620, this VIP)**: every migration step except draining the 6 timelock-owned converters and allowlisting swap routers on the 10 buyback proxies. The May 2026 Prime Rewards Allocation is driven by the VIP itself: Prime.addMarket(vU), PLP.initializeTokens/setMax/setSpeed and PLP.sweepToken are called directly from NormalTimelock; the helper only wraps a single soft-failing USDC → USDT → U multihop in executeSwap() so a thin-pool revert can't unwind the rest of the migration. PLP already holds ~25k USDT for the May 2026 distribution, so only U is bought. +- **Part 2 (VIP-621)**: allowlisting 9 swap routers on every buyback, the converter drain, and the final return of all 16 (10 buybacks + 6 converters) ownership to NormalTimelock. + +Between part 1 and part 2 the 6 legacy converters are paused (no inbound conversion can occur), PSR is already repointed away from them, and Shortfall auctions are paused. Balances are frozen and there is no economic surface from them. The helper retains ownership of all 16 contracts across the gap but holds no ACM privileges (DEFAULT_ADMIN_ROLE is renounced at the end of execute1()) and has no external entrypoints beyond the one-shot execute1 / executeSwap / execute2. + +#### Proposed Changes + +1. **Grant DEFAULT_ADMIN_ROLE** on the AccessControlManager to the V2 helper, so execute1() can self-grant the transient ACM permissions it needs (pauseConversion per converter, Shortfall.pauseAuctions, PSR addOrUpdateDistributionConfigs / removeDistributionConfig). The role is renounced at the end of execute1(). +2. **Transfer ownership** of the 6 timelock-owned legacy converters to the V2 helper. The 10 buyback proxies are deployed with pendingOwner = V2 helper, so execute1() accepts them without an intermediate NormalTimelock claim. +3. **helper.execute1()** — non-drain, non-allowlist phase: + - Accepts ownership of all 16 contracts (10 buybacks + 6 converters). + - Pauses every timelock-owned converter (no inbound conversion) and Shortfall auctions (RiskFundV2 is downstream). + - Repoints ProtocolShareReserve distributions: 18 new buyback rows added and 12 stale rows zeroed in a sequence that respects PSR.maxLoopsLimit (20) at every checkpoint and preserves the per-schema percentage invariant (1e4 or 0) at the end of every addOrUpdate call. + - Grants the cron operator persistent executeBuyback and forwardBaseAsset ACM permissions on every buyback. + - Renounces DEFAULT_ADMIN_ROLE on the AccessControlManager so the helper retains no residual ACM privilege between the two VIPs. + - Helper retains ownership of all 16 contracts until execute2(). +4. **May 2026 Prime Rewards Allocation** (driven directly from NormalTimelock; helper only wraps the swap): + - Prime.addMarket(coreComptroller, vU, supplyMultiplier=2e18, borrowMultiplier=0). + - PLP.initializeTokens([U]). + - PLP.setMaxTokensDistributionSpeed([U], [1e18]). + - PLP.sweepToken(USDC, V2 helper, 10,000e18). Of PLP's ~14.9k USDC, ~4k is reserved for unclaimed user rewards, so only 10k is swept. + - helper.executeSwap() — approves 10k USDC to PancakeSwap V3 router, runs a single USDC → USDT → U multihop (the direct USDC/U V3 pool is too thin; the deep USDT/U pool is required). Wrapped in try/catch with StepFailed-on-revert so a slippage hit can't take down the rest of the VIP. Min-out = 9,900e18 U (~1% buffer under the 9,996.60 U QuoterV2 read at 2026-05-13). USDT leg is intentionally omitted — PLP already holds enough USDT for the May 2026 distribution. Output lands directly in PLP; any leftover USDC is forwarded back to NormalTimelock. + - PLP.setTokensDistributionSpeed([USDT, U], [...]) — USDT speed runs against PLP's existing balance; U speed runs against the swap output. +5. **Upgrade RiskFundV2 implementation**. The new implementation removes updatePoolState, sweepTokenFromPool, and the poolAssetsFunds mapping. The upgrade is safe because RiskFundConverter is paused inside execute1() above, so no in-flight convertExactTokens callback can hit the removed updatePoolState selector — even though the converter still holds balance until part 2. + +Helper source: contracts/helpers/TokenBuybackMigrationHelper.sol in protocol-reserve PR [#164](https://github.com/VenusProtocol/protocol-reserve/pull/164) (deployed to bscmainnet at the address above). New RiskFundV2 implementation: protocol-reserve PR [#158](https://github.com/VenusProtocol/protocol-reserve/pull/158). Buyback proxies (10): protocol-reserve PR [#162](https://github.com/VenusProtocol/protocol-reserve/pull/162) redeploy. + +#### Why split + +BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The original VIP-618 helper.execute() requires ~17.5M gas (driven primarily by _drainAllConverters iterating 6 converters x 47 core-pool tokens and _allowlistRoutersOnAllBuybacks iterating 10 buybacks x 9 routers). Splitting drain + router allowlist into execute2() drops both entrypoints comfortably under the cap; vip-framework asserts the cap inside testVip so future violations fail in CI rather than at execute time on chain.`, + forDescription: "I agree that Venus Protocol should proceed with this proposal", + againstDescription: "I do not think that Venus Protocol should proceed with this proposal", + abstainDescription: "I am indifferent to whether Venus Protocol proceeds or not", + }; + + return makeProposal( + [ + // 1. Grant DEFAULT_ADMIN_ROLE on the ACM to the V2 helper. + { + target: bscmainnet.ACCESS_CONTROL_MANAGER, + signature: "grantRole(bytes32,address)", + params: [DEFAULT_ADMIN_ROLE, MIGRATION_HELPER_V2], + }, + + // 2. Transfer ownership of the 6 timelock-owned legacy converters to the V2 helper. + // The 10 buyback proxies were deployed with pendingOwner = V2 helper directly, + // so execute1() accepts them without an intermediate NormalTimelock claim. + ...TIMELOCK_OWNED_CONVERTERS.map(c => ({ + target: c, + signature: "transferOwnership(address)", + params: [MIGRATION_HELPER_V2], + })), + + // 3. helper.execute1() — accept 16 ownerships, pause converters + Shortfall, + // PSR rewire, grant operator perms, renounce ACM admin. No handback yet. + { + target: MIGRATION_HELPER_V2, + signature: HELPER_EXECUTE1_SIG, + params: [], + }, + + // 4. May 2026 Prime Rewards Allocation — driven by the VIP (PLP/Prime setters + // are onlyOwner-gated on NormalTimelock or simple ACM-gated calls; the + // helper only wraps the swap leg). + + // 4a. Add vU as a Prime market (supply-only, matching USDT/USDC). + { + target: PRIME, + signature: "addMarket(address,address,uint256,uint256)", + params: [CORE_COMPTROLLER, VU, SUPPLY_MULTIPLIER, BORROW_MULTIPLIER], + }, + + // 4b. Initialize U in PrimeLiquidityProvider so distribution accounting tracks it. + { + target: PRIME_LIQUIDITY_PROVIDER, + signature: "initializeTokens(address[])", + params: [[U]], + }, + + // 4c. Set U's max distribution speed to 1e18, matching every other Prime + // token across BSC and Ethereum. + { + target: PRIME_LIQUIDITY_PROVIDER, + signature: "setMaxTokensDistributionSpeed(address[],uint256[])", + params: [[U], [U_MAX_DISTRIBUTION_SPEED]], + }, + + // 4d. Sweep 10k USDC out of PLP into the V2 helper (not NormalTimelock) + // so helper.executeSwap() has the exact USDC_TO_SWAP it needs. + // ~4k of PLP's ~14.9k USDC is reserved for unclaimed user rewards. + { + target: PRIME_LIQUIDITY_PROVIDER, + signature: "sweepToken(address,address,uint256)", + params: [USDC, MIGRATION_HELPER_V2, USDC_TO_SWEEP], + }, + + // 4e. helper.executeSwap() — single soft-failing USDC -> USDT -> U + // multihop on PancakeSwap V3 (direct USDC/U pool is too thin; the + // deep USDT/U pool is required). Output to PLP; leftover USDC + // forwarded back to NormalTimelock. USDT leg is omitted — PLP + // already holds enough USDT for the May 2026 distribution. + { + target: MIGRATION_HELPER_V2, + signature: HELPER_EXECUTE_SWAP_SIG, + params: [], + }, + + // 4f. Set Prime distribution speeds for USDT and U at the $12,250/month + // per-market target. USDT speed runs against PLP's existing balance + // (~25k); U speed runs against the swap output. Safe to call even if + // the soft-failing swap above didn't land — the speed is just a rate. + { + target: PRIME_LIQUIDITY_PROVIDER, + signature: "setTokensDistributionSpeed(address[],uint256[])", + params: [ + [USDT, U], + [NEW_PRIME_SPEED_FOR_USDT, NEW_PRIME_SPEED_FOR_U], + ], + }, + + // 5. Upgrade RiskFundV2 implementation. Safe because RiskFundConverter was + // paused inside execute1() above; no convertExactTokens callback can + // reach the removed updatePoolState selector. + { + target: DEFAULT_PROXY_ADMIN, + signature: "upgrade(address,address)", + params: [RISK_FUND_V2, NEW_RISK_FUND_V2_IMPL], + }, + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip620; diff --git a/vips/vip-620/bscmainnet-part-2.ts b/vips/vip-620/bscmainnet-part-2.ts new file mode 100644 index 000000000..729322230 --- /dev/null +++ b/vips/vip-620/bscmainnet-part-2.ts @@ -0,0 +1,78 @@ +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +import { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS } from "./bscmainnet-part-1"; + +export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; + +const HELPER_EXECUTE2_SIG = "execute2()"; + +export const VIP_NUMBER = "vip-621"; + +export const vip621 = () => { + const meta = { + version: "v2", + title: "VIP-621 [BNB Chain] TokenBuyback Migration Part 2 (router allowlist + drain + handback)", + description: `#### Summary + +Final step of the TokenBuyback migration begun in VIP-620. Allowlists 9 swap routers on every buyback, drains every non-zero ERC20 balance from the 6 timelock-owned legacy converters into the corresponding new buyback proxies, and returns ownership of all 16 contracts (10 buybacks + 6 converters) to NormalTimelock. + +This VIP must be queued and executed **after** VIP-620, which: +- Granted the V2 helper DEFAULT_ADMIN_ROLE on the ACM (renounced at end of execute1) and converter ownership. +- Accepted ownership of the 10 buyback proxies (deployed with pendingOwner = V2 helper) and the 6 timelock-owned converters. +- Paused every timelock-owned converter (no inbound conversion since pause) and Shortfall auctions. +- Repointed PSR distributions away from legacy converters (no inbound revenue since rewire). +- Ran the May 2026 Prime allocation (Prime.addMarket(vU), PLP.initializeTokens/setMax/setSpeed, helper.executeSwap()). +- Upgraded RiskFundV2 to the new implementation. + +Between part 1 and part 2 the converters are paused and PSR no longer routes to them, so balances are frozen and there is no economic surface from them. The drain in this VIP simply moves the frozen balances into the new buybacks. + +#### Proposed Changes + +1. **helper.execute2()** — three steps: + - Allowlists 9 swap routers on every buyback (PancakeSwap V2 / V3 / Smart / Universal, Uniswap V2 SwapRouter02 / V3 SwapRouter02 / V4 / Universal, 1inch v5). + - Drains every non-zero core-pool ERC20 balance off each timelock-owned converter into its replacement buyback: + - RiskFundConverter → RISK_FUND_BUYBACK + - USDT_PRIME_CONVERTER → U_PRIME_BUYBACK + - USDC_PRIME_CONVERTER → U_PRIME_BUYBACK + - BTCB_PRIME_CONVERTER → U_PRIME_BUYBACK + - ETH_PRIME_CONVERTER → U_PRIME_BUYBACK + - XVS_VAULT_CONVERTER → XVS_BUYBACK + - Transfers ownership of all 16 contracts (10 buybacks + 6 converters) back to NormalTimelock. +2. **Accept ownership** of the 10 buybacks and 6 converters returned by the helper. + +After this VIP executes, the V2 helper holds no privileges, no balances, and no ownership over any contract. All three entrypoints (execute1, executeSwap, execute2) revert AlreadyExecuted on any subsequent call. + +#### Why split + +BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The original VIP-618 helper.execute() requires ~17.5M gas (driven primarily by _drainAllConverters iterating 6 converters x 47 core-pool tokens and _allowlistRoutersOnAllBuybacks iterating 10 buybacks x 9 routers). Splitting the drain + router allowlist into execute2() drops both halves comfortably under the cap.`, + forDescription: "I agree that Venus Protocol should proceed with this proposal", + againstDescription: "I do not think that Venus Protocol should proceed with this proposal", + abstainDescription: "I am indifferent to whether Venus Protocol proceeds or not", + }; + + return makeProposal( + [ + // 1. helper.execute2() — router allowlist on 10 buybacks, drain 6 + // converters, hand back ownership of all 16 contracts to NormalTimelock. + { + target: MIGRATION_HELPER_V2, + signature: HELPER_EXECUTE2_SIG, + params: [], + }, + + // 2. Accept ownership of the 10 buybacks and 6 converters handed back by + // the helper. Order matches the helper's hand-back order (buybacks + // first, then converters) for legibility. + ...[...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS].map(a => ({ + target: a, + signature: "acceptOwnership()", + params: [], + })), + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip621;