diff --git a/simulations/vip-634/bscmainnet.ts b/simulations/vip-634/bscmainnet.ts new file mode 100644 index 000000000..59cdfeb7d --- /dev/null +++ b/simulations/vip-634/bscmainnet.ts @@ -0,0 +1,18 @@ +import { forking, testVip } from "src/vip-framework"; + +import vip634 from "../../vips/vip-634/bscmainnet"; +import { BNB_BTC, BNB_CORE, BNB_DEFI, BNB_GAMEFI } from "../../vips/vip-634/phase4Markets"; +import { checkPhase4PostVip, checkPhase4PreVip } from "./utils/checkPhase4"; + +// BSC mainnet block (after the BNB push-out IRMs were deployed). +const FORK_BLOCK = 104562598; + +const BNB_POOLS = [BNB_CORE, BNB_BTC, BNB_DEFI, BNB_GAMEFI]; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip(BNB_POOLS); + + testVip("VIP-634 Market Deprecation Phase 4 (Part 1) — BNB Chain", await vip634()); + + checkPhase4PostVip(BNB_POOLS); +}); diff --git a/simulations/vip-634/ethereum.ts b/simulations/vip-634/ethereum.ts new file mode 100644 index 000000000..914cc3485 --- /dev/null +++ b/simulations/vip-634/ethereum.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import vip634 from "../../vips/vip-634/bscmainnet"; +import { ETH_CORE } from "../../vips/vip-634/phase4Markets"; +import { checkPhase4PostVip, checkPhase4PreVip } from "./utils/checkPhase4"; + +// Ethereum mainnet block (after the Ethereum push-out IRM was deployed). +const FORK_BLOCK = 25329944; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([ETH_CORE]); + + testForkedNetworkVipCommands("VIP-634 Ethereum Core", await vip634()); + + checkPhase4PostVip([ETH_CORE]); +}); diff --git a/simulations/vip-634/opbnbmainnet.ts b/simulations/vip-634/opbnbmainnet.ts new file mode 100644 index 000000000..72d69678d --- /dev/null +++ b/simulations/vip-634/opbnbmainnet.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import vip634 from "../../vips/vip-634/bscmainnet"; +import { OPBNB } from "../../vips/vip-634/phase4Markets"; +import { checkPhase4PostVip, checkPhase4PreVip } from "./utils/checkPhase4"; + +// opBNB mainnet block (after the opBNB push-out IRM was deployed). +const FORK_BLOCK = 153977309; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([OPBNB]); + + testForkedNetworkVipCommands("VIP-634 opBNB", await vip634()); + + checkPhase4PostVip([OPBNB]); +}); diff --git a/simulations/vip-634/opmainnet.ts b/simulations/vip-634/opmainnet.ts new file mode 100644 index 000000000..183c5b085 --- /dev/null +++ b/simulations/vip-634/opmainnet.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import vip634 from "../../vips/vip-634/bscmainnet"; +import { OPTIMISM } from "../../vips/vip-634/phase4Markets"; +import { checkPhase4PostVip, checkPhase4PreVip } from "./utils/checkPhase4"; + +// Optimism mainnet block (after the Optimism push-out IRM was deployed). +const FORK_BLOCK = 153006241; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([OPTIMISM]); + + testForkedNetworkVipCommands("VIP-634 Optimism", await vip634()); + + checkPhase4PostVip([OPTIMISM]); +}); diff --git a/simulations/vip-634/unichainmainnet.ts b/simulations/vip-634/unichainmainnet.ts new file mode 100644 index 000000000..07386b354 --- /dev/null +++ b/simulations/vip-634/unichainmainnet.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import vip634 from "../../vips/vip-634/bscmainnet"; +import { UNICHAIN } from "../../vips/vip-634/phase4Markets"; +import { checkPhase4PostVip, checkPhase4PreVip } from "./utils/checkPhase4"; + +// Unichain mainnet block (after the Unichain push-out IRM was deployed). +const FORK_BLOCK = 50862900; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([UNICHAIN]); + + testForkedNetworkVipCommands("VIP-634 Unichain", await vip634()); + + checkPhase4PostVip([UNICHAIN]); +}); diff --git a/simulations/vip-634/utils/checkPhase4.ts b/simulations/vip-634/utils/checkPhase4.ts new file mode 100644 index 000000000..b8f9710b4 --- /dev/null +++ b/simulations/vip-634/utils/checkPhase4.ts @@ -0,0 +1,187 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; + +import { PUSHOUT_IRM, PoolDef, RF_FULL } from "../../../vips/vip-634/phase4Markets"; + +// Documented pre-VIP collateral factor on the two BNB Liquid Staked ETH markets (5%). +const CF_GAP = parseUnits("0.05", 18); + +const VTOKEN_ABI = [ + "function reserveFactorMantissa() view returns (uint256)", + "function interestRateModel() view returns (address)", +]; + +const COMPTROLLER_ABI = [ + "function supplyCaps(address) view returns (uint256)", + "function borrowCaps(address) view returns (uint256)", + "function markets(address) view returns (bool isListed, uint256 collateralFactorMantissa, uint256 liquidationThresholdMantissa)", + "function poolMarkets(uint96, address) view returns (bool isListed, uint256 collateralFactorMantissa, uint256 unused, uint256 liquidationThresholdMantissa, bool isBorrowAllowed)", +]; + +// Asserts the pre-VIP state for the given pools, proving the VIP is not a no-op. +// Every assertion mirrors exactly one action emitted by generatePoolCommands, and a +// test is only registered when that action touches at least one market in the pool — +// so no check asserts state for a market the VIP does not modify, and an empty filter +// never shows up as a vacuously-passing test: +// - reserve factor below 100% on markets being raised (skips already-100% markets) +// - interest rate model not yet the push-out IRM (skips PLANET / stkBNB) +// - leftover supply / borrow cap gaps still open (> 0) +// - the two collateral-factor gaps still read 5% +export const checkPhase4PreVip = (pools: PoolDef[]) => { + const provider = ethers.provider; + + describe("Pre-VIP behavior", () => { + for (const pool of pools) { + describe(pool.label, () => { + const comptroller = new Contract(pool.comptroller, COMPTROLLER_ABI, provider); + const rfMarkets = pool.markets.filter(m => !m.rfAlready100); + const irmMarkets = pool.markets.filter(m => !m.skipIrm); + const supplyGap = pool.markets.filter(m => m.supplyCapGap); + const borrowGap = pool.markets.filter(m => m.borrowCapGap); + const cfGap = pool.markets.filter(m => m.cfGapLiqThreshold !== undefined); + const emodeCfGap = pool.markets.filter(m => m.emodeCfGap !== undefined); + + if (rfMarkets.length > 0) { + it("reserve factor below 100% on markets being raised", async () => { + for (const m of rfMarkets) { + const vToken = new Contract(m.vToken, VTOKEN_ABI, provider); + expect((await vToken.reserveFactorMantissa()).lt(RF_FULL), m.symbol).to.be.true; + } + }); + } + + if (irmMarkets.length > 0) { + it("interest rate model is not yet the push-out IRM", async () => { + const pushout = PUSHOUT_IRM[pool.irmKey].toLowerCase(); + for (const m of irmMarkets) { + const vToken = new Contract(m.vToken, VTOKEN_ABI, provider); + expect((await vToken.interestRateModel()).toLowerCase(), m.symbol).to.not.equal(pushout); + } + }); + } + + if (supplyGap.length > 0) { + it("outstanding supply caps still open (> 0)", async () => { + for (const m of supplyGap) { + expect((await comptroller.supplyCaps(m.vToken)).gt(0), m.symbol).to.be.true; + } + }); + } + + if (borrowGap.length > 0) { + it("outstanding borrow caps still open (> 0)", async () => { + for (const m of borrowGap) { + expect((await comptroller.borrowCaps(m.vToken)).gt(0), m.symbol).to.be.true; + } + }); + } + + if (cfGap.length > 0) { + it("collateral-factor gaps still read 5% (liquidation threshold matches)", async () => { + for (const m of cfGap) { + const data = await comptroller.markets(m.vToken); + expect(data.collateralFactorMantissa.toString(), m.symbol).to.equal(CF_GAP.toString()); + expect(data.liquidationThresholdMantissa.toString(), m.symbol).to.equal(m.cfGapLiqThreshold); + } + }); + } + + if (emodeCfGap.length > 0) { + it("e-mode collateral-factor gaps still open (> 0, liquidation threshold matches)", async () => { + for (const m of emodeCfGap) { + const g = m.emodeCfGap; + if (!g) continue; + const data = await comptroller.poolMarkets(g.poolId, m.vToken); + expect(data.collateralFactorMantissa.gt(0), m.symbol).to.be.true; + expect(data.liquidationThresholdMantissa.toString(), m.symbol).to.equal(g.liquidationThreshold); + } + }); + } + }); + } + }); +}; + +// Asserts the Phase-4 end state for the given pools. Every assertion mirrors exactly +// one action emitted by generatePoolCommands and is only registered when that action +// touches at least one market in the pool: +// - reserve factor raised to 100% (skips already-100% markets) +// - interest rate model repointed to the push-out IRM (skips PLANET / stkBNB) +// - leftover supply / borrow cap gaps closed to 0 +// - collateral-factor gaps closed to 0 with the liquidation threshold preserved +export const checkPhase4PostVip = (pools: PoolDef[]) => { + const provider = ethers.provider; + + describe("Post-VIP behavior", () => { + for (const pool of pools) { + describe(pool.label, () => { + const comptroller = new Contract(pool.comptroller, COMPTROLLER_ABI, provider); + const rfMarkets = pool.markets.filter(m => !m.rfAlready100); + const irmMarkets = pool.markets.filter(m => !m.skipIrm); + const supplyGap = pool.markets.filter(m => m.supplyCapGap); + const borrowGap = pool.markets.filter(m => m.borrowCapGap); + const cfGap = pool.markets.filter(m => m.cfGapLiqThreshold !== undefined); + const emodeCfGap = pool.markets.filter(m => m.emodeCfGap !== undefined); + + if (rfMarkets.length > 0) { + it("reserve factor raised to 100%", async () => { + for (const m of rfMarkets) { + const vToken = new Contract(m.vToken, VTOKEN_ABI, provider); + expect((await vToken.reserveFactorMantissa()).toString(), m.symbol).to.equal(RF_FULL.toString()); + } + }); + } + + if (irmMarkets.length > 0) { + it("interest rate model repointed to the push-out IRM", async () => { + const expected = PUSHOUT_IRM[pool.irmKey].toLowerCase(); + for (const m of irmMarkets) { + const vToken = new Contract(m.vToken, VTOKEN_ABI, provider); + expect((await vToken.interestRateModel()).toLowerCase(), m.symbol).to.equal(expected); + } + }); + } + + if (supplyGap.length > 0) { + it("outstanding supply caps closed to 0", async () => { + for (const m of supplyGap) { + expect((await comptroller.supplyCaps(m.vToken)).toString(), m.symbol).to.equal("0"); + } + }); + } + + if (borrowGap.length > 0) { + it("outstanding borrow caps closed to 0", async () => { + for (const m of borrowGap) { + expect((await comptroller.borrowCaps(m.vToken)).toString(), m.symbol).to.equal("0"); + } + }); + } + + if (cfGap.length > 0) { + it("outstanding collateral factors closed to 0 (liquidation threshold preserved)", async () => { + for (const m of cfGap) { + const data = await comptroller.markets(m.vToken); + expect(data.collateralFactorMantissa.toString(), m.symbol).to.equal("0"); + expect(data.liquidationThresholdMantissa.toString(), m.symbol).to.equal(m.cfGapLiqThreshold); + } + }); + } + + if (emodeCfGap.length > 0) { + it("e-mode collateral factors closed to 0 (liquidation threshold preserved)", async () => { + for (const m of emodeCfGap) { + const g = m.emodeCfGap; + if (!g) continue; + const data = await comptroller.poolMarkets(g.poolId, m.vToken); + expect(data.collateralFactorMantissa.toString(), m.symbol).to.equal("0"); + expect(data.liquidationThresholdMantissa.toString(), m.symbol).to.equal(g.liquidationThreshold); + } + }); + } + }); + } + }); +}; diff --git a/simulations/vip-635/arbitrumone.ts b/simulations/vip-635/arbitrumone.ts new file mode 100644 index 000000000..2aef2bd62 --- /dev/null +++ b/simulations/vip-635/arbitrumone.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import { ARBITRUM_LIQUID_STAKED_ETH } from "../../vips/vip-634/phase4Markets"; +import vip635 from "../../vips/vip-635/bscmainnet"; +import { checkPhase4PostVip, checkPhase4PreVip } from "../vip-634/utils/checkPhase4"; + +// Arbitrum One block (after the Arbitrum push-out IRM was deployed). +const FORK_BLOCK = 474085204; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([ARBITRUM_LIQUID_STAKED_ETH]); + + testForkedNetworkVipCommands("VIP-635 Arbitrum", await vip635()); + + checkPhase4PostVip([ARBITRUM_LIQUID_STAKED_ETH]); +}); diff --git a/simulations/vip-635/basemainnet.ts b/simulations/vip-635/basemainnet.ts new file mode 100644 index 000000000..3af01013c --- /dev/null +++ b/simulations/vip-635/basemainnet.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import { BASE_CORE } from "../../vips/vip-634/phase4Markets"; +import vip635 from "../../vips/vip-635/bscmainnet"; +import { checkPhase4PostVip, checkPhase4PreVip } from "../vip-634/utils/checkPhase4"; + +// Base mainnet block (after the Base push-out IRM was deployed). +const FORK_BLOCK = 47410957; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([BASE_CORE]); + + testForkedNetworkVipCommands("VIP-635 Base", await vip635()); + + checkPhase4PostVip([BASE_CORE]); +}); diff --git a/simulations/vip-635/bscmainnet.ts b/simulations/vip-635/bscmainnet.ts new file mode 100644 index 000000000..073c20538 --- /dev/null +++ b/simulations/vip-635/bscmainnet.ts @@ -0,0 +1,24 @@ +import { forking, testVip } from "src/vip-framework"; + +import { + BNB_LIQUID_STAKED_BNB, + BNB_LIQUID_STAKED_ETH, + BNB_MEME, + BNB_STABLECOINS, + BNB_TRON, +} from "../../vips/vip-634/phase4Markets"; +import vip635 from "../../vips/vip-635/bscmainnet"; +import { checkPhase4PostVip, checkPhase4PreVip } from "../vip-634/utils/checkPhase4"; + +// BSC mainnet block (after the BNB push-out IRM was deployed). +const FORK_BLOCK = 104562598; + +const BNB_POOLS = [BNB_MEME, BNB_LIQUID_STAKED_BNB, BNB_LIQUID_STAKED_ETH, BNB_STABLECOINS, BNB_TRON]; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip(BNB_POOLS); + + testVip("VIP-635 Market Deprecation Phase 4 (Part 2) — BNB Chain", await vip635()); + + checkPhase4PostVip(BNB_POOLS); +}); diff --git a/simulations/vip-635/ethereum.ts b/simulations/vip-635/ethereum.ts new file mode 100644 index 000000000..a52953806 --- /dev/null +++ b/simulations/vip-635/ethereum.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import { ETH_CURVE, ETH_LIQUID_STAKED_ETH } from "../../vips/vip-634/phase4Markets"; +import vip635 from "../../vips/vip-635/bscmainnet"; +import { checkPhase4PostVip, checkPhase4PreVip } from "../vip-634/utils/checkPhase4"; + +// Ethereum mainnet block (after the Ethereum push-out IRM was deployed). +const FORK_BLOCK = 25329944; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([ETH_CURVE, ETH_LIQUID_STAKED_ETH]); + + testForkedNetworkVipCommands("VIP-635 Ethereum Curve + Liquid Staked ETH", await vip635()); + + checkPhase4PostVip([ETH_CURVE, ETH_LIQUID_STAKED_ETH]); +}); diff --git a/simulations/vip-635/zksyncmainnet.ts b/simulations/vip-635/zksyncmainnet.ts new file mode 100644 index 000000000..f8bb179c9 --- /dev/null +++ b/simulations/vip-635/zksyncmainnet.ts @@ -0,0 +1,16 @@ +import { forking, testForkedNetworkVipCommands } from "src/vip-framework"; + +import { ZKSYNC_CORE } from "../../vips/vip-634/phase4Markets"; +import vip635 from "../../vips/vip-635/bscmainnet"; +import { checkPhase4PostVip, checkPhase4PreVip } from "../vip-634/utils/checkPhase4"; + +// zkSync Era mainnet block (after the zkSync push-out IRM was deployed). +const FORK_BLOCK = 70814289; + +forking(FORK_BLOCK, async () => { + checkPhase4PreVip([ZKSYNC_CORE]); + + testForkedNetworkVipCommands("VIP-635 zkSync Era", await vip635()); + + checkPhase4PostVip([ZKSYNC_CORE]); +}); diff --git a/src/vip-framework/index.ts b/src/vip-framework/index.ts index 7a7362e65..0a341acab 100644 --- a/src/vip-framework/index.ts +++ b/src/vip-framework/index.ts @@ -174,6 +174,104 @@ export const pretendExecutingVip = async (proposal: Proposal, sender: string = G return txResponses; }; +// Minimal Gnosis Safe ABI — the proposer is a Safe and the real propose() ships through +// its execTransaction(), so we measure the cap against that path (see estimateProposeViaMultisig). +const SAFE_ABI = [ + "function getOwners() view returns (address[])", + "function getThreshold() view returns (uint256)", + "function execTransaction(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,bytes signatures) payable returns (bool)", +]; + +// Gnosis Safe storage layout: slot 4 holds `threshold`. +const SAFE_THRESHOLD_SLOT = "0x4"; + +// Warn when a tx is this close to the cap: it fits today but mainnet state drift +// (cold/warm SLOADs, proposalCount, etc.) can tip it over between sim and execution. +const NEAR_CAP_WARN_BPS = 9700; // 97% + +// Logs a single governance transaction's gas in a consistent format: +// [gas]