From a0122312649cf681866762e54eed5e22f26a0e4b Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 13:04:38 +0530 Subject: [PATCH 1/9] feat(vip-framework): discover and enforce per-tx gas cap in sims add resolvePerTxGasCap (eth_txGasLimitCap RPC, static fallback map in networkConfig) and apply it as tx.gasLimit in testVip, testForkedNetworkVipCommands, pretendExecutingVip so hardhat OOGs at the same boundary mainnet would. catches BSC Maxwell+Osaka and Ethereum Fusaka EIP-7825 caps; future hardforks via EIP-8123 are picked up automatically. --- src/networkConfig.ts | 28 ++++++++++++++++- src/utils.ts | 53 ++++++++++++++++++++++++++++++++ src/vip-framework/index.ts | 63 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 4 deletions(-) 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..9a5937240 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-8123 `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-8123 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-8123)"; + } + } + } 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..cd98f2426 100644 --- a/src/vip-framework/index.ts +++ b/src/vip-framework/index.ts @@ -7,7 +7,7 @@ import { BigNumber, Contract, ContractInterface } from "ethers"; import { FORKED_NETWORK, ethers } from "hardhat"; import { NETWORK_ADDRESSES } from "../networkAddresses"; -import { NETWORK_CONFIG } from "../networkConfig"; +import { NETWORK_CONFIG, PER_TX_GAS_CAP_2_24, PER_TX_GAS_CAP_BY_NETWORK } from "../networkConfig"; import { Proposal, ProposalType, REMOTE_NETWORKS, SUPPORTED_NETWORKS } from "../types"; import { calculateGasForAdapterParam, @@ -18,6 +18,7 @@ import { initMainnetUser, mineBlocks, mineOnZksync, + resolvePerTxGasCap, setForkBlock, validateTargetAddresses, } from "../utils"; @@ -45,6 +46,15 @@ const OMNICHAIN_GOVERNANCE_EXECUTOR = // New voting period: 115,200 * 1.67 = 192,384 const VOTING_PERIOD = 192384; +// Per-tx gas cap source: see `PER_TX_GAS_CAP_BY_NETWORK` in +// `src/networkConfig.ts` for the static fallback table, and +// `resolvePerTxGasCap` in `src/utils.ts` for the runtime resolver that prefers +// EIP-8123 (`eth_txGasLimitCap`) when supported. +// +// Re-exported here for backwards compatibility with any caller that imported +// from `src/vip-framework/index.ts` before the move. +export { PER_TX_GAS_CAP_2_24, PER_TX_GAS_CAP_BY_NETWORK }; + export const { DEFAULT_PROPOSER_ADDRESS, GOVERNOR_PROXY, @@ -125,13 +135,24 @@ export const pretendExecutingVip = async (proposal: Proposal, sender: string = G const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar.start(proposal.signatures.length, 0); + let totalGas = BigNumber.from(0); 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); txResponses.push(txResponse); bar.update(i + 1); } bar.stop(); + const cap = await resolvePerTxGasCap(FORKED_NETWORK); + const capSuffix = Number.isFinite(cap) + ? ` (${totalGas.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 totalGasUsed=${totalGas.toString()} across ${proposal.signatures.length} ` + + `commands${capSuffix}`, + ); return txResponses; }; @@ -224,7 +245,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 +354,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); From aab2e13fba5607603e2f6c94f0b81a59ec93a8ca Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 13:04:49 +0530 Subject: [PATCH 2/9] feat(vip-800): split TokenBuyback migration to fit BSC per-tx gas cap VIP-618's bundled execute() needed ~17.5M gas, exceeding BSC's Maxwell+ Osaka per-tx cap of 2^24 = 16,777,216. Replace with vip-800 part-1 (non-drain migration: grants, ownership, routers, ACM perms, pause, PSR rewire, Prime allocation, RiskFundV2 upgrade) and part-2 (converter drain + handback). Each fits comfortably under the cap. --- simulations/vip-800/bscmainnet-part-1.ts | 363 +++++++++++++++++++++++ simulations/vip-800/bscmainnet-part-2.ts | 185 ++++++++++++ vips/vip-800/bscmainnet-part-1.ts | 163 ++++++++++ vips/vip-800/bscmainnet-part-2.ts | 71 +++++ 4 files changed, 782 insertions(+) create mode 100644 simulations/vip-800/bscmainnet-part-1.ts create mode 100644 simulations/vip-800/bscmainnet-part-2.ts create mode 100644 vips/vip-800/bscmainnet-part-1.ts create mode 100644 vips/vip-800/bscmainnet-part-2.ts diff --git a/simulations/vip-800/bscmainnet-part-1.ts b/simulations/vip-800/bscmainnet-part-1.ts new file mode 100644 index 000000000..1a8edbc46 --- /dev/null +++ b/simulations/vip-800/bscmainnet-part-1.ts @@ -0,0 +1,363 @@ +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 HELPER_V3_ARTIFACT from "../../artifacts/contracts/helpers/TokenBuybackMigrationHelper.sol/TokenBuybackMigrationHelper.json"; +import { + BTCB_PRIME_CONVERTER, + ETH_PRIME_CONVERTER, + PRIME, + RISK_FUND_CONVERTER, + USDC_PRIME_CONVERTER, + USDT_PRIME_CONVERTER, + VTREASURY, + VU, + WBNB_BURN_CONVERTER, + XVS_VAULT_CONVERTER, +} from "../../vips/vip-618/bscmainnet"; +import vip800Part1, { + 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, + USDT, + U_PRIME_BUYBACK, + XVS_BUYBACK, +} from "../../vips/vip-800/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"; + +// Re-export so part-2 sim can pick up the same address universe without +// duplicating imports. +export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Real-env fork block: V2 helper bytecode exists on chain at MIGRATION_HELPER_V2 +// (deployed at block 97983412). The compiled bytecode in the helper artifact +// folds in the May 2026 Prime allocation block; on-chain bytecode at this fork +// block does not yet include that update. The `before` hook etches the freshly +// compiled bytecode at MIGRATION_HELPER_V2 so the sim exercises the helper +// version that will land on chain when the new artifact is redeployed. +const FORK_BLOCK = 97988051; + +const SHORTFALL_MIN_ABI = ["function auctionsPaused() view returns (bool)"]; +const CONVERTER_MIN_ABI = ["function conversionPaused() view returns (bool)"]; +const HELPER_MIN_ABI = [ + "function executed1() view returns (bool)", + "function executed2() view returns (bool)", + "event StepFailed(string step, bytes reason)", +]; +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, HELPER_MIN_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 timelockUsdcBalanceBefore: BigNumber; + const converterBalanceBefore = new Map(); + + before(async () => { + // Etch the freshly compiled V3 helper bytecode at the canonical + // MIGRATION_HELPER_V2 address. The on-chain bytecode at this fork block is + // an earlier V2 cut without the Prime allocation block; the freshly + // compiled artifact in `artifacts/contracts/helpers/...` is the version + // that will land on chain when the helper is redeployed. Storage is + // preserved (executed1/executed2 default to false; ReentrancyGuard + // _status = 0 → OZ treats as NOT_ENTERED on first entry). + await ethers.provider.send("hardhat_setCode", [ + MIGRATION_HELPER_V2, + (HELPER_V3_ARTIFACT as { deployedBytecode: string }).deployedBytecode, + ]); + + // Pre-condition the deploy script will fulfil on chain: every buyback's + // pendingOwner must point at MIGRATION_HELPER_V2 so helper.execute1() can + // accept ownership. At the fork block buybacks still point at the V1 + // helper, so impersonate the current owner and re-point each one. + for (const b of BUYBACKS) { + const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); + const currentPending = await buybackOwnable.pendingOwner(); + if (currentPending.toLowerCase() === MIGRATION_HELPER_V2.toLowerCase()) continue; + const currentOwner = await buybackOwnable.owner(); + const ownerSigner = await initMainnetUser(currentOwner, ethers.utils.parseEther("1")); + const buybackAsOwner = new ethers.Contract(b, ["function transferOwnership(address)"], ownerSigner); + await buybackAsOwner.transferOwnership(MIGRATION_HELPER_V2); + } + + riskFundV2UsdtBalanceBefore = await usdt.balanceOf(RISK_FUND_V2); + plpUsdcBalanceBefore = await usdc.balanceOf(PRIME_LIQUIDITY_PROVIDER); + plpUsdtBalanceBefore = await usdt.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()).to.equal(MIGRATION_HELPER_V2); + } + }); + + it("each timelock-owned legacy converter is not yet paused", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect(await converter(c).conversionPaused()).to.be.false; + } + }); + + it("helper has not yet executed phase 1 or phase 2", async () => { + expect(await helper.executed1()).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("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); + }); + }); + + testVip("VIP-800 part 1 — non-drain migration & May Prime allocation", await vip800Part1()); + + describe("Post-VIP state (part 1)", () => { + it("helper.executed1 is true; executed2 still false", async () => { + expect(await helper.executed1()).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("NormalTimelock owns each buyback proxy", async () => { + for (const b of BUYBACKS) { + expect(await ownable(b).owner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + } + }); + + it("helper still owns each timelock-owned legacy converter (handed back in part 2)", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect(await ownable(c).owner()).to.equal(MIGRATION_HELPER_V2); + } + }); + + 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)).to.equal(NEW_RISK_FUND_V2_IMPL); + }); + + 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 (run inside execute1)", () => { + it("vU is registered as a Prime market", async () => { + const m = await prime.markets(VU); + expect(m.exists).to.be.true; + }); + + it("U is initialized in PrimeLiquidityProvider", async () => { + expect(await plp.lastAccruedBlockOrSecond(U)).to.be.gt(0); + }); + + it("PLP USDC balance decreased (helper swept USDC out for swaps)", async () => { + const after = await usdc.balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(after).to.be.lt(plpUsdcBalanceBefore); + }); + + it("PLP USDT balance increased iff USDC->USDT swap leg succeeded", async () => { + // Soft-fail leg: if USDT pool quote was healthy, PLP USDT balance went + // up. If StepFailed("swapUSDCtoUSDT") fired, balance is unchanged. + // Don't require either outcome — just sanity-check non-negative delta. + const after = await usdt.balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(after).to.be.gte(plpUsdtBalanceBefore); + }); + + it("Helper holds zero USDC after execute1 (leftover forwarded back to NormalTimelock)", async () => { + expect(await usdc.balanceOf(MIGRATION_HELPER_V2)).to.equal(0); + }); + + it("NormalTimelock USDC balance is non-decreasing (helper forwards leftover here)", async () => { + const after = await usdc.balanceOf(bscmainnet.NORMAL_TIMELOCK); + expect(after).to.be.gte(timelockUsdcBalanceBefore); + }); + }); + + describe("Post-VIP helper invariants (part 1)", () => { + it("helper is NOT owner/pendingOwner of any buyback (handed back in execute1)", async () => { + for (const b of BUYBACKS) { + expect((await ownable(b).owner()).toLowerCase(), `${b} owner`).to.not.equal(MIGRATION_HELPER_V2.toLowerCase()); + expect((await ownable(b).pendingOwner()).toLowerCase(), `${b} pendingOwner`).to.not.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; + }); + }); +}); diff --git a/simulations/vip-800/bscmainnet-part-2.ts b/simulations/vip-800/bscmainnet-part-2.ts new file mode 100644 index 000000000..d76607c03 --- /dev/null +++ b/simulations/vip-800/bscmainnet-part-2.ts @@ -0,0 +1,185 @@ +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, pretendExecutingVip, testVip } from "src/vip-framework"; + +import HELPER_V3_ARTIFACT from "../../artifacts/contracts/helpers/TokenBuybackMigrationHelper.sol/TokenBuybackMigrationHelper.json"; +import { + BTCB_PRIME_CONVERTER, + ETH_PRIME_CONVERTER, + RISK_FUND_CONVERTER, + USDC_PRIME_CONVERTER, + USDT_PRIME_CONVERTER, + XVS_VAULT_CONVERTER, +} from "../../vips/vip-618/bscmainnet"; +import vip800Part1, { + BUYBACKS, + CORE_TOKENS, + MIGRATION_HELPER_V2, + RISK_FUND_BUYBACK, + TIMELOCK_OWNED_CONVERTERS, + U_PRIME_BUYBACK, + XVS_BUYBACK, +} from "../../vips/vip-800/bscmainnet-part-1"; +import vip800Part2 from "../../vips/vip-800/bscmainnet-part-2"; +import ACM_ABI from "../vip-618/abi/AccessControlManager.json"; +import ERC20_ABI from "../vip-618/abi/ERC20.json"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Real-env block: V2 helper (MIGRATION_HELPER_V2) is on chain. +// Match part-1 sim's FORK_BLOCK. +const FORK_BLOCK = 97988051; + +const HELPER_MIN_ABI = ["function executed1() view returns (bool)", "function executed2() 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 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, HELPER_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); + + // 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 () => { + // Etch the freshly compiled V3 helper bytecode at MIGRATION_HELPER_V2 + // (mirrors part-1 sim). + await ethers.provider.send("hardhat_setCode", [ + MIGRATION_HELPER_V2, + (HELPER_V3_ARTIFACT as { deployedBytecode: string }).deployedBytecode, + ]); + + // Pre-condition the deploy script will fulfil on chain: every buyback's + // pendingOwner must point at MIGRATION_HELPER_V2 so part-1's + // helper.execute1() can accept ownership. At the fork block buybacks may + // still point at the V1 helper, so re-point each one via impersonation. + // Idempotent: skips buybacks already pointed at V2. + for (const b of BUYBACKS) { + const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); + const currentPending = await buybackOwnable.pendingOwner(); + if (currentPending.toLowerCase() === MIGRATION_HELPER_V2.toLowerCase()) continue; + + const currentOwner = await buybackOwnable.owner(); + const ownerSigner = await initMainnetUser(currentOwner, ethers.utils.parseEther("1")); + const buybackAsOwner = new ethers.Contract(b, ["function transferOwnership(address)"], ownerSigner); + await buybackAsOwner.transferOwnership(MIGRATION_HELPER_V2); + } + + // Apply part-1 from NormalTimelock so helper.execute1() passes the + // `msg.sender == NORMAL_TIMELOCK` gate. + await pretendExecutingVip(await vip800Part1(), bscmainnet.NORMAL_TIMELOCK); + + 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 is true and executed2 is false", async () => { + expect(await helper.executed1()).to.be.true; + expect(await helper.executed2()).to.be.false; + }); + + it("helper still owns each timelock-owned legacy converter", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect((await ownable(c).owner()).toLowerCase()).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + } + }); + + 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; + }); + }); + + testVip("VIP-800 part 2 — drain converters and hand back ownership", await vip800Part2()); + + 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("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 (e.g. 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 timelock-owned legacy converter", async () => { + for (const c of TIMELOCK_OWNED_CONVERTERS) { + expect((await ownable(c).owner()).toLowerCase()).to.equal(bscmainnet.NORMAL_TIMELOCK.toLowerCase()); + } + }); + + it("helper is neither owner nor pendingOwner of any of the 16 migrated contracts", async () => { + const targets = [...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS]; + for (const t of targets) { + 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/vips/vip-800/bscmainnet-part-1.ts b/vips/vip-800/bscmainnet-part-1.ts new file mode 100644 index 000000000..5d775f869 --- /dev/null +++ b/vips/vip-800/bscmainnet-part-1.ts @@ -0,0 +1,163 @@ +import { ethers } from "ethers"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +import { + BUYBACKS, + CORE_TOKENS, + DEFAULT_PROXY_ADMIN, + NEW_RISK_FUND_V2_IMPL, + PRIME_LIQUIDITY_PROVIDER, + PROTOCOL_SHARE_RESERVE, + RISK_FUND_BUYBACK, + RISK_FUND_V2, + SHORTFALL, + TIMELOCK_OWNED_CONVERTERS, + U, + USDC, + USDT, + U_PRIME_BUYBACK, + XVS_BUYBACK, +} from "../vip-618/bscmainnet"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Re-export the address universe so simulations and downstream tooling have a +// single import surface for both halves of the migration. +export { + BUYBACKS, + CORE_TOKENS, + DEFAULT_PROXY_ADMIN, + NEW_RISK_FUND_V2_IMPL, + PRIME_LIQUIDITY_PROVIDER, + PROTOCOL_SHARE_RESERVE, + RISK_FUND_BUYBACK, + RISK_FUND_V2, + SHORTFALL, + TIMELOCK_OWNED_CONVERTERS, + U, + USDC, + USDT, + U_PRIME_BUYBACK, + XVS_BUYBACK, +}; + +// ===== TokenBuyback migration helper V2 (execute1 / execute2 split) ===== +// V1 helper at 0x352d2188A5C838854B8565dCD88cD3c9c996e83A is unexecutable: its +// single execute() needs ~17.5M gas, exceeding BSC's Osaka per-tx cap of +// 16,777,216 (2^24). V2 splits the migration into: +// execute1() — every step except draining the 6 timelock-owned converters. +// Includes the May 2026 Prime rewards allocation (Shortfall +// pause, vU addMarket, PLP init/sweep/swap/speeds) folded in +// after the PSR rewire. Swap legs and the final +// setTokensDistributionSpeed are soft-fail (try/catch), so a +// thin-pool revert can't unwind the migration core. Any +// leftover USDC is forwarded back to NormalTimelock. +// execute2() — only the converter drain + handBack of the 6 timelock-owned +// converters (called by vip-800 part 2). +// The 10 buyback proxies' pendingOwner must be re-pointed to this V2 address +// by the deployer (off-chain transferOwnership from the current owner) before +// this VIP executes. The 6 timelock-owned converters keep this helper as their +// owner across both VIPs; ownership returns to NormalTimelock at the end of +// part 2. +// Deployment: protocol-reserve/deployments/bscmainnet/TokenBuybackMigrationHelper.json +export const MIGRATION_HELPER_V2 = "0xa30fcE7A72aD101f6afd4D8b89D1AD8687f51cb0"; + +// AccessControl `DEFAULT_ADMIN_ROLE` (OZ AccessControl) — the admin role on the +// AccessControlManager. Granting it to the helper lets `execute1()` self-grant +// transient ACM permissions and renounce them at the end of the call. +const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; + +// Helper.execute1() — non-drain phase of the migration. Includes the May 2026 +// Prime rewards allocation block (soft-fail on swap legs and final +// setTokensDistributionSpeed). Drain happens in execute2() (vip-800 part 2). +const HELPER_EXECUTE1_SIG = "execute1()"; + +export const VIP_NUMBER = "vip-800-part-1"; + +export const vip800Part1 = () => { + const meta = { + version: "v2", + title: "VIP-800 [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). VIP-800 splits the migration into two proposals: + +- **Part 1 (this VIP)**: every migration step except draining the 6 timelock-owned converters. The May 2026 Prime Rewards Allocation is folded inside helper.execute1(); swap legs and the final setTokensDistributionSpeed are soft-fail to insulate the migration core from thin-pool reverts. +- **Part 2 (vip-800-part-2)**: the converter drain and the final return of converter ownership to NormalTimelock. + +Between part 1 and part 2 the 6 legacy converters are paused (no inbound conversion can occur) and PSR is already repointed away from them, so balances are frozen and there is no economic surface from them. + +#### Proposed Changes + +1. **Grant DEFAULT_ADMIN_ROLE** on the AccessControlManager to the V2 helper, so it can self-grant the transient ACM permissions it needs (pauseConversion per converter, addOrUpdateDistributionConfigs, removeDistributionConfig, Shortfall.pauseAuctions, Prime.addMarket, PLP initialize/setMaxSpeed/setSpeed/sweepToken). +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 the helper accepts them inside execute1() without an intermediate NormalTimelock claim. +3. **helper.execute1()** — runs every non-drain step: + - Accepts ownership of all 16 contracts (10 buybacks + 6 converters). + - Allowlists 9 swap routers on every buyback (PancakeSwap V2 / V3 / Smart / Universal, Uniswap V2 SwapRouter02 / V3 SwapRouter02 / V4 / Universal, 1inch v5). + - Grants executeBuyback and forwardBaseAsset ACM permissions to the cron operator on every buyback. + - Calls pauseConversion() on every timelock-owned converter. + - Repoints ProtocolShareReserve distributions: 18 new buyback rows added and 12 stale rows zeroed in a sequence that preserves the per-schema percentage invariant (1e4 or 0) at every checkpoint. + - Runs the May 2026 Prime allocation: Shortfall.pauseAuctions, Prime.addMarket(vU), PLP.initializeTokens([U]), PLP.setMaxTokensDistributionSpeed([U],[1e18]), PLP.sweepToken(USDC, helper, 14986e18), router approve, swap USDC -> USDT and USDC -> U via PancakeSwap V3 (both wrapped in try/catch — failure emits StepFailed and the migration continues), reset router allowance, forward leftover USDC back to NormalTimelock, PLP.setTokensDistributionSpeed([USDT, U], [...]) (also try/catch). + - Transfers ownership of the **10 buybacks** back to NormalTimelock (the 6 converters stay helper-owned until part 2). + - Renounces DEFAULT_ADMIN_ROLE on the AccessControlManager so the helper retains no residual ACM privilege between the two VIPs. +4. **Accept ownership** of the 10 buybacks returned by the helper. +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 this repository (deployed to bscmainnet at the address above). Implementation of the new RiskFundV2: [VenusProtocol/protocol-reserve PR #158](https://github.com/VenusProtocol/protocol-reserve/pull/158). + +#### 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). Splitting drain into execute2() drops part 1 below the cap; part 2 is small enough on its own. 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. + ...TIMELOCK_OWNED_CONVERTERS.map(c => ({ + target: c, + signature: "transferOwnership(address)", + params: [MIGRATION_HELPER_V2], + })), + + // 3. helper.execute1() — non-drain phase + Prime allocation. + { + target: MIGRATION_HELPER_V2, + signature: HELPER_EXECUTE1_SIG, + params: [], + }, + + // 4. Accept ownership of the 10 buybacks handed back by the helper. + // The 6 converters stay helper-owned until vip-800-part-2 runs execute2(). + ...BUYBACKS.map(a => ({ + target: a, + signature: "acceptOwnership()", + params: [], + })), + + // 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 vip800Part1; diff --git a/vips/vip-800/bscmainnet-part-2.ts b/vips/vip-800/bscmainnet-part-2.ts new file mode 100644 index 000000000..01271fb63 --- /dev/null +++ b/vips/vip-800/bscmainnet-part-2.ts @@ -0,0 +1,71 @@ +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +import { TIMELOCK_OWNED_CONVERTERS } from "../vip-618/bscmainnet"; +import { MIGRATION_HELPER_V2 } from "./bscmainnet-part-1"; + +export { MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; + +const HELPER_EXECUTE2_SIG = "execute2()"; + +export const VIP_NUMBER = "vip-800-part-2"; + +export const vip800Part2 = () => { + const meta = { + version: "v2", + title: "VIP-800 [BNB Chain] TokenBuyback Migration Part 2 (drain + converter handback)", + description: `#### Summary + +Final step of the VIP-800 TokenBuyback migration. Drains every non-zero ERC20 balance from the 6 timelock-owned legacy converters into the corresponding new buyback proxies and returns converter ownership to NormalTimelock. + +This VIP must be queued and executed **after** vip-800-part-1, which: +- Granted the V2 helper DEFAULT_ADMIN_ROLE on the ACM (renounced at end of execute1) and converter ownership. +- Paused every timelock-owned converter (no inbound conversion since pause). +- Repointed PSR distributions away from legacy converters (no inbound revenue since rewire). +- Returned ownership of the 10 buyback proxies to NormalTimelock. + +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()** — drains the 6 timelock-owned converters and transfers ownership of each back to NormalTimelock: + - 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 +2. **Accept ownership** of the 6 converters returned by the helper. + +After this VIP executes, the V2 helper holds no privileges, no balances, and no ownership over any contract. Both execute1() and 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). Splitting drain into execute2() drops part 1 below the cap; this part 2 contains only the drain plus 6 acceptOwnership() calls and is small enough on its own.`, + 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() — drain + converter handback. + { + target: MIGRATION_HELPER_V2, + signature: HELPER_EXECUTE2_SIG, + params: [], + }, + + // 2. Accept ownership of the 6 converters returned by the helper. + ...TIMELOCK_OWNED_CONVERTERS.map(c => ({ + target: c, + signature: "acceptOwnership()", + params: [], + })), + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip800Part2; From 94abff771e7773e0ce91c7b77fcdc9f087d91a97 Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 17:22:38 +0530 Subject: [PATCH 3/9] feat(vip-800): align with redeployed helper contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refresh MIGRATION_HELPER_V2 to the 0x296a3E redeploy (commit 746fe99) and update the 10 buyback proxy addresses to the latest redeploy. - Drop the USDC -> USDT swap leg: helper.executeSwap() now runs a single USDC -> USDT -> U multihop. USDC sweep tuned to 10k (4k stays on PLP for unclaimed user rewards); U_MIN_OUT raised to 9,900e18 under the 9,996.60 U QuoterV2 read. - Pull the May 2026 Prime allocation (Prime.addMarket(vU), PLP.initializeTokens/setMax/setSpeed, PLP.sweepToken) out of the helper and drive it from the VIP itself; helper now exposes three one-shot entrypoints — execute1 / executeSwap / execute2. - Move router allowlisting out of execute1 into execute2 to fit BSC's per-tx gas cap. Part 2 hands back ownership of all 16 contracts (10 buybacks + 6 converters) instead of just the 6 converters. - Sims: replace the artifact-bytecode etch with a local ABI under simulations/vip-800/abi/, bump FORK_BLOCK to 98041000 (past the helper and buyback redeploys), and add executedSwap / router- allowlist assertions for the new flow. --- .../abi/TokenBuybackMigrationHelper.json | 148 +++++++++++++++++ simulations/vip-800/bscmainnet-part-1.ts | 84 +++++----- simulations/vip-800/bscmainnet-part-2.ts | 90 +++++++---- vips/vip-618/bscmainnet.ts | 35 ++-- vips/vip-800/bscmainnet-part-1.ts | 150 ++++++++++++------ vips/vip-800/bscmainnet-part-2.ts | 49 +++--- 6 files changed, 397 insertions(+), 159 deletions(-) create mode 100644 simulations/vip-800/abi/TokenBuybackMigrationHelper.json diff --git a/simulations/vip-800/abi/TokenBuybackMigrationHelper.json b/simulations/vip-800/abi/TokenBuybackMigrationHelper.json new file mode 100644 index 000000000..c21ddaba3 --- /dev/null +++ b/simulations/vip-800/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-800/bscmainnet-part-1.ts b/simulations/vip-800/bscmainnet-part-1.ts index 1a8edbc46..50087e227 100644 --- a/simulations/vip-800/bscmainnet-part-1.ts +++ b/simulations/vip-800/bscmainnet-part-1.ts @@ -5,7 +5,6 @@ import { NETWORK_ADDRESSES } from "src/networkAddresses"; import { initMainnetUser } from "src/utils"; import { forking, testVip } from "src/vip-framework"; -import HELPER_V3_ARTIFACT from "../../artifacts/contracts/helpers/TokenBuybackMigrationHelper.sol/TokenBuybackMigrationHelper.json"; import { BTCB_PRIME_CONVERTER, ETH_PRIME_CONVERTER, @@ -41,6 +40,7 @@ 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"; // Re-export so part-2 sim can pick up the same address universe without // duplicating imports. @@ -48,21 +48,14 @@ export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; const { bscmainnet } = NETWORK_ADDRESSES; -// Real-env fork block: V2 helper bytecode exists on chain at MIGRATION_HELPER_V2 -// (deployed at block 97983412). The compiled bytecode in the helper artifact -// folds in the May 2026 Prime allocation block; on-chain bytecode at this fork -// block does not yet include that update. The `before` hook etches the freshly -// compiled bytecode at MIGRATION_HELPER_V2 so the sim exercises the helper -// version that will land on chain when the new artifact is redeployed. -const FORK_BLOCK = 97988051; +// 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 = 98041000; const SHORTFALL_MIN_ABI = ["function auctionsPaused() view returns (bool)"]; const CONVERTER_MIN_ABI = ["function conversionPaused() view returns (bool)"]; -const HELPER_MIN_ABI = [ - "function executed1() view returns (bool)", - "function executed2() view returns (bool)", - "event StepFailed(string step, bytes reason)", -]; const PRIME_MIN_ABI = [ "function markets(address) view returns (uint256 supplyMultiplier, uint256 borrowMultiplier, uint256 rewardIndex, uint256 sumOfMembersScore, bool exists)", ]; @@ -121,7 +114,7 @@ forking(FORK_BLOCK, async () => { 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, HELPER_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); @@ -132,25 +125,14 @@ forking(FORK_BLOCK, async () => { let riskFundV2UsdtBalanceBefore: BigNumber; let plpUsdcBalanceBefore: BigNumber; let plpUsdtBalanceBefore: BigNumber; + let plpUBalanceBefore: BigNumber; let timelockUsdcBalanceBefore: BigNumber; const converterBalanceBefore = new Map(); before(async () => { - // Etch the freshly compiled V3 helper bytecode at the canonical - // MIGRATION_HELPER_V2 address. The on-chain bytecode at this fork block is - // an earlier V2 cut without the Prime allocation block; the freshly - // compiled artifact in `artifacts/contracts/helpers/...` is the version - // that will land on chain when the helper is redeployed. Storage is - // preserved (executed1/executed2 default to false; ReentrancyGuard - // _status = 0 → OZ treats as NOT_ENTERED on first entry). - await ethers.provider.send("hardhat_setCode", [ - MIGRATION_HELPER_V2, - (HELPER_V3_ARTIFACT as { deployedBytecode: string }).deployedBytecode, - ]); - // Pre-condition the deploy script will fulfil on chain: every buyback's // pendingOwner must point at MIGRATION_HELPER_V2 so helper.execute1() can - // accept ownership. At the fork block buybacks still point at the V1 + // accept ownership. At the fork block buybacks still point at the previous // helper, so impersonate the current owner and re-point each one. for (const b of BUYBACKS) { const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); @@ -165,6 +147,7 @@ forking(FORK_BLOCK, async () => { 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) { @@ -189,8 +172,9 @@ forking(FORK_BLOCK, async () => { } }); - it("helper has not yet executed phase 1 or phase 2", async () => { + 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; }); @@ -211,8 +195,9 @@ forking(FORK_BLOCK, async () => { testVip("VIP-800 part 1 — non-drain migration & May Prime allocation", await vip800Part1()); describe("Post-VIP state (part 1)", () => { - it("helper.executed1 is true; executed2 still false", async () => { + 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; }); @@ -220,13 +205,13 @@ forking(FORK_BLOCK, async () => { expect(await acm.hasRole(DEFAULT_ADMIN_ROLE, MIGRATION_HELPER_V2)).to.be.false; }); - it("NormalTimelock owns each buyback proxy", async () => { + it("helper still owns each buyback (handback deferred to part 2)", async () => { for (const b of BUYBACKS) { - expect(await ownable(b).owner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + expect(await ownable(b).owner()).to.equal(MIGRATION_HELPER_V2); } }); - it("helper still owns each timelock-owned legacy converter (handed back in part 2)", async () => { + 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()).to.equal(MIGRATION_HELPER_V2); } @@ -305,7 +290,7 @@ forking(FORK_BLOCK, async () => { }); }); - describe("Post-VIP Prime allocation (run inside execute1)", () => { + describe("Post-VIP Prime allocation", () => { it("vU is registered as a Prime market", async () => { const m = await prime.markets(VU); expect(m.exists).to.be.true; @@ -315,20 +300,22 @@ forking(FORK_BLOCK, async () => { expect(await plp.lastAccruedBlockOrSecond(U)).to.be.gt(0); }); - it("PLP USDC balance decreased (helper swept USDC out for swaps)", async () => { + it("PLP USDC balance decreased (VIP swept USDC into the helper for swaps)", async () => { const after = await usdc.balanceOf(PRIME_LIQUIDITY_PROVIDER); expect(after).to.be.lt(plpUsdcBalanceBefore); }); - it("PLP USDT balance increased iff USDC->USDT swap leg succeeded", async () => { - // Soft-fail leg: if USDT pool quote was healthy, PLP USDT balance went - // up. If StepFailed("swapUSDCtoUSDT") fired, balance is unchanged. - // Don't require either outcome — just sanity-check non-negative delta. + it("PLP USDT balance is unchanged (USDT swap leg was dropped — PLP already holds enough)", async () => { const after = await usdt.balanceOf(PRIME_LIQUIDITY_PROVIDER); - expect(after).to.be.gte(plpUsdtBalanceBefore); + expect(after).to.equal(plpUsdtBalanceBefore); + }); + + it("PLP U balance non-decreasing (USDC -> USDT -> U multihop is soft-fail)", async () => { + const after = await erc20(U).balanceOf(PRIME_LIQUIDITY_PROVIDER); + expect(after).to.be.gte(plpUBalanceBefore); }); - it("Helper holds zero USDC after execute1 (leftover forwarded back to NormalTimelock)", async () => { + it("Helper holds zero USDC after executeSwap (leftover forwarded back to NormalTimelock)", async () => { expect(await usdc.balanceOf(MIGRATION_HELPER_V2)).to.equal(0); }); @@ -339,12 +326,9 @@ forking(FORK_BLOCK, async () => { }); describe("Post-VIP helper invariants (part 1)", () => { - it("helper is NOT owner/pendingOwner of any buyback (handed back in execute1)", async () => { + 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.not.equal(MIGRATION_HELPER_V2.toLowerCase()); - expect((await ownable(b).pendingOwner()).toLowerCase(), `${b} pendingOwner`).to.not.equal( - MIGRATION_HELPER_V2.toLowerCase(), - ); + expect((await ownable(b).owner()).toLowerCase(), `${b} owner`).to.equal(MIGRATION_HELPER_V2.toLowerCase()); } }); @@ -359,5 +343,15 @@ forking(FORK_BLOCK, async () => { 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-800/bscmainnet-part-2.ts b/simulations/vip-800/bscmainnet-part-2.ts index d76607c03..94d5ab0d0 100644 --- a/simulations/vip-800/bscmainnet-part-2.ts +++ b/simulations/vip-800/bscmainnet-part-2.ts @@ -5,11 +5,19 @@ import { NETWORK_ADDRESSES } from "src/networkAddresses"; import { initMainnetUser } from "src/utils"; import { forking, pretendExecutingVip, testVip } from "src/vip-framework"; -import HELPER_V3_ARTIFACT from "../../artifacts/contracts/helpers/TokenBuybackMigrationHelper.sol/TokenBuybackMigrationHelper.json"; 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, @@ -26,17 +34,31 @@ import vip800Part1, { import vip800Part2 from "../../vips/vip-800/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; -// Real-env block: V2 helper (MIGRATION_HELPER_V2) is on chain. -// Match part-1 sim's FORK_BLOCK. -const FORK_BLOCK = 97988051; +// 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 = 98041000; -const HELPER_MIN_ABI = ["function executed1() view returns (bool)", "function executed2() view returns (bool)"]; +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 }, @@ -48,10 +70,11 @@ const DRAIN_BY_CONVERTER: { converter: string; recipient: string }[] = [ 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, HELPER_MIN_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 @@ -60,18 +83,11 @@ forking(FORK_BLOCK, async () => { const converterBalanceAfterPart1 = new Map(); before(async () => { - // Etch the freshly compiled V3 helper bytecode at MIGRATION_HELPER_V2 - // (mirrors part-1 sim). - await ethers.provider.send("hardhat_setCode", [ - MIGRATION_HELPER_V2, - (HELPER_V3_ARTIFACT as { deployedBytecode: string }).deployedBytecode, - ]); - // Pre-condition the deploy script will fulfil on chain: every buyback's // pendingOwner must point at MIGRATION_HELPER_V2 so part-1's // helper.execute1() can accept ownership. At the fork block buybacks may - // still point at the V1 helper, so re-point each one via impersonation. - // Idempotent: skips buybacks already pointed at V2. + // still point at the previous helper, so re-point each one via + // impersonation. Idempotent: skips buybacks already pointed at V2. for (const b of BUYBACKS) { const buybackOwnable = new ethers.Contract(b, OWNABLE_MIN_ABI, ethers.provider); const currentPending = await buybackOwnable.pendingOwner(); @@ -83,8 +99,8 @@ forking(FORK_BLOCK, async () => { await buybackAsOwner.transferOwnership(MIGRATION_HELPER_V2); } - // Apply part-1 from NormalTimelock so helper.execute1() passes the - // `msg.sender == NORMAL_TIMELOCK` gate. + // Apply part-1 from NormalTimelock so helper.execute1() / executeSwap() + // pass the `msg.sender == NORMAL_TIMELOCK` gate. await pretendExecutingVip(await vip800Part1(), bscmainnet.NORMAL_TIMELOCK); for (const d of DRAIN_BY_CONVERTER) { @@ -100,14 +116,23 @@ forking(FORK_BLOCK, async () => { }); describe("Pre-VIP state (part 2, after part-1 has executed)", () => { - it("helper.executed1 is true and executed2 is false", async () => { + 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 each timelock-owned legacy converter", async () => { - for (const c of TIMELOCK_OWNED_CONVERTERS) { - expect((await ownable(c).owner()).toLowerCase()).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + 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()).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; + } } }); @@ -116,7 +141,7 @@ forking(FORK_BLOCK, async () => { }); }); - testVip("VIP-800 part 2 — drain converters and hand back ownership", await vip800Part2()); + testVip("VIP-800 part 2 — router allowlist, drain, and hand back ownership", await vip800Part2()); describe("Post-VIP state (part 2)", () => { it("helper.executed2 is true; second execute2() reverts", async () => { @@ -126,6 +151,14 @@ forking(FORK_BLOCK, async () => { await expect(helperWithExecute2.connect(timelockSigner).execute2()).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) { @@ -136,8 +169,8 @@ forking(FORK_BLOCK, async () => { 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 (e.g. four PrimeConverters - // all flow to U_PRIME_BUYBACK). + // 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) { @@ -156,15 +189,14 @@ forking(FORK_BLOCK, async () => { } }); - it("NormalTimelock owns each timelock-owned legacy converter", async () => { - for (const c of TIMELOCK_OWNED_CONVERTERS) { - expect((await ownable(c).owner()).toLowerCase()).to.equal(bscmainnet.NORMAL_TIMELOCK.toLowerCase()); + 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 () => { - const targets = [...BUYBACKS, ...TIMELOCK_OWNED_CONVERTERS]; - for (const t of targets) { + 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(), diff --git a/vips/vip-618/bscmainnet.ts b/vips/vip-618/bscmainnet.ts index 39cfaa0e1..5fc4273fc 100644 --- a/vips/vip-618/bscmainnet.ts +++ b/vips/vip-618/bscmainnet.ts @@ -164,16 +164,16 @@ export const TIMELOCK_OWNED_CONVERTERS: string[] = [ ]; // ===== New TokenBuyback proxies (10 instances) ===== -export const RISK_FUND_BUYBACK = "0xfffB20c23650B27126815994f3F07eF6B46aea60"; -export const USDT_PRIME_BUYBACK = "0x0191Bb3CD28A96691F5EC5066ad42A0373ae11C6"; -export const U_PRIME_BUYBACK = "0xFd50bd4107705929df73Ac683BD505232BA9E9dB"; -export const XVS_BUYBACK = "0xBaAc819aE93b29fA6512a095CA00255a4F05b027"; -export const U_TREASURY_BUYBACK = "0xef7cb42a7EBD4b011905D20Fc8038a603c3f22E4"; -export const BTCB_TREASURY_BUYBACK = "0x69739FF52e90BC93dCaEd5a2431072b5082d326D"; -export const ETH_TREASURY_BUYBACK = "0x9e0543F9E09fb5b8a58F73d11967DC894dbD40a7"; -export const USDT_TREASURY_BUYBACK = "0xBF858c95D778022b48E6Ad343D3d644017fb0ca7"; -export const USDC_TREASURY_BUYBACK = "0xFB5FA544dBf39983198BDD01e2c26E3AB597e22A"; -export const XVS_TREASURY_BUYBACK = "0x01D0f07D389692D386EB8D09Da3bbCa5C83be551"; +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, @@ -214,13 +214,18 @@ const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; // the new TokenBuyback flow once the cron starts. export const PANCAKE_V3_FEE_TIER = 100; -// PLP holds ~14,987 USDC at the snapshot block; sweep 14,986 USDC and split -// 7,493 USDC per leg, leaving ~1 USDC of dust in PLP. -export const USDC_TO_SWEEP = parseUnits("14986", 18); +// PLP holds ~14.9k USDC at the snapshot block; ~4k of that is reserved for +// unclaimed user rewards so the VIP sweeps 10k into the V2 helper. PLP already +// holds ~25k USDT — enough for the May 2026 distribution — so the helper now +// runs a single USDC -> USDT -> U multihop instead of two legs. +export const USDC_TO_SWEEP = parseUnits("10000", 18); +// Legacy two-leg constants kept for the (unexecutable) vip-618 wiring below; +// the V2 helper hardcodes USDC_TO_SWAP = 10_000e18 and U_MIN_OUT = 9_900e18 +// directly and ignores these. export const USDC_PER_LEG = parseUnits("7493", 18); -// 1% slippage floor on each stable/stable leg. export const USDT_MIN_OUT = parseUnits("7418", 18); -export const U_MIN_OUT = parseUnits("7418", 18); +// 1% slippage floor under the 9,996.60 U quote (QuoterV2, 2026-05-13). +export const U_MIN_OUT = parseUnits("9900", 18); // Prime multipliers for vU (matches USDT/USDC supply-only convention). export const SUPPLY_MULTIPLIER = parseUnits("2", 18); diff --git a/vips/vip-800/bscmainnet-part-1.ts b/vips/vip-800/bscmainnet-part-1.ts index 5d775f869..eada9172a 100644 --- a/vips/vip-800/bscmainnet-part-1.ts +++ b/vips/vip-800/bscmainnet-part-1.ts @@ -4,20 +4,29 @@ import { ProposalType } from "src/types"; import { makeProposal } from "src/utils"; import { + BORROW_MULTIPLIER, BUYBACKS, + 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_BUYBACK, RISK_FUND_V2, SHORTFALL, + SUPPLY_MULTIPLIER, TIMELOCK_OWNED_CONVERTERS, U, USDC, + USDC_TO_SWEEP, USDT, + U_MAX_DISTRIBUTION_SPEED, U_PRIME_BUYBACK, + VU, XVS_BUYBACK, } from "../vip-618/bscmainnet"; @@ -43,36 +52,21 @@ export { XVS_BUYBACK, }; -// ===== TokenBuyback migration helper V2 (execute1 / execute2 split) ===== -// V1 helper at 0x352d2188A5C838854B8565dCD88cD3c9c996e83A is unexecutable: its -// single execute() needs ~17.5M gas, exceeding BSC's Osaka per-tx cap of -// 16,777,216 (2^24). V2 splits the migration into: -// execute1() — every step except draining the 6 timelock-owned converters. -// Includes the May 2026 Prime rewards allocation (Shortfall -// pause, vU addMarket, PLP init/sweep/swap/speeds) folded in -// after the PSR rewire. Swap legs and the final -// setTokensDistributionSpeed are soft-fail (try/catch), so a -// thin-pool revert can't unwind the migration core. Any -// leftover USDC is forwarded back to NormalTimelock. -// execute2() — only the converter drain + handBack of the 6 timelock-owned -// converters (called by vip-800 part 2). -// The 10 buyback proxies' pendingOwner must be re-pointed to this V2 address -// by the deployer (off-chain transferOwnership from the current owner) before -// this VIP executes. The 6 timelock-owned converters keep this helper as their -// owner across both VIPs; ownership returns to NormalTimelock at the end of -// part 2. +// 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 = "0xa30fcE7A72aD101f6afd4D8b89D1AD8687f51cb0"; +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 -// transient ACM permissions and renounce them at the end of the call. +// 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; -// Helper.execute1() — non-drain phase of the migration. Includes the May 2026 -// Prime rewards allocation block (soft-fail on swap legs and final -// setTokensDistributionSpeed). Drain happens in execute2() (vip-800 part 2). const HELPER_EXECUTE1_SIG = "execute1()"; +const HELPER_EXECUTE_SWAP_SIG = "executeSwap()"; export const VIP_NUMBER = "vip-800-part-1"; @@ -84,32 +78,36 @@ export const vip800Part1 = () => { Replaces VIP-618 (unexecutable on-chain because its single helper.execute() exceeds BSC's Osaka per-tx gas cap of 16,777,216). VIP-800 splits the migration into two proposals: -- **Part 1 (this VIP)**: every migration step except draining the 6 timelock-owned converters. The May 2026 Prime Rewards Allocation is folded inside helper.execute1(); swap legs and the final setTokensDistributionSpeed are soft-fail to insulate the migration core from thin-pool reverts. -- **Part 2 (vip-800-part-2)**: the converter drain and the final return of converter ownership to NormalTimelock. +- **Part 1 (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-800-part-2)**: 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) and PSR is already repointed away from them, so balances are frozen and there is no economic surface from them. +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 it can self-grant the transient ACM permissions it needs (pauseConversion per converter, addOrUpdateDistributionConfigs, removeDistributionConfig, Shortfall.pauseAuctions, Prime.addMarket, PLP initialize/setMaxSpeed/setSpeed/sweepToken). -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 the helper accepts them inside execute1() without an intermediate NormalTimelock claim. -3. **helper.execute1()** — runs every non-drain step: +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). - - Allowlists 9 swap routers on every buyback (PancakeSwap V2 / V3 / Smart / Universal, Uniswap V2 SwapRouter02 / V3 SwapRouter02 / V4 / Universal, 1inch v5). - - Grants executeBuyback and forwardBaseAsset ACM permissions to the cron operator on every buyback. - - Calls pauseConversion() on every timelock-owned converter. - - Repoints ProtocolShareReserve distributions: 18 new buyback rows added and 12 stale rows zeroed in a sequence that preserves the per-schema percentage invariant (1e4 or 0) at every checkpoint. - - Runs the May 2026 Prime allocation: Shortfall.pauseAuctions, Prime.addMarket(vU), PLP.initializeTokens([U]), PLP.setMaxTokensDistributionSpeed([U],[1e18]), PLP.sweepToken(USDC, helper, 14986e18), router approve, swap USDC -> USDT and USDC -> U via PancakeSwap V3 (both wrapped in try/catch — failure emits StepFailed and the migration continues), reset router allowance, forward leftover USDC back to NormalTimelock, PLP.setTokensDistributionSpeed([USDT, U], [...]) (also try/catch). - - Transfers ownership of the **10 buybacks** back to NormalTimelock (the 6 converters stay helper-owned until part 2). + - 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. -4. **Accept ownership** of the 10 buybacks returned by the helper. + - 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 this repository (deployed to bscmainnet at the address above). Implementation of the new RiskFundV2: [VenusProtocol/protocol-reserve PR #158](https://github.com/VenusProtocol/protocol-reserve/pull/158). +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). Splitting drain into execute2() drops part 1 below the cap; part 2 is small enough on its own. vip-framework asserts the cap inside testVip so future violations fail in CI rather than at execute time on chain.`, +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", @@ -125,30 +123,84 @@ BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The or }, // 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() — non-drain phase + Prime allocation. + // 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. Accept ownership of the 10 buybacks handed back by the helper. - // The 6 converters stay helper-owned until vip-800-part-2 runs execute2(). - ...BUYBACKS.map(a => ({ - target: a, - signature: "acceptOwnership()", + // 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. + // 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)", diff --git a/vips/vip-800/bscmainnet-part-2.ts b/vips/vip-800/bscmainnet-part-2.ts index 01271fb63..9142754c3 100644 --- a/vips/vip-800/bscmainnet-part-2.ts +++ b/vips/vip-800/bscmainnet-part-2.ts @@ -1,10 +1,9 @@ import { ProposalType } from "src/types"; import { makeProposal } from "src/utils"; -import { TIMELOCK_OWNED_CONVERTERS } from "../vip-618/bscmainnet"; -import { MIGRATION_HELPER_V2 } from "./bscmainnet-part-1"; +import { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS } from "./bscmainnet-part-1"; -export { MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; +export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; const HELPER_EXECUTE2_SIG = "execute2()"; @@ -13,35 +12,40 @@ export const VIP_NUMBER = "vip-800-part-2"; export const vip800Part2 = () => { const meta = { version: "v2", - title: "VIP-800 [BNB Chain] TokenBuyback Migration Part 2 (drain + converter handback)", + title: "VIP-800 [BNB Chain] TokenBuyback Migration Part 2 (router allowlist + drain + handback)", description: `#### Summary -Final step of the VIP-800 TokenBuyback migration. Drains every non-zero ERC20 balance from the 6 timelock-owned legacy converters into the corresponding new buyback proxies and returns converter ownership to NormalTimelock. +Final step of the VIP-800 TokenBuyback migration. 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-800-part-1, which: - Granted the V2 helper DEFAULT_ADMIN_ROLE on the ACM (renounced at end of execute1) and converter ownership. -- Paused every timelock-owned converter (no inbound conversion since pause). +- 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). -- Returned ownership of the 10 buyback proxies to NormalTimelock. +- 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()** — drains the 6 timelock-owned converters and transfers ownership of each back to NormalTimelock: - - 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 -2. **Accept ownership** of the 6 converters returned by the helper. +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. Both execute1() and execute2() revert AlreadyExecuted on any subsequent call. +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). Splitting drain into execute2() drops part 1 below the cap; this part 2 contains only the drain plus 6 acceptOwnership() calls and is small enough on its own.`, +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", @@ -49,16 +53,19 @@ BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The or return makeProposal( [ - // 1. helper.execute2() — drain + converter handback. + // 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 6 converters returned by the helper. - ...TIMELOCK_OWNED_CONVERTERS.map(c => ({ - target: c, + // 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: [], })), From 18e512d7fb3f5abd572d36b3ce9895b2275ba1fc Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 17:55:56 +0530 Subject: [PATCH 4/9] feat(vip-800): enforce pendingOwner pre-condition --- simulations/vip-800/bscmainnet-part-1.ts | 31 +++++++++++++++--------- simulations/vip-800/bscmainnet-part-2.ts | 27 +++++++++++---------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/simulations/vip-800/bscmainnet-part-1.ts b/simulations/vip-800/bscmainnet-part-1.ts index 50087e227..20f79b3b7 100644 --- a/simulations/vip-800/bscmainnet-part-1.ts +++ b/simulations/vip-800/bscmainnet-part-1.ts @@ -52,7 +52,7 @@ const { bscmainnet } = NETWORK_ADDRESSES; // (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 = 98041000; +const FORK_BLOCK = 98045598; const SHORTFALL_MIN_ABI = ["function auctionsPaused() view returns (bool)"]; const CONVERTER_MIN_ABI = ["function conversionPaused() view returns (bool)"]; @@ -130,18 +130,27 @@ forking(FORK_BLOCK, async () => { const converterBalanceBefore = new Map(); before(async () => { - // Pre-condition the deploy script will fulfil on chain: every buyback's - // pendingOwner must point at MIGRATION_HELPER_V2 so helper.execute1() can - // accept ownership. At the fork block buybacks still point at the previous - // helper, so impersonate the current owner and re-point each one. + // 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 currentPending = await buybackOwnable.pendingOwner(); - if (currentPending.toLowerCase() === MIGRATION_HELPER_V2.toLowerCase()) continue; - const currentOwner = await buybackOwnable.owner(); - const ownerSigner = await initMainnetUser(currentOwner, ethers.utils.parseEther("1")); - const buybackAsOwner = new ethers.Contract(b, ["function transferOwnership(address)"], ownerSigner); - await buybackAsOwner.transferOwnership(MIGRATION_HELPER_V2); + 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-800 part-1 is queued.`, + ); + } } riskFundV2UsdtBalanceBefore = await usdt.balanceOf(RISK_FUND_V2); diff --git a/simulations/vip-800/bscmainnet-part-2.ts b/simulations/vip-800/bscmainnet-part-2.ts index 94d5ab0d0..36b2aa8b1 100644 --- a/simulations/vip-800/bscmainnet-part-2.ts +++ b/simulations/vip-800/bscmainnet-part-2.ts @@ -41,7 +41,7 @@ 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 = 98041000; +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)"]; @@ -83,20 +83,21 @@ forking(FORK_BLOCK, async () => { const converterBalanceAfterPart1 = new Map(); before(async () => { - // Pre-condition the deploy script will fulfil on chain: every buyback's - // pendingOwner must point at MIGRATION_HELPER_V2 so part-1's - // helper.execute1() can accept ownership. At the fork block buybacks may - // still point at the previous helper, so re-point each one via - // impersonation. Idempotent: skips buybacks already pointed at V2. + // Production pre-condition (enforced — no fork-only impersonation patch): + // every buyback proxy's pendingOwner must already point at + // MIGRATION_HELPER_V2. Same off-chain step as part-1; mirrored here because + // part-2's `pretendExecutingVip(part1)` 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 currentPending = await buybackOwnable.pendingOwner(); - if (currentPending.toLowerCase() === MIGRATION_HELPER_V2.toLowerCase()) continue; - - const currentOwner = await buybackOwnable.owner(); - const ownerSigner = await initMainnetUser(currentOwner, ethers.utils.parseEther("1")); - const buybackAsOwner = new ethers.Contract(b, ["function transferOwnership(address)"], ownerSigner); - await buybackAsOwner.transferOwnership(MIGRATION_HELPER_V2); + 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-800 part-1 is queued.`, + ); + } } // Apply part-1 from NormalTimelock so helper.execute1() / executeSwap() From f5a7e4425dc03d55f167e0140e5fa93073c35445 Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 18:06:12 +0530 Subject: [PATCH 5/9] refactor(vip-framework): fix per-tx cap reporting and EIP refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pretendExecutingVip runs each cmd as its own tx, so cap applies per-cmd not to sum. Log per-cmd max + warn on over-cap cmds. Fix EIP-8123 → EIP-7825. Drop dead re-export (no prior callers). --- src/utils.ts | 6 +++--- src/vip-framework/index.ts | 37 ++++++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 9a5937240..84da07f85 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -86,11 +86,11 @@ const _perTxGasCapCache = new Map(); /// Resolve the per-tx gas cap that the simulation should mirror. /// /// Resolution order: -/// 1. EIP-8123 `eth_txGasLimitCap` RPC (auto picks up future hardforks the +/// 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-8123 yet). +/// (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 @@ -109,7 +109,7 @@ export const resolvePerTxGasCap = async (forkedNetwork: string | undefined): Pro const parsed = BigNumber.from(raw).toNumber(); if (parsed > 0) { cap = parsed; - source = "eth_txGasLimitCap (EIP-8123)"; + source = "eth_txGasLimitCap (EIP-7825)"; } } } catch { diff --git a/src/vip-framework/index.ts b/src/vip-framework/index.ts index cd98f2426..82c65f57b 100644 --- a/src/vip-framework/index.ts +++ b/src/vip-framework/index.ts @@ -7,7 +7,7 @@ import { BigNumber, Contract, ContractInterface } from "ethers"; import { FORKED_NETWORK, ethers } from "hardhat"; import { NETWORK_ADDRESSES } from "../networkAddresses"; -import { NETWORK_CONFIG, PER_TX_GAS_CAP_2_24, PER_TX_GAS_CAP_BY_NETWORK } from "../networkConfig"; +import { NETWORK_CONFIG } from "../networkConfig"; import { Proposal, ProposalType, REMOTE_NETWORKS, SUPPORTED_NETWORKS } from "../types"; import { calculateGasForAdapterParam, @@ -46,15 +46,6 @@ const OMNICHAIN_GOVERNANCE_EXECUTOR = // New voting period: 115,200 * 1.67 = 192,384 const VOTING_PERIOD = 192384; -// Per-tx gas cap source: see `PER_TX_GAS_CAP_BY_NETWORK` in -// `src/networkConfig.ts` for the static fallback table, and -// `resolvePerTxGasCap` in `src/utils.ts` for the runtime resolver that prefers -// EIP-8123 (`eth_txGasLimitCap`) when supported. -// -// Re-exported here for backwards compatibility with any caller that imported -// from `src/vip-framework/index.ts` before the move. -export { PER_TX_GAS_CAP_2_24, PER_TX_GAS_CAP_BY_NETWORK }; - export const { DEFAULT_PROPOSER_ADDRESS, GOVERNOR_PROXY, @@ -135,23 +126,39 @@ 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 cap = await resolvePerTxGasCap(FORKED_NETWORK); - const capSuffix = Number.isFinite(cap) - ? ` (${totalGas.mul(10000).div(cap).toNumber() / 100}% of ${FORKED_NETWORK} per-tx cap ${cap})` + 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 totalGasUsed=${totalGas.toString()} across ${proposal.signatures.length} ` + - `commands${capSuffix}`, + `[gas] pretendExecutingVip ${proposal.signatures.length} commands, ` + + `maxCmdGasUsed=${maxCmdGas.toString()} (cmd[${maxCmdIdx}])${maxSuffix}, ` + + `totalGasUsed=${totalGas.toString()}`, ); return txResponses; }; From d45338a0830f6da56d5319b97ee56bce98fc40a9 Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 18:16:40 +0530 Subject: [PATCH 6/9] fix(vip-618): revert mutation; host redeploy constants in vip-800 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore vip-618's original 10 buyback addresses, USDC_TO_SWEEP=14,986 and U_MIN_OUT=7,418 — the VIP is queued on-chain and must match what governance audited. - Define the redeployed buyback addresses (PR #162) and retuned single-multihop budget (USDC_TO_SWEEP=10,000, U_MIN_OUT=9,900 under the 9,996.60 U QuoterV2 read) locally in vip-800/part-1. - BUYBACKS index order preserved so PSR-row → buyback mapping survives. - vip-800/part-1 keeps re-exporting the untouched vip-618 constants so sims have a single import surface. --- vips/vip-618/bscmainnet.ts | 35 +++++++++------------ vips/vip-800/bscmainnet-part-1.ts | 52 +++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/vips/vip-618/bscmainnet.ts b/vips/vip-618/bscmainnet.ts index 5fc4273fc..39cfaa0e1 100644 --- a/vips/vip-618/bscmainnet.ts +++ b/vips/vip-618/bscmainnet.ts @@ -164,16 +164,16 @@ export const TIMELOCK_OWNED_CONVERTERS: string[] = [ ]; // ===== New TokenBuyback proxies (10 instances) ===== -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 RISK_FUND_BUYBACK = "0xfffB20c23650B27126815994f3F07eF6B46aea60"; +export const USDT_PRIME_BUYBACK = "0x0191Bb3CD28A96691F5EC5066ad42A0373ae11C6"; +export const U_PRIME_BUYBACK = "0xFd50bd4107705929df73Ac683BD505232BA9E9dB"; +export const XVS_BUYBACK = "0xBaAc819aE93b29fA6512a095CA00255a4F05b027"; +export const U_TREASURY_BUYBACK = "0xef7cb42a7EBD4b011905D20Fc8038a603c3f22E4"; +export const BTCB_TREASURY_BUYBACK = "0x69739FF52e90BC93dCaEd5a2431072b5082d326D"; +export const ETH_TREASURY_BUYBACK = "0x9e0543F9E09fb5b8a58F73d11967DC894dbD40a7"; +export const USDT_TREASURY_BUYBACK = "0xBF858c95D778022b48E6Ad343D3d644017fb0ca7"; +export const USDC_TREASURY_BUYBACK = "0xFB5FA544dBf39983198BDD01e2c26E3AB597e22A"; +export const XVS_TREASURY_BUYBACK = "0x01D0f07D389692D386EB8D09Da3bbCa5C83be551"; export const BUYBACKS: string[] = [ RISK_FUND_BUYBACK, @@ -214,18 +214,13 @@ const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; // the new TokenBuyback flow once the cron starts. export const PANCAKE_V3_FEE_TIER = 100; -// PLP holds ~14.9k USDC at the snapshot block; ~4k of that is reserved for -// unclaimed user rewards so the VIP sweeps 10k into the V2 helper. PLP already -// holds ~25k USDT — enough for the May 2026 distribution — so the helper now -// runs a single USDC -> USDT -> U multihop instead of two legs. -export const USDC_TO_SWEEP = parseUnits("10000", 18); -// Legacy two-leg constants kept for the (unexecutable) vip-618 wiring below; -// the V2 helper hardcodes USDC_TO_SWAP = 10_000e18 and U_MIN_OUT = 9_900e18 -// directly and ignores these. +// PLP holds ~14,987 USDC at the snapshot block; sweep 14,986 USDC and split +// 7,493 USDC per leg, leaving ~1 USDC of dust in PLP. +export const USDC_TO_SWEEP = parseUnits("14986", 18); export const USDC_PER_LEG = parseUnits("7493", 18); +// 1% slippage floor on each stable/stable leg. export const USDT_MIN_OUT = parseUnits("7418", 18); -// 1% slippage floor under the 9,996.60 U quote (QuoterV2, 2026-05-13). -export const U_MIN_OUT = parseUnits("9900", 18); +export const U_MIN_OUT = parseUnits("7418", 18); // Prime multipliers for vU (matches USDT/USDC supply-only convention). export const SUPPLY_MULTIPLIER = parseUnits("2", 18); diff --git a/vips/vip-800/bscmainnet-part-1.ts b/vips/vip-800/bscmainnet-part-1.ts index eada9172a..7c825c45e 100644 --- a/vips/vip-800/bscmainnet-part-1.ts +++ b/vips/vip-800/bscmainnet-part-1.ts @@ -1,11 +1,14 @@ 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-800 imports only the values that survived the redeploy unchanged +// and redefines the buyback addresses and swap-budget constants locally below. import { BORROW_MULTIPLIER, - BUYBACKS, CORE_COMPTROLLER, CORE_TOKENS, DEFAULT_PROXY_ADMIN, @@ -15,41 +18,72 @@ import { PRIME, PRIME_LIQUIDITY_PROVIDER, PROTOCOL_SHARE_RESERVE, - RISK_FUND_BUYBACK, RISK_FUND_V2, SHORTFALL, SUPPLY_MULTIPLIER, TIMELOCK_OWNED_CONVERTERS, U, USDC, - USDC_TO_SWEEP, USDT, U_MAX_DISTRIBUTION_SPEED, - U_PRIME_BUYBACK, VU, - XVS_BUYBACK, } 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-800 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 { - BUYBACKS, CORE_TOKENS, DEFAULT_PROXY_ADMIN, NEW_RISK_FUND_V2_IMPL, PRIME_LIQUIDITY_PROVIDER, PROTOCOL_SHARE_RESERVE, - RISK_FUND_BUYBACK, RISK_FUND_V2, SHORTFALL, TIMELOCK_OWNED_CONVERTERS, U, USDC, USDT, - U_PRIME_BUYBACK, - XVS_BUYBACK, }; // Latest TokenBuybackMigrationHelper redeploy (protocol-reserve PR #164, From 51c6a10472b62dc21887468fa25b7da0126bf5d4 Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 18:16:53 +0530 Subject: [PATCH 7/9] feat(vip-800): enhance tests for migration helper ownership and token balances --- simulations/vip-800/bscmainnet-part-1.ts | 72 +++++++++++++++++------- simulations/vip-800/bscmainnet-part-2.ts | 25 +++++++- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/simulations/vip-800/bscmainnet-part-1.ts b/simulations/vip-800/bscmainnet-part-1.ts index 20f79b3b7..ecbb4641b 100644 --- a/simulations/vip-800/bscmainnet-part-1.ts +++ b/simulations/vip-800/bscmainnet-part-1.ts @@ -6,12 +6,17 @@ 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, @@ -31,7 +36,9 @@ import vip800Part1, { TIMELOCK_OWNED_CONVERTERS, U, USDC, + USDC_TO_SWEEP, USDT, + U_MIN_OUT, U_PRIME_BUYBACK, XVS_BUYBACK, } from "../../vips/vip-800/bscmainnet-part-1"; @@ -42,10 +49,6 @@ 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"; -// Re-export so part-2 sim can pick up the same address universe without -// duplicating imports. -export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; - const { bscmainnet } = NETWORK_ADDRESSES; // Fork block must be past the latest TokenBuybackMigrationHelper redeploy @@ -171,13 +174,19 @@ forking(FORK_BLOCK, async () => { 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()).to.equal(MIGRATION_HELPER_V2); + 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()).to.be.false; + expect(await converter(c).conversionPaused(), c).to.be.false; } }); @@ -191,6 +200,11 @@ forking(FORK_BLOCK, 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; @@ -199,6 +213,10 @@ forking(FORK_BLOCK, async () => { 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-800 part 1 — non-drain migration & May Prime allocation", await vip800Part1()); @@ -216,13 +234,13 @@ forking(FORK_BLOCK, async () => { it("helper still owns each buyback (handback deferred to part 2)", async () => { for (const b of BUYBACKS) { - expect(await ownable(b).owner()).to.equal(MIGRATION_HELPER_V2); + 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()).to.equal(MIGRATION_HELPER_V2); + expect((await ownable(c).owner()).toLowerCase(), c).to.equal(MIGRATION_HELPER_V2.toLowerCase()); } }); @@ -235,7 +253,9 @@ forking(FORK_BLOCK, async () => { }); it("RiskFundV2 proxy upgraded to new implementation", async () => { - expect(await proxyAdmin.getProxyImplementation(RISK_FUND_V2)).to.equal(NEW_RISK_FUND_V2_IMPL); + 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 () => { @@ -300,37 +320,51 @@ forking(FORK_BLOCK, async () => { }); describe("Post-VIP Prime allocation", () => { - it("vU is registered as a Prime market", async () => { + it("vU is registered as a Prime market with the expected multipliers", async () => { const m = await prime.markets(VU); - expect(m.exists).to.be.true; + 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 USDC balance decreased (VIP swept USDC into the helper for swaps)", async () => { + 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(after).to.be.lt(plpUsdcBalanceBefore); + expect(plpUsdcBalanceBefore.sub(after)).to.equal(USDC_TO_SWEEP); }); - it("PLP USDT balance is unchanged (USDT swap leg was dropped — PLP already holds enough)", async () => { + 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 non-decreasing (USDC -> USDT -> U multihop is soft-fail)", async () => { + 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).to.be.gte(plpUBalanceBefore); + expect(after.sub(plpUBalanceBefore)).to.be.gte(U_MIN_OUT); }); - it("Helper holds zero USDC after executeSwap (leftover forwarded back to NormalTimelock)", async () => { + it("Helper holds zero USDC after executeSwap", async () => { expect(await usdc.balanceOf(MIGRATION_HELPER_V2)).to.equal(0); }); - it("NormalTimelock USDC balance is non-decreasing (helper forwards leftover here)", async () => { + 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.be.gte(timelockUsdcBalanceBefore); + expect(after).to.equal(timelockUsdcBalanceBefore); }); }); diff --git a/simulations/vip-800/bscmainnet-part-2.ts b/simulations/vip-800/bscmainnet-part-2.ts index 36b2aa8b1..a3ef8a639 100644 --- a/simulations/vip-800/bscmainnet-part-2.ts +++ b/simulations/vip-800/bscmainnet-part-2.ts @@ -5,6 +5,9 @@ import { NETWORK_ADDRESSES } from "src/networkAddresses"; import { initMainnetUser } from "src/utils"; import { forking, pretendExecutingVip, testVip } from "src/vip-framework"; +// Prefer vip-800/bscmainnet-part-1 for anything it exports; fall back to the +// frozen vip-618 only for what vip-800 does not re-export (legacy converters +// and the 9 swap routers). import { BTCB_PRIME_CONVERTER, ETH_PRIME_CONVERTER, @@ -125,7 +128,7 @@ forking(FORK_BLOCK, async () => { 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()).to.equal(MIGRATION_HELPER_V2.toLowerCase()); + expect((await ownable(a).owner()).toLowerCase(), a).to.equal(MIGRATION_HELPER_V2.toLowerCase()); } }); @@ -140,6 +143,15 @@ forking(FORK_BLOCK, async () => { 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-800 part 2 — router allowlist, drain, and hand back ownership", await vip800Part2()); @@ -152,6 +164,17 @@ forking(FORK_BLOCK, async () => { 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) { From 33bf176c935ff97f843e7591175b0d5e5f20f39f Mon Sep 17 00:00:00 2001 From: Debugger022 Date: Wed, 13 May 2026 18:56:05 +0530 Subject: [PATCH 8/9] test(vip-800): apply part-1 via testVip in part-2 sim setup Run part-1 through full governance flow (propose/vote/execute) instead of direct timelock impersonation via pretendExecutingVip, so part-2 sees the same post-part-1 state mainnet will. Each testVip uses its own loadFixture function, so part-2's first snapshot captures post-part-1 state. Balances now captured in callbackAfterExecution. --- simulations/vip-800/bscmainnet-part-2.ts | 37 ++++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/simulations/vip-800/bscmainnet-part-2.ts b/simulations/vip-800/bscmainnet-part-2.ts index a3ef8a639..4ef952a4e 100644 --- a/simulations/vip-800/bscmainnet-part-2.ts +++ b/simulations/vip-800/bscmainnet-part-2.ts @@ -3,7 +3,7 @@ import { BigNumber } from "ethers"; import { ethers } from "hardhat"; import { NETWORK_ADDRESSES } from "src/networkAddresses"; import { initMainnetUser } from "src/utils"; -import { forking, pretendExecutingVip, testVip } from "src/vip-framework"; +import { forking, testVip } from "src/vip-framework"; // Prefer vip-800/bscmainnet-part-1 for anything it exports; fall back to the // frozen vip-618 only for what vip-800 does not re-export (legacy converters @@ -88,9 +88,8 @@ forking(FORK_BLOCK, async () => { before(async () => { // Production pre-condition (enforced — no fork-only impersonation patch): // every buyback proxy's pendingOwner must already point at - // MIGRATION_HELPER_V2. Same off-chain step as part-1; mirrored here because - // part-2's `pretendExecutingVip(part1)` re-enters helper.execute1() which - // calls acceptOwnership() on every buyback. + // 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(); @@ -102,21 +101,27 @@ forking(FORK_BLOCK, async () => { ); } } + }); - // Apply part-1 from NormalTimelock so helper.execute1() / executeSwap() - // pass the `msg.sender == NORMAL_TIMELOCK` gate. - await pretendExecutingVip(await vip800Part1(), bscmainnet.NORMAL_TIMELOCK); - - 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)); + // 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-800 part 1 (setup for part-2 sim)", await vip800Part1(), { + 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)); } - 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)", () => { From 1bce090dcbffd39e77dc0567722b351736b11091 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 13 May 2026 15:14:19 +0000 Subject: [PATCH 9/9] chore: rename vip-800 -> vip-620/vip-621 for on-chain numbering proposalCount is 619; the two-part TokenBuyback migration now lands as VIP-620 (part 1) and VIP-621 (part 2). Directory stays as vips/vip-620/ to keep both parts together with the shared simulation ABI. --- .../abi/TokenBuybackMigrationHelper.json | 0 .../{vip-800 => vip-620}/bscmainnet-part-1.ts | 8 ++++---- .../{vip-800 => vip-620}/bscmainnet-part-2.ts | 16 ++++++++-------- vips/{vip-800 => vip-620}/bscmainnet-part-1.ts | 18 +++++++++--------- vips/{vip-800 => vip-620}/bscmainnet-part-2.ts | 12 ++++++------ 5 files changed, 27 insertions(+), 27 deletions(-) rename simulations/{vip-800 => vip-620}/abi/TokenBuybackMigrationHelper.json (100%) rename simulations/{vip-800 => vip-620}/bscmainnet-part-1.ts (98%) rename simulations/{vip-800 => vip-620}/bscmainnet-part-2.ts (95%) rename vips/{vip-800 => vip-620}/bscmainnet-part-1.ts (91%) rename vips/{vip-800 => vip-620}/bscmainnet-part-2.ts (87%) diff --git a/simulations/vip-800/abi/TokenBuybackMigrationHelper.json b/simulations/vip-620/abi/TokenBuybackMigrationHelper.json similarity index 100% rename from simulations/vip-800/abi/TokenBuybackMigrationHelper.json rename to simulations/vip-620/abi/TokenBuybackMigrationHelper.json diff --git a/simulations/vip-800/bscmainnet-part-1.ts b/simulations/vip-620/bscmainnet-part-1.ts similarity index 98% rename from simulations/vip-800/bscmainnet-part-1.ts rename to simulations/vip-620/bscmainnet-part-1.ts index ecbb4641b..f81b7bf0c 100644 --- a/simulations/vip-800/bscmainnet-part-1.ts +++ b/simulations/vip-620/bscmainnet-part-1.ts @@ -22,7 +22,7 @@ import { WBNB_BURN_CONVERTER, XVS_VAULT_CONVERTER, } from "../../vips/vip-618/bscmainnet"; -import vip800Part1, { +import vip620, { BUYBACKS, CORE_TOKENS, DEFAULT_PROXY_ADMIN, @@ -41,7 +41,7 @@ import vip800Part1, { U_MIN_OUT, U_PRIME_BUYBACK, XVS_BUYBACK, -} from "../../vips/vip-800/bscmainnet-part-1"; +} 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"; @@ -151,7 +151,7 @@ forking(FORK_BLOCK, async () => { 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-800 part-1 is queued.`, + `transferOwnership(${MIGRATION_HELPER_V2}) on every proxy before VIP-620 is queued.`, ); } } @@ -219,7 +219,7 @@ forking(FORK_BLOCK, async () => { }); }); - testVip("VIP-800 part 1 — non-drain migration & May Prime allocation", await vip800Part1()); + 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 () => { diff --git a/simulations/vip-800/bscmainnet-part-2.ts b/simulations/vip-620/bscmainnet-part-2.ts similarity index 95% rename from simulations/vip-800/bscmainnet-part-2.ts rename to simulations/vip-620/bscmainnet-part-2.ts index 4ef952a4e..911579034 100644 --- a/simulations/vip-800/bscmainnet-part-2.ts +++ b/simulations/vip-620/bscmainnet-part-2.ts @@ -5,8 +5,8 @@ import { NETWORK_ADDRESSES } from "src/networkAddresses"; import { initMainnetUser } from "src/utils"; import { forking, testVip } from "src/vip-framework"; -// Prefer vip-800/bscmainnet-part-1 for anything it exports; fall back to the -// frozen vip-618 only for what vip-800 does not re-export (legacy converters +// 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, @@ -25,7 +25,7 @@ import { USDT_PRIME_CONVERTER, XVS_VAULT_CONVERTER, } from "../../vips/vip-618/bscmainnet"; -import vip800Part1, { +import vip620, { BUYBACKS, CORE_TOKENS, MIGRATION_HELPER_V2, @@ -33,8 +33,8 @@ import vip800Part1, { TIMELOCK_OWNED_CONVERTERS, U_PRIME_BUYBACK, XVS_BUYBACK, -} from "../../vips/vip-800/bscmainnet-part-1"; -import vip800Part2 from "../../vips/vip-800/bscmainnet-part-2"; +} 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"; @@ -97,7 +97,7 @@ forking(FORK_BLOCK, async () => { 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-800 part-1 is queued.`, + `transferOwnership(${MIGRATION_HELPER_V2}) on every proxy before VIP-620 is queued.`, ); } } @@ -109,7 +109,7 @@ forking(FORK_BLOCK, async () => { // 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-800 part 1 (setup for part-2 sim)", await vip800Part1(), { + 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) { @@ -159,7 +159,7 @@ forking(FORK_BLOCK, async () => { }); }); - testVip("VIP-800 part 2 — router allowlist, drain, and hand back ownership", await vip800Part2()); + 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 () => { diff --git a/vips/vip-800/bscmainnet-part-1.ts b/vips/vip-620/bscmainnet-part-1.ts similarity index 91% rename from vips/vip-800/bscmainnet-part-1.ts rename to vips/vip-620/bscmainnet-part-1.ts index 7c825c45e..a8ea48061 100644 --- a/vips/vip-800/bscmainnet-part-1.ts +++ b/vips/vip-620/bscmainnet-part-1.ts @@ -5,7 +5,7 @@ import { ProposalType } from "src/types"; import { makeProposal } from "src/utils"; // vip-618 is on-chain (proposed, execution failed) and its constants are -// frozen. VIP-800 imports only the values that survived the redeploy unchanged +// 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, @@ -33,7 +33,7 @@ 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-800 carries its +// 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"; @@ -102,18 +102,18 @@ const DEFAULT_ADMIN_ROLE = ethers.constants.HashZero; const HELPER_EXECUTE1_SIG = "execute1()"; const HELPER_EXECUTE_SWAP_SIG = "executeSwap()"; -export const VIP_NUMBER = "vip-800-part-1"; +export const VIP_NUMBER = "vip-620"; -export const vip800Part1 = () => { +export const vip620 = () => { const meta = { version: "v2", - title: "VIP-800 [BNB Chain] TokenBuyback Migration Part 1 & May Prime Allocation", + 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). VIP-800 splits the migration into two proposals: +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 (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-800-part-2)**: allowlisting 9 swap routers on every buyback, the converter drain, and the final return of all 16 (10 buybacks + 6 converters) ownership to NormalTimelock. +- **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. @@ -246,4 +246,4 @@ BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The or ); }; -export default vip800Part1; +export default vip620; diff --git a/vips/vip-800/bscmainnet-part-2.ts b/vips/vip-620/bscmainnet-part-2.ts similarity index 87% rename from vips/vip-800/bscmainnet-part-2.ts rename to vips/vip-620/bscmainnet-part-2.ts index 9142754c3..729322230 100644 --- a/vips/vip-800/bscmainnet-part-2.ts +++ b/vips/vip-620/bscmainnet-part-2.ts @@ -7,17 +7,17 @@ export { BUYBACKS, MIGRATION_HELPER_V2, TIMELOCK_OWNED_CONVERTERS }; const HELPER_EXECUTE2_SIG = "execute2()"; -export const VIP_NUMBER = "vip-800-part-2"; +export const VIP_NUMBER = "vip-621"; -export const vip800Part2 = () => { +export const vip621 = () => { const meta = { version: "v2", - title: "VIP-800 [BNB Chain] TokenBuyback Migration Part 2 (router allowlist + drain + handback)", + title: "VIP-621 [BNB Chain] TokenBuyback Migration Part 2 (router allowlist + drain + handback)", description: `#### Summary -Final step of the VIP-800 TokenBuyback migration. 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. +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-800-part-1, which: +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. @@ -75,4 +75,4 @@ BSC's Osaka hardfork enforces a hard per-tx gas cap of 2^24 = 16,777,216. The or ); }; -export default vip800Part2; +export default vip621;