diff --git a/simulations/vip-632/bscmainnet.ts b/simulations/vip-632/bscmainnet.ts new file mode 100644 index 000000000..66df199ef --- /dev/null +++ b/simulations/vip-632/bscmainnet.ts @@ -0,0 +1,168 @@ +import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; +import { impersonateAccount, setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { BigNumber, Contract } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES, ORACLE_BNB } from "src/networkAddresses"; +import { setMaxStalePeriodInBinanceOracle, setMaxStalePeriodInChainlinkOracle } from "src/utils"; +import { forking, testVip } from "src/vip-framework"; + +import vip632, { SXP, SXP_DIRECT_PRICE } from "../../vips/vip-632/bscmainnet"; + +const { bscmainnet } = NETWORK_ADDRESSES; +const CHAINLINK_ORACLE = bscmainnet.CHAINLINK_ORACLE; + +// The deprecated SXP market whose dead Chainlink feed bricks full-account +// liquidity checks for every account that has it in its entered markets. +const vSXP = "0x2fF3d0F6990a40261c66E1ff2017aCBc282EB6d0"; +// Affected account from the support report, used to verify the fix end-to-end +const USER = "0x299cBFae709f280b856C7e0ea619AC608a4d9845"; +// vUSDC core market — the borrow that was reverting for the user +const vUSDC = "0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8"; +const vBNB = "0xA07c5b74C9B40447a954e1466938b865b6BBea36"; +const BORROW_AMOUNT = parseUnits("7500", 18); + +const CHAINLINK_ORACLE_ABI = [ + "function getPrice(address asset) view returns (uint256)", + "function prices(address asset) view returns (uint256)", + "event PricePosted(address indexed asset, uint256 previousPriceMantissa, uint256 newPriceMantissa)", +]; +const RESILIENT_ORACLE_ABI = [ + "function getUnderlyingPrice(address) view returns (uint256)", + "function getTokenConfig(address) view returns (tuple(address asset, address[3] oracles, bool[3] enableFlagsForOracles))", +]; +const COMPTROLLER_ABI = [ + "function getAssetsIn(address) view returns (address[])", + "function getAccountLiquidity(address) view returns (uint256,uint256,uint256)", + "function checkMembership(address,address) view returns (bool)", + "function exitMarket(address) returns (uint256)", +]; +const VTOKEN_ABI = ["function borrow(uint256) returns (uint256)", "function underlying() view returns (address)"]; +const ERC20_ABI = ["function balanceOf(address) view returns (uint256)", "function symbol() view returns (string)"]; + +const BLOCK_NUMBER = 103745245; + +forking(BLOCK_NUMBER, async () => { + let oracle: Contract; + let resilientOracle: Contract; + let comptroller: Contract; + let vUsdc: Contract; + let usdc: Contract; + + before(async () => { + oracle = new ethers.Contract(CHAINLINK_ORACLE, CHAINLINK_ORACLE_ABI, ethers.provider); + resilientOracle = new ethers.Contract(bscmainnet.RESILIENT_ORACLE, RESILIENT_ORACLE_ABI, ethers.provider); + comptroller = new ethers.Contract(bscmainnet.UNITROLLER, COMPTROLLER_ABI, ethers.provider); + vUsdc = new ethers.Contract(vUSDC, VTOKEN_ABI, ethers.provider); + usdc = new ethers.Contract(await vUsdc.underlying(), ERC20_ABI, ethers.provider); + + await impersonateAccount(USER); + await setBalance(USER, parseUnits("1", 18)); + + // The fork is frozen in time while testVip mines through voting + timelock, + // so every live feed in the user's entered markets would go stale and produce + // false "invalid resilient oracle price" reverts in the post-VIP user checks. + // Bump stale periods on every enabled leg (main/pivot/fallback) of every + // entered asset — EXCEPT SXP, whose dead feed is the very thing the VIP fixes. + const markets: string[] = await comptroller.getAssetsIn(USER); + for (const market of markets) { + if (market.toLowerCase() === vSXP.toLowerCase()) continue; + const underlying = + market.toLowerCase() === vBNB.toLowerCase() + ? ORACLE_BNB + : await new ethers.Contract(market, VTOKEN_ABI, ethers.provider).underlying(); + const cfg = await resilientOracle.getTokenConfig(underlying); + for (let i = 0; i < 3; i++) { + const leg = cfg.oracles[i]; + if (!cfg.enableFlagsForOracles[i] || leg === ethers.constants.AddressZero) continue; + try { + // Chainlink-interface oracles (Chainlink, RedStone, ...) + await setMaxStalePeriodInChainlinkOracle( + leg, + underlying, + ethers.constants.AddressZero, + bscmainnet.NORMAL_TIMELOCK, + ); + } catch { + // Binance oracle keys by symbol instead + const symbol = + underlying.toLowerCase() === ORACLE_BNB.toLowerCase() + ? "BNB" + : await new ethers.Contract(underlying, ERC20_ABI, ethers.provider).symbol(); + await setMaxStalePeriodInBinanceOracle(leg, symbol); + } + } + } + }); + + describe("Pre-VIP behavior", () => { + it("SXP direct price is not yet 0.00046", async () => { + expect(await oracle.prices(SXP)).to.not.equal(SXP_DIRECT_PRICE); + }); + + it("ResilientOracle.getUnderlyingPrice(vSXP) reverts (retired Chainlink feed)", async () => { + await expect(resilientOracle.getUnderlyingPrice(vSXP)).to.be.revertedWith("invalid resilient oracle price"); + }); + + it("the affected user's borrow reverts on the same oracle error", async () => { + const signer = await ethers.getSigner(USER); + await expect(vUsdc.connect(signer).callStatic.borrow(BORROW_AMOUNT)).to.be.revertedWith( + "invalid resilient oracle price", + ); + }); + + it("the affected user cannot even exit the SXP market", async () => { + const signer = await ethers.getSigner(USER); + await expect(comptroller.connect(signer).callStatic.exitMarket(vSXP)).to.be.revertedWith( + "invalid resilient oracle price", + ); + }); + }); + + testVip("VIP-632 Set SXP direct price", await vip632(), { + callbackAfterExecution: async txResponse => { + await expect(txResponse).to.emit(oracle, "PricePosted").withArgs(SXP, anyValue, SXP_DIRECT_PRICE); + }, + }); + + describe("Post-VIP behavior", () => { + it("SXP direct price is set to 0.00046", async () => { + expect(await oracle.prices(SXP)).to.equal(SXP_DIRECT_PRICE); + }); + + it("getUnderlyingPrice(vSXP) returns the direct price (SXP has 18 decimals)", async () => { + expect(await resilientOracle.getUnderlyingPrice(vSXP)).to.equal(SXP_DIRECT_PRICE); + }); + + it("every market the affected user has entered can be priced again", async () => { + const markets: string[] = await comptroller.getAssetsIn(USER); + for (const market of markets) { + expect(await resilientOracle.getUnderlyingPrice(market)).to.be.gt(0); + } + }); + + it("the affected user has positive liquidity and no shortfall", async () => { + const [err, liquidity, shortfall] = await comptroller.getAccountLiquidity(USER); + expect(err).to.equal(0); + expect(shortfall).to.equal(0); + expect(liquidity).to.be.gt(0); + }); + + it("the affected user can borrow again and actually receives the funds", async () => { + const signer = await ethers.getSigner(USER); + const before: BigNumber = await usdc.balanceOf(USER); + const tx = await vUsdc.connect(signer).borrow(BORROW_AMOUNT); + await tx.wait(); + const after: BigNumber = await usdc.balanceOf(USER); + expect(after.sub(before)).to.equal(BORROW_AMOUNT); + }); + + it("the affected user can exit the SXP market (clears the stale membership)", async () => { + const signer = await ethers.getSigner(USER); + const tx = await comptroller.connect(signer).exitMarket(vSXP); + await tx.wait(); + expect(await comptroller.checkMembership(USER, vSXP)).to.equal(false); + }); + }); +}); diff --git a/vips/vip-632/bscmainnet.ts b/vips/vip-632/bscmainnet.ts new file mode 100644 index 000000000..831cf88ff --- /dev/null +++ b/vips/vip-632/bscmainnet.ts @@ -0,0 +1,51 @@ +import { parseUnits } from "ethers/lib/utils"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +const CHAINLINK_ORACLE = bscmainnet.CHAINLINK_ORACLE; + +// SXP (Swipe) BEP20 on BNB Chain, 18 decimals. +export const SXP = "0x47BEAd2563dCBf3bF2c9407fEa4dC236fAbA485A"; + +// New direct price for SXP: 0.00046 USD, scaled to 1e18 (SXP has 18 decimals). +export const SXP_DIRECT_PRICE = parseUnits("0.00046", 18); // 460000000000000 + +const vip632 = () => { + const meta = { + version: "v2", + title: "VIP-632 [BNB Chain] Set SXP Direct Price to 0.00046 USD", + description: `#### Summary + +This Critical VIP sets a fixed direct price of 0.00046 USD for the SXP market on the Venus Chainlink oracle, after Chainlink retired the SXP/USD price feed this week. + +#### Description + +SXP (Swipe) migrated and rebranded to Solar on a separate chain, and the SXP market on Venus BNB Chain has been paused for an extended period, holding only residual positions. Following the April 2026 delisting of Solar (SXP) from major venues, SXP liquidity has collapsed and its price has fallen to near zero. Chainlink has decided to retire the SXP/USD price feed, and switched it off this week. With the feed no longer reporting, the Venus oracle can no longer obtain a valid price for SXP, which can cause price reads to revert and block repayments and liquidations. This proposal sets a fixed direct price of 0.00046 USD on the Chainlink oracle, making it return a deterministic value for SXP independent of the retired feed. No other change is made and no risk parameters are modified. + +#### Actions + +This VIP performs the following action on BNB Chain: + +1. **Set SXP direct price** — Calls setDirectPrice(${SXP}, ${SXP_DIRECT_PRICE.toString()}) on the Chainlink oracle (${CHAINLINK_ORACLE}), fixing the SXP price at 0.00046 USD (scaled to 1e18 for an 18-decimal asset).`, + forDescription: "Execute the proposal", + againstDescription: "Do not execute the proposal", + abstainDescription: "Indifferent to execution", + }; + + return makeProposal( + [ + { + target: CHAINLINK_ORACLE, + signature: "setDirectPrice(address,uint256)", + params: [SXP, SXP_DIRECT_PRICE], + }, + ], + meta, + ProposalType.CRITICAL, + ); +}; + +export default vip632;