diff --git a/simulations/vip-675/bscmainnet.ts b/simulations/vip-675/bscmainnet.ts new file mode 100644 index 000000000..5eeca7250 --- /dev/null +++ b/simulations/vip-675/bscmainnet.ts @@ -0,0 +1,68 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { forking, testVip } from "src/vip-framework"; + +import vip675, { PLP, PRIME_LEADERBOARD, PRIME_V2 } from "../../vips/vip-675/bscmainnet"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +// Minimal inline ABIs — PrimeV2 / PrimeLeaderboard not deployed yet, so no +// generated ABI files exist. Copy full ABIs into ./abi once deployed and swap these out. +const OWNABLE2STEP_ABI = ["function owner() view returns (address)", "function pendingOwner() view returns (address)"]; +const PLP_ABI = ["function prime() view returns (address)"]; + +// TODO: set to a block after PrimeV2 / PrimeLeaderboard are deployed on bscmainnet +const BLOCK_NUMBER = 0; + +forking(BLOCK_NUMBER, async () => { + let primeV2: Contract; + let primeLeaderboard: Contract; + let plp: Contract; + + before(async () => { + primeV2 = new ethers.Contract(PRIME_V2, OWNABLE2STEP_ABI, ethers.provider); + primeLeaderboard = new ethers.Contract(PRIME_LEADERBOARD, OWNABLE2STEP_ABI, ethers.provider); + plp = new ethers.Contract(PLP, PLP_ABI, ethers.provider); + }); + + describe("Pre-VIP behavior", () => { + it("PrimeV2 ownership pending on NormalTimelock (not accepted)", async () => { + expect(await primeV2.pendingOwner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + }); + + it("PrimeLeaderboard ownership pending on NormalTimelock (not accepted)", async () => { + expect(await primeLeaderboard.pendingOwner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + }); + + it("PLP prime token is not yet PrimeV2", async () => { + expect(await plp.prime()).to.not.equal(PRIME_V2); + }); + }); + + testVip("VIP-675 PrimeV2 + PrimeLeaderboard setup", await vip675(), { + callbackAfterExecution: async () => { + // TODO: assert OwnershipTransferred / PermissionGranted / MarketAdded events + // once PrimeV2 / PrimeLeaderboard ABIs are available + }, + }); + + describe("Post-VIP behavior", () => { + it("PrimeV2 owner is the NormalTimelock", async () => { + expect(await primeV2.owner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + }); + + it("PrimeLeaderboard owner is the NormalTimelock", async () => { + expect(await primeLeaderboard.owner()).to.equal(bscmainnet.NORMAL_TIMELOCK); + }); + + it("PLP points at PrimeV2", async () => { + expect(await plp.prime()).to.equal(PRIME_V2); + }); + + it("Prime markets are configured on PrimeV2", async () => { + // TODO: assert each addMarket entry exists with the expected multipliers + }); + }); +}); diff --git a/simulations/vip-675/bsctestnet-addendum.ts b/simulations/vip-675/bsctestnet-addendum.ts new file mode 100644 index 000000000..0b9384409 --- /dev/null +++ b/simulations/vip-675/bsctestnet-addendum.ts @@ -0,0 +1,113 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { forking, testVip } from "src/vip-framework"; + +import { PRIME_LEADERBOARD, PRIME_V2 } from "../../vips/vip-675/bsctestnet"; +import vip675Addendum, { TIER_DURATIONS, TIER_MULTIPLIERS } from "../../vips/vip-675/bsctestnet-addendum"; + +const { bsctestnet } = NETWORK_ADDRESSES; + +const ACM_ABI = [ + "function hasRole(bytes32 role, address account) view returns (bool)", + "event PermissionGranted(address account, address contractAddress, string functionSig)", +]; +const LEADERBOARD_ABI = [ + "function getMultiplierTiers() view returns (uint256[] durations, uint256[] multipliers)", + "event MultiplierTiersUpdated(uint256[] durations, uint256[] multipliers)", +]; + +const BLOCK_NUMBER = 111353484; + +// All ACM-gated functions on PrimeV2 / PrimeLeaderboard that the addendum grants to the Guardian. +const PRIME_V2_NEW_GUARDIAN_SIGS = [ + "setPrimeLeaderboard(address)", + "addMarket(address,uint256,uint256)", + "removeMarket(address)", + "setLimit(uint256)", + "updateAlpha(uint128,uint128)", + "updateMultipliers(address,uint256,uint256)", + "setMaxLoopsLimit(uint256)", + "pause()", + "unpause()", +]; +const PRIME_LEADERBOARD_NEW_GUARDIAN_SIGS = [ + "setMultiplierTiers(uint256[],uint256[])", + "setPrimeV2(address)", + "setMaxLoopsLimit(uint256)", +]; + +forking(BLOCK_NUMBER, async () => { + let acm: Contract; + let leaderboard: Contract; + + before(async () => { + acm = new ethers.Contract(bsctestnet.ACCESS_CONTROL_MANAGER, ACM_ABI, ethers.provider); + leaderboard = new ethers.Contract(PRIME_LEADERBOARD, LEADERBOARD_ABI, ethers.provider); + }); + + const roleFor = (target: string, signature: string) => + ethers.utils.solidityKeccak256(["address", "string"], [target, signature]); + + describe("Pre-VIP behavior", () => { + it("Guardian does not yet hold the addendum permissions on PrimeV2", async () => { + for (const sig of PRIME_V2_NEW_GUARDIAN_SIGS) { + expect(await acm.hasRole(roleFor(PRIME_V2, sig), bsctestnet.GUARDIAN)).to.equal(false); + } + }); + + it("Guardian does not yet hold the addendum permissions on PrimeLeaderboard", async () => { + for (const sig of PRIME_LEADERBOARD_NEW_GUARDIAN_SIGS) { + expect(await acm.hasRole(roleFor(PRIME_LEADERBOARD, sig), bsctestnet.GUARDIAN)).to.equal(false); + } + }); + + it("PrimeLeaderboard tiers are still the day-scale defaults", async () => { + const { durations, multipliers } = await leaderboard.getMultiplierTiers(); + expect(durations.map((d: { toString: () => string }) => d.toString())).to.deep.equal([ + (30 * 24 * 60 * 60).toString(), + (60 * 24 * 60 * 60).toString(), + (90 * 24 * 60 * 60).toString(), + ]); + expect(multipliers.map((m: { toString: () => string }) => m.toString())).to.deep.equal([ + "1300000000000000000", + "1600000000000000000", + "2000000000000000000", + ]); + }); + }); + + testVip("VIP-675 addendum [Testnet]", await vip675Addendum(), { + callbackAfterExecution: async txResponse => { + await expect(txResponse) + .to.emit(leaderboard, "MultiplierTiersUpdated") + .withArgs(TIER_DURATIONS, TIER_MULTIPLIERS); + await expect(txResponse) + .to.emit(acm, "PermissionGranted") + .withArgs(bsctestnet.GUARDIAN, PRIME_LEADERBOARD, "setMultiplierTiers(uint256[],uint256[])"); + }, + }); + + describe("Post-VIP behavior", () => { + it("Guardian holds all addendum permissions on PrimeV2", async () => { + for (const sig of PRIME_V2_NEW_GUARDIAN_SIGS) { + expect(await acm.hasRole(roleFor(PRIME_V2, sig), bsctestnet.GUARDIAN)).to.equal(true); + } + }); + + it("Guardian holds all addendum permissions on PrimeLeaderboard", async () => { + for (const sig of PRIME_LEADERBOARD_NEW_GUARDIAN_SIGS) { + expect(await acm.hasRole(roleFor(PRIME_LEADERBOARD, sig), bsctestnet.GUARDIAN)).to.equal(true); + } + }); + + it("PrimeLeaderboard tiers are compressed to the hour-scale schedule", async () => { + const { durations, multipliers } = await leaderboard.getMultiplierTiers(); + expect(durations.map((d: { toString: () => string }) => d.toString())).to.deep.equal( + TIER_DURATIONS.map(d => d.toString()), + ); + expect(multipliers.map((m: { toString: () => string }) => m.toString())).to.deep.equal(TIER_MULTIPLIERS); + }); + }); +}); diff --git a/simulations/vip-675/bsctestnet.ts b/simulations/vip-675/bsctestnet.ts new file mode 100644 index 000000000..2121a7f52 --- /dev/null +++ b/simulations/vip-675/bsctestnet.ts @@ -0,0 +1,203 @@ +import { expect } from "chai"; +import { Contract, constants } from "ethers"; +import { ethers } from "hardhat"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { forking, testVip } from "src/vip-framework"; + +import vip675, { + COMPTROLLER, + LEGACY_PRIME, + MINT_DEADLINE, + MINT_THRESHOLD, + PLP, + PRIME_LEADERBOARD, + PRIME_MARKETS, + PRIME_V2, + XVS_VAULT, +} from "../../vips/vip-675/bsctestnet"; + +const { bsctestnet } = NETWORK_ADDRESSES; +const ACM_ABI = [ + "function hasRole(bytes32 role, address account) view returns (bool)", + "event PermissionGranted(address account, address contractAddress, string functionSig)", +]; +const VAULT_ABI = ["function primeToken() view returns (address)"]; +const COMPTROLLER_ABI = ["function prime() view returns (address)"]; + +// Minimal inline ABIs +const PRIME_V2_ABI = [ + "function owner() view returns (address)", + "function pendingOwner() view returns (address)", + "function primeLeaderboard() view returns (address)", + "function tokenLimit() view returns (uint256)", + "function mintThreshold() view returns (uint256)", + "function mintDeadline() view returns (uint256)", + "function markets(address) view returns (uint256 supplyMultiplier, uint256 borrowMultiplier, uint256 rewardIndex, uint256 sumOfMembersScore, bool exists)", + "event MarketAdded(address indexed market, uint256 supplyMultiplier, uint256 borrowMultiplier)", + "event MintThresholdUpdated(uint256 oldThreshold, uint256 newThreshold, uint256 deadline)", + "event PrimeLeaderboardSet(address indexed oldLeaderboard, address indexed newLeaderboard)", +]; +const PRIME_LEADERBOARD_ABI = [ + "function owner() view returns (address)", + "function pendingOwner() view returns (address)", + "function primeV2() view returns (address)", +]; +const PLP_ABI = ["function prime() view returns (address)"]; +const LEGACY_PRIME_ABI = ["function paused() view returns (bool)"]; + +const BLOCK_NUMBER = 111010308; + +forking(BLOCK_NUMBER, async () => { + let primeV2: Contract; + let primeLeaderboard: Contract; + let plp: Contract; + let legacyPrime: Contract; + let acm: Contract; + let xvsVault: Contract; + let comptroller: Contract; + + before(async () => { + primeV2 = new ethers.Contract(PRIME_V2, PRIME_V2_ABI, ethers.provider); + primeLeaderboard = new ethers.Contract(PRIME_LEADERBOARD, PRIME_LEADERBOARD_ABI, ethers.provider); + plp = new ethers.Contract(PLP, PLP_ABI, ethers.provider); + legacyPrime = new ethers.Contract(LEGACY_PRIME, LEGACY_PRIME_ABI, ethers.provider); + acm = new ethers.Contract(bsctestnet.ACCESS_CONTROL_MANAGER, ACM_ABI, ethers.provider); + xvsVault = new ethers.Contract(XVS_VAULT, VAULT_ABI, ethers.provider); + comptroller = new ethers.Contract(COMPTROLLER, COMPTROLLER_ABI, ethers.provider); + }); + + const roleFor = (target: string, signature: string) => + ethers.utils.solidityKeccak256(["address", "string"], [target, signature]); + + describe("Pre-VIP behavior", () => { + it("PrimeV2 ownership pending on NormalTimelock (not accepted)", async () => { + expect(await primeV2.pendingOwner()).to.equal(bsctestnet.NORMAL_TIMELOCK); + }); + + it("PrimeLeaderboard ownership pending on NormalTimelock (not accepted)", async () => { + expect(await primeLeaderboard.pendingOwner()).to.equal(bsctestnet.NORMAL_TIMELOCK); + }); + + it("PLP prime token is still the legacy Prime", async () => { + expect(await plp.prime()).to.equal(LEGACY_PRIME); + }); + + it("PrimeV2 <-> PrimeLeaderboard are not yet wired", async () => { + expect(await primeV2.primeLeaderboard()).to.equal(constants.AddressZero); + expect(await primeLeaderboard.primeV2()).to.equal(constants.AddressZero); + }); + + it("PrimeV2 mint window is not yet open", async () => { + expect(await primeV2.mintThreshold()).to.equal(0); + expect(await primeV2.mintDeadline()).to.equal(0); + }); + + it("legacy Prime is active (unpaused)", async () => { + expect(await legacyPrime.paused()).to.equal(false); + }); + + it("XVSVault prime hook still points at legacy Prime", async () => { + expect(await xvsVault.primeToken()).to.equal(LEGACY_PRIME); + }); + + it("Comptroller prime still points at legacy Prime", async () => { + expect(await comptroller.prime()).to.equal(LEGACY_PRIME); + }); + }); + + testVip("VIP-675 [Testnet] PrimeV2 + PrimeLeaderboard setup", await vip675(), { + callbackAfterExecution: async txResponse => { + await expect(txResponse) + .to.emit(primeV2, "PrimeLeaderboardSet") + .withArgs(constants.AddressZero, PRIME_LEADERBOARD); + await expect(txResponse).to.emit(primeV2, "MintThresholdUpdated").withArgs(0, MINT_THRESHOLD, MINT_DEADLINE); + for (const market of PRIME_MARKETS) { + await expect(txResponse) + .to.emit(primeV2, "MarketAdded") + .withArgs(market.vToken, market.supplyMultiplier, market.borrowMultiplier); + } + // ACM emits PermissionGranted for every ACM-gated function granted by this VIP; + // spot-check the Guardian's issue(address) grant on PrimeV2. + await expect(txResponse) + .to.emit(acm, "PermissionGranted") + .withArgs(bsctestnet.GUARDIAN, PRIME_V2, "issue(address)"); + }, + }); + + describe("Post-VIP behavior", () => { + it("PrimeV2 owner is the NormalTimelock", async () => { + expect(await primeV2.owner()).to.equal(bsctestnet.NORMAL_TIMELOCK); + }); + + it("PrimeLeaderboard owner is the NormalTimelock", async () => { + expect(await primeLeaderboard.owner()).to.equal(bsctestnet.NORMAL_TIMELOCK); + }); + + it("PLP points at PrimeV2", async () => { + expect(await plp.prime()).to.equal(PRIME_V2); + }); + + it("PrimeV2 <-> PrimeLeaderboard are wired", async () => { + expect(await primeV2.primeLeaderboard()).to.equal(PRIME_LEADERBOARD); + expect(await primeLeaderboard.primeV2()).to.equal(PRIME_V2); + }); + + it("PrimeV2 token limit is 500", async () => { + expect(await primeV2.tokenLimit()).to.equal(500); + }); + + it("PrimeV2 mint window is configured", async () => { + expect(await primeV2.mintThreshold()).to.equal(MINT_THRESHOLD); + expect(await primeV2.mintDeadline()).to.equal(MINT_DEADLINE); + }); + + it("Guardian can call epoch ops on PrimeV2 (issue / issueBatch / burn / burnBatch / setMintThreshold)", async () => { + for (const sig of [ + "issue(address)", + "issueBatch(address[])", + "burn(address)", + "burnBatch(address[])", + "setMintThreshold(uint256,uint256)", + ]) { + expect(await acm.hasRole(roleFor(PRIME_V2, sig), bsctestnet.GUARDIAN)).to.equal(true); + } + }); + + it("Guardian can seed stakers on PrimeLeaderboard", async () => { + for (const sig of ["initializeStakers(address[],uint256[],uint64[])", "finalizeInitialization()"]) { + expect(await acm.hasRole(roleFor(PRIME_LEADERBOARD, sig), bsctestnet.GUARDIAN)).to.equal(true); + } + }); + + it("NormalTimelock can configure PrimeLeaderboard (tiers / primeV2 / maxLoops)", async () => { + for (const sig of [ + "setMultiplierTiers(uint256[],uint256[])", + "setPrimeV2(address)", + "setMaxLoopsLimit(uint256)", + ]) { + expect(await acm.hasRole(roleFor(PRIME_LEADERBOARD, sig), bsctestnet.NORMAL_TIMELOCK)).to.equal(true); + } + }); + + it("XVSVault prime hook points at PrimeLeaderboard", async () => { + expect(await xvsVault.primeToken()).to.equal(PRIME_LEADERBOARD); + }); + + it("Comptroller prime points at PrimeV2", async () => { + expect(await comptroller.prime()).to.equal(PRIME_V2); + }); + + it("legacy Prime is decommissioned (paused)", async () => { + expect(await legacyPrime.paused()).to.equal(true); + }); + + for (const market of PRIME_MARKETS) { + it(`market ${market.vToken} is configured on PrimeV2`, async () => { + const m = await primeV2.markets(market.vToken); + expect(m.exists).to.equal(true); + expect(m.supplyMultiplier).to.equal(market.supplyMultiplier); + expect(m.borrowMultiplier).to.equal(market.borrowMultiplier); + }); + } + }); +}); diff --git a/vips/vip-675/bscmainnet.ts b/vips/vip-675/bscmainnet.ts new file mode 100644 index 000000000..e13842f7c --- /dev/null +++ b/vips/vip-675/bscmainnet.ts @@ -0,0 +1,173 @@ +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +const { bscmainnet } = NETWORK_ADDRESSES; + +const ACM = bscmainnet.ACCESS_CONTROL_MANAGER; +const NORMAL_TIMELOCK = bscmainnet.NORMAL_TIMELOCK; +const FAST_TRACK_TIMELOCK = bscmainnet.FAST_TRACK_TIMELOCK; +const CRITICAL_TIMELOCK = bscmainnet.CRITICAL_TIMELOCK; + +// ============================================================================ +// TODO: replace with the deployed PrimeV2 / PrimeLeaderboard proxy addresses +// (not yet deployed on bscmainnet — see venus-protocol deploy/014-deploy-prime-v2.ts). +// On live networks the deploy script initiates transferOwnership of both contracts to the +// NormalTimelock (pending acceptance) but does NOT wire them — the setPrimeV2 / +// setPrimeLeaderboard wiring is ACM-gated and done here. This VIP accepts ownership, grants +// ACM permissions, wires the pair, and configures the markets. +// ============================================================================ +export const PRIME_V2 = "0x0000000000000000000000000000000000000000"; // TODO +export const PRIME_LEADERBOARD = "0x0000000000000000000000000000000000000000"; // TODO + +// Existing contracts reused by PrimeV2 +export const PLP = "0x23c4F844ffDdC6161174eB32c770D4D8C07833F2"; // PrimeLiquidityProvider (existing) +export const LEGACY_PRIME = "0xBbCD063efE506c3D42a0Fa2dB5C08430288C71FC"; // current Prime (to be replaced) + +// Prime markets on the Core pool (bscmainnet) +const VUSDT = "0xfD5840Cd36d94D7229439859C0112a4185BC0255"; +const VUSDC = "0xecA88125a5ADbe82614ffC12D0DB554E2e2867C8"; +const VBTC = "0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B"; +const VETH = "0xf508fCD89b8bd15579dc79A6827cB4686A3592c8"; + +const ALL_TIMELOCKS = [NORMAL_TIMELOCK, FAST_TRACK_TIMELOCK, CRITICAL_TIMELOCK]; + +// Grant a single ACM permission for `target.signature` to every timelock in `accounts`. +const grant = (target: string, signature: string, accounts: string[] = [NORMAL_TIMELOCK]) => + accounts.map(account => ({ + target: ACM, + signature: "giveCallPermission(address,string,address)", + params: [target, signature, account], + })); + +// ACM-gated functions on PrimeV2 (see PrimeV2.sol _checkAccessAllowed) +const PRIME_V2_PERMISSIONS = [ + ...grant(PRIME_V2, "issue(address)"), + ...grant(PRIME_V2, "issueBatch(address[])"), + ...grant(PRIME_V2, "burn(address)"), + ...grant(PRIME_V2, "burnBatch(address[])"), + ...grant(PRIME_V2, "setPrimeLeaderboard(address)"), + ...grant(PRIME_V2, "addMarket(address,uint256,uint256)"), + ...grant(PRIME_V2, "removeMarket(address)"), + ...grant(PRIME_V2, "setLimit(uint256)"), + ...grant(PRIME_V2, "updateAlpha(uint128,uint128)"), + ...grant(PRIME_V2, "updateMultipliers(address,uint256,uint256)"), + ...grant(PRIME_V2, "setMaxLoopsLimit(uint256)"), + ...grant(PRIME_V2, "setMintThreshold(uint256,uint256)"), + ...grant(PRIME_V2, "pause()", ALL_TIMELOCKS), + ...grant(PRIME_V2, "unpause()", ALL_TIMELOCKS), +]; + +// ACM-gated functions on PrimeLeaderboard (see PrimeLeaderboard.sol _checkAccessAllowed) +const PRIME_LEADERBOARD_PERMISSIONS = [ + ...grant(PRIME_LEADERBOARD, "initializeStakers(address[],uint256[],uint64[])"), + ...grant(PRIME_LEADERBOARD, "finalizeInitialization()"), + ...grant(PRIME_LEADERBOARD, "setMultiplierTiers(uint256[],uint256[])"), + ...grant(PRIME_LEADERBOARD, "setPrimeV2(address)"), + ...grant(PRIME_LEADERBOARD, "setMaxLoopsLimit(uint256)"), +]; + +const vip675 = () => { + const meta = { + version: "v2", + title: "VIP-675 Deploy and configure PrimeV2 and PrimeLeaderboard", + description: `#### Summary + +If passed, this VIP will accept ownership of the new PrimeV2 and PrimeLeaderboard contracts on BNB Chain, grant the governance timelocks the required ACM permissions, point the existing PrimeLiquidityProvider at PrimeV2, and configure the Prime markets and leaderboard tiers. + +#### Description + +TODO: describe the PrimeV2 / PrimeLeaderboard rollout, the markets added, the leaderboard multiplier tiers, and the migration of existing Prime holders. + +#### Security and additional considerations + +TODO: list audits (incl. Sherlock VPD-1292), testnet deployment and simulation links. + +#### References + +- PrimeV2 / PrimeLeaderboard implementation: https://github.com/VenusProtocol/venus-protocol/pull/676`, + 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. Accept ownership (deploy script transferred ownership to NormalTimelock) + { + target: PRIME_V2, + signature: "acceptOwnership()", + params: [], + }, + { + target: PRIME_LEADERBOARD, + signature: "acceptOwnership()", + params: [], + }, + + // 2. Grant ACM permissions + ...PRIME_V2_PERMISSIONS, + ...PRIME_LEADERBOARD_PERMISSIONS, + + // 3. Wire PrimeV2 <-> PrimeLeaderboard (ACM-gated; deploy does NOT wire on live networks) + { + target: PRIME_V2, + signature: "setPrimeLeaderboard(address)", + params: [PRIME_LEADERBOARD], + }, + { + target: PRIME_LEADERBOARD, + signature: "setPrimeV2(address)", + params: [PRIME_V2], + }, + + // 4. Point the existing PrimeLiquidityProvider at PrimeV2 (onlyOwner = NormalTimelock) + { + target: PLP, + signature: "setPrimeToken(address)", + params: [PRIME_V2], + }, + + // 5. Configure PrimeV2 markets + // TODO: confirm supplyMultiplier / borrowMultiplier (1e18 scaled) per market + { + target: PRIME_V2, + signature: "addMarket(address,uint256,uint256)", + params: [VUSDT, "0", "0"], // TODO multipliers + }, + { + target: PRIME_V2, + signature: "addMarket(address,uint256,uint256)", + params: [VUSDC, "0", "0"], // TODO multipliers + }, + { + target: PRIME_V2, + signature: "addMarket(address,uint256,uint256)", + params: [VBTC, "0", "0"], // TODO multipliers + }, + { + target: PRIME_V2, + signature: "addMarket(address,uint256,uint256)", + params: [VETH, "0", "0"], // TODO multipliers + }, + + // Note: setMintThreshold is intentionally not called here — governance sets it + // after the first epoch ends (the setMintThreshold ACM permission is granted above). + + // 6. Decommission the legacy Prime: pause it (halts claim / score updates / issuance). + // NormalTimelock already holds the togglePause ACM permission, so no grant is needed. + // Legacy Prime is currently unpaused, so a single togglePause pauses it. + // (setLimit(0,0) is NOT used: it reverts with InvalidLimit because the legacy Prime + // already has revocable tokens minted, and limits cannot be set below the minted total.) + { + target: LEGACY_PRIME, + signature: "togglePause()", + params: [], + }, + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip675; diff --git a/vips/vip-675/bsctestnet-addendum.ts b/vips/vip-675/bsctestnet-addendum.ts new file mode 100644 index 000000000..2dc411f76 --- /dev/null +++ b/vips/vip-675/bsctestnet-addendum.ts @@ -0,0 +1,90 @@ +import { parseUnits } from "ethers/lib/utils"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +import { PRIME_LEADERBOARD, PRIME_V2 } from "./bsctestnet"; + +const { bsctestnet } = NETWORK_ADDRESSES; + +const ACM = bsctestnet.ACCESS_CONTROL_MANAGER; +const GUARDIAN = bsctestnet.GUARDIAN; + +// Grant ACM permission for `target.signature` to `account`. +const grant = (target: string, signature: string, account: string) => ({ + target: ACM, + signature: "giveCallPermission(address,string,address)", + params: [target, signature, account], +}); + +// ACM-gated functions on PrimeV2 / PrimeLeaderboard that the Guardian did NOT yet hold +// after the original VIP-675. Granting them here gives the Guardian full operational +// control over both contracts (issue/burn/setMintThreshold/initializeStakers/finalizeInitialization +// were already granted in the original VIP-675 and are intentionally not re-granted). +const PRIME_V2_GUARDIAN_PERMISSIONS = [ + grant(PRIME_V2, "setPrimeLeaderboard(address)", GUARDIAN), + grant(PRIME_V2, "addMarket(address,uint256,uint256)", GUARDIAN), + grant(PRIME_V2, "removeMarket(address)", GUARDIAN), + grant(PRIME_V2, "setLimit(uint256)", GUARDIAN), + grant(PRIME_V2, "updateAlpha(uint128,uint128)", GUARDIAN), + grant(PRIME_V2, "updateMultipliers(address,uint256,uint256)", GUARDIAN), + grant(PRIME_V2, "setMaxLoopsLimit(uint256)", GUARDIAN), + grant(PRIME_V2, "pause()", GUARDIAN), + grant(PRIME_V2, "unpause()", GUARDIAN), +]; + +const PRIME_LEADERBOARD_GUARDIAN_PERMISSIONS = [ + grant(PRIME_LEADERBOARD, "setMultiplierTiers(uint256[],uint256[])", GUARDIAN), + grant(PRIME_LEADERBOARD, "setPrimeV2(address)", GUARDIAN), + grant(PRIME_LEADERBOARD, "setMaxLoopsLimit(uint256)", GUARDIAN), +]; + +// Compressed multiplier tiers for faster testnet iteration: +// <1h -> 1.0x (implicit base, not a tier entry) +// 1-2h -> 1.3x +// 2-3h -> 1.6x +// >=3h -> 2.0x +export const TIER_DURATIONS = [3600, 7200, 10800]; +export const TIER_MULTIPLIERS = [ + parseUnits("1.3", 18).toString(), + parseUnits("1.6", 18).toString(), + parseUnits("2", 18).toString(), +]; + +const vip675Addendum = () => { + const meta = { + version: "v2", + title: "VIP-675 addendum [Testnet] Extend Guardian permissions and compress leaderboard tiers", + description: `#### Summary + +If passed, this addendum to VIP-675 will: + +- Grant the Guardian the remaining ACM permissions on PrimeV2 and PrimeLeaderboard (every ACM-gated function), so the Guardian can fully operate both contracts without going through governance for each call during testnet integration. +- Compress the PrimeLeaderboard multiplier tiers from day-scale (30/60/90 days -> 1.3x/1.6x/2.0x) to hour-scale (1h/2h/3h -> 1.3x/1.6x/2.0x), so FE/BE testing can exercise the tier progression without waiting weeks. + +The base 1.0x multiplier (<1h) is implicit and is not a tier entry. + +#### References + +- VIP-675 (original): https://github.com/VenusProtocol/vips/pull/712`, + 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( + [ + ...PRIME_V2_GUARDIAN_PERMISSIONS, + ...PRIME_LEADERBOARD_GUARDIAN_PERMISSIONS, + { + target: PRIME_LEADERBOARD, + signature: "setMultiplierTiers(uint256[],uint256[])", + params: [TIER_DURATIONS, TIER_MULTIPLIERS], + }, + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip675Addendum; diff --git a/vips/vip-675/bsctestnet.ts b/vips/vip-675/bsctestnet.ts new file mode 100644 index 000000000..34f251108 --- /dev/null +++ b/vips/vip-675/bsctestnet.ts @@ -0,0 +1,220 @@ +import { parseUnits } from "ethers/lib/utils"; +import { NETWORK_ADDRESSES } from "src/networkAddresses"; +import { ProposalType } from "src/types"; +import { makeProposal } from "src/utils"; + +const { bsctestnet } = NETWORK_ADDRESSES; + +const ACM = bsctestnet.ACCESS_CONTROL_MANAGER; +const NORMAL_TIMELOCK = bsctestnet.NORMAL_TIMELOCK; +const FAST_TRACK_TIMELOCK = bsctestnet.FAST_TRACK_TIMELOCK; +const CRITICAL_TIMELOCK = bsctestnet.CRITICAL_TIMELOCK; +const GUARDIAN = bsctestnet.GUARDIAN; + +// Deployed via venus-protocol PR #677 (feat/VPD-1313). On live networks the deploy script +// initiates transferOwnership of both contracts to the NormalTimelock (pending acceptance), +// but does NOT wire them — the setPrimeV2 / setPrimeLeaderboard wiring is ACM-gated and done +// here. This VIP accepts ownership, grants ACM permissions, wires PrimeV2 <-> PrimeLeaderboard, +// repoints the PrimeLiquidityProvider, configures the Prime markets, opens the mint window, +// switches the XVS Vault hook and the Core pool Comptroller to the new contracts, and pauses +// the legacy Prime. +export const PRIME_V2 = "0x4B8b963324dB0D40f64539032AB49CB07c88e312"; +export const PRIME_LEADERBOARD = "0x45E9b8A46558c359b6Ee30580A599AAa1e5d9cDE"; + +// Existing contracts reused by PrimeV2 +export const PLP = "0xAdeddc73eAFCbed174e6C400165b111b0cb80B7E"; // PrimeLiquidityProvider (existing) +export const LEGACY_PRIME = "0xe840F8EC2Dc50E7D22e5e2991975b9F6e34b62Ad"; // current Prime (to be replaced) +export const COMPTROLLER = bsctestnet.UNITROLLER; +export const XVS_VAULT = bsctestnet.XVS_VAULT_PROXY; +export const XVS = bsctestnet.XVS; +export const XVS_VAULT_POOL_ID = 1; + +// Permissionless mint window config for PrimeV2.setMintThreshold(mintThreshold, mintDeadline) +export const MINT_THRESHOLD = parseUnits("1", 18).toString(); // 1 XVS effective stake (testnet) +export const MINT_DEADLINE = "0"; // 0 = no expiry + +// Prime markets on the Core pool (bsctestnet) — mirrors the legacy Prime markets +// and their supply/borrow multipliers (read from the legacy Prime at LEGACY_PRIME). +const VUSDT = "0xb7526572FFE56AB9D7489838Bf2E18e3323b441A"; +const VUSDC = "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7"; +const VBTC = "0xb6e9322C49FD75a367Fcb17B0Fcd62C5070EbCBe"; +const VETH = "0x162D005F0Fff510E54958Cfc5CF32A3180A84aab"; + +interface PrimeMarket { + vToken: string; + supplyMultiplier: string; + borrowMultiplier: string; +} + +export const PRIME_MARKETS: PrimeMarket[] = [ + { vToken: VUSDT, supplyMultiplier: parseUnits("2", 18).toString(), borrowMultiplier: "0" }, + { vToken: VUSDC, supplyMultiplier: parseUnits("2", 18).toString(), borrowMultiplier: "0" }, + { vToken: VBTC, supplyMultiplier: parseUnits("2", 18).toString(), borrowMultiplier: parseUnits("4", 18).toString() }, + { vToken: VETH, supplyMultiplier: parseUnits("2", 18).toString(), borrowMultiplier: parseUnits("4", 18).toString() }, +]; + +const ALL_TIMELOCKS = [NORMAL_TIMELOCK, FAST_TRACK_TIMELOCK, CRITICAL_TIMELOCK]; + +// Grant ACM permission for `target.signature` to every account in `accounts` +// (defaults to NormalTimelock only). +const grant = (target: string, signature: string, accounts: string[] = [NORMAL_TIMELOCK]) => + accounts.map(account => ({ + target: ACM, + signature: "giveCallPermission(address,string,address)", + params: [target, signature, account], + })); + +// The off-chain admin (Guardian) runs the epoch operations directly (ranking + issue/burn, +// mint-threshold updates, and the one-time staker seeding), so it gets those permissions in +// addition to the NormalTimelock. +const KEEPER_ACCOUNTS = [NORMAL_TIMELOCK, GUARDIAN]; + +// ACM-gated functions on PrimeV2 (see PrimeV2.sol _checkAccessAllowed) +const PRIME_V2_PERMISSIONS = [ + ...grant(PRIME_V2, "issue(address)", KEEPER_ACCOUNTS), + ...grant(PRIME_V2, "issueBatch(address[])", KEEPER_ACCOUNTS), + ...grant(PRIME_V2, "burn(address)", KEEPER_ACCOUNTS), + ...grant(PRIME_V2, "burnBatch(address[])", KEEPER_ACCOUNTS), + ...grant(PRIME_V2, "setPrimeLeaderboard(address)"), + ...grant(PRIME_V2, "addMarket(address,uint256,uint256)"), + ...grant(PRIME_V2, "removeMarket(address)"), + ...grant(PRIME_V2, "setLimit(uint256)"), + ...grant(PRIME_V2, "updateAlpha(uint128,uint128)"), + ...grant(PRIME_V2, "updateMultipliers(address,uint256,uint256)"), + ...grant(PRIME_V2, "setMaxLoopsLimit(uint256)"), + ...grant(PRIME_V2, "setMintThreshold(uint256,uint256)", KEEPER_ACCOUNTS), + ...grant(PRIME_V2, "pause()", ALL_TIMELOCKS), + ...grant(PRIME_V2, "unpause()", ALL_TIMELOCKS), +]; + +// ACM-gated functions on PrimeLeaderboard (see PrimeLeaderboard.sol _checkAccessAllowed) +const PRIME_LEADERBOARD_PERMISSIONS = [ + ...grant(PRIME_LEADERBOARD, "initializeStakers(address[],uint256[],uint64[])", KEEPER_ACCOUNTS), + ...grant(PRIME_LEADERBOARD, "finalizeInitialization()", KEEPER_ACCOUNTS), + ...grant(PRIME_LEADERBOARD, "setMultiplierTiers(uint256[],uint256[])"), + ...grant(PRIME_LEADERBOARD, "setPrimeV2(address)"), + ...grant(PRIME_LEADERBOARD, "setMaxLoopsLimit(uint256)"), +]; + +const vip675 = () => { + const meta = { + version: "v2", + title: "VIP-675 [Testnet] Deploy and configure PrimeV2 and PrimeLeaderboard", + description: `#### Summary + +If passed, this VIP will bring the new PrimeV2 and PrimeLeaderboard contracts live on BNB Chain testnet: it accepts their ownership, grants the required ACM permissions, wires the two contracts together, points the existing PrimeLiquidityProvider at PrimeV2, configures the Prime markets, opens the permissionless mint window, switches the XVS Vault hook and the Core pool Comptroller from the legacy Prime to the new contracts, and pauses the legacy Prime. + +#### Description + +If passed, this VIP will: + +- Accept ownership of PrimeV2 and PrimeLeaderboard (previously transferred to the Normal Timelock by the deploy script). +- Grant ACM permissions: configuration functions to the Normal Timelock; the epoch operations (issue/issueBatch/burn/burnBatch and setMintThreshold on PrimeV2, and initializeStakers/finalizeInitialization on PrimeLeaderboard) to both the Normal Timelock and the Guardian; pause/unpause to all three timelocks. +- Wire the contracts together by setting PrimeLeaderboard on PrimeV2 and PrimeV2 on PrimeLeaderboard. +- Point the existing PrimeLiquidityProvider at PrimeV2 so Prime rewards accrue to the new contract. +- Add the Core pool markets to PrimeV2 (vUSDT, vUSDC, vBTC, vETH) with the same supply/borrow multipliers used by the legacy Prime. +- Open the permissionless mint window via setMintThreshold (minimum effective stake of 1 XVS, no deadline). +- Switch the XVS Vault prime hook from the legacy Prime to PrimeLeaderboard, so vault deposits/withdrawals update the leaderboard (which in turn calls PrimeV2). Reward token and pool id are unchanged (XVS, pool 1). +- Update the Core pool Comptroller's prime address from the legacy Prime to PrimeV2, so market hooks call the new contract. +- Pause the legacy Prime to decommission it. + +The leaderboard multiplier tiers (30/60/90 days mapping to 1.3x/1.6x/2.0x) and the PrimeV2 token limit (500) are set in the contracts' initializers, so they are not re-set here. Seeding existing stakers into PrimeLeaderboard (initializeStakers + finalizeInitialization) is performed off-chain by the Guardian using a staker snapshot built from XVS vault history. + +#### References + +- PrimeV2 / PrimeLeaderboard implementation: https://github.com/VenusProtocol/venus-protocol/pull/676 +- PrimeV2 / PrimeLeaderboard testnet deployments: https://github.com/VenusProtocol/venus-protocol/pull/677`, + 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. Accept ownership (deploy script transferred ownership to NormalTimelock) + { + target: PRIME_V2, + signature: "acceptOwnership()", + params: [], + }, + { + target: PRIME_LEADERBOARD, + signature: "acceptOwnership()", + params: [], + }, + + // 2. Grant ACM permissions + ...PRIME_V2_PERMISSIONS, + ...PRIME_LEADERBOARD_PERMISSIONS, + + // 3. Wire PrimeV2 <-> PrimeLeaderboard (ACM-gated; deploy does NOT wire on live networks) + { + target: PRIME_V2, + signature: "setPrimeLeaderboard(address)", + params: [PRIME_LEADERBOARD], + }, + { + target: PRIME_LEADERBOARD, + signature: "setPrimeV2(address)", + params: [PRIME_V2], + }, + + // 4. Point the existing PrimeLiquidityProvider at PrimeV2 (onlyOwner = NormalTimelock) + { + target: PLP, + signature: "setPrimeToken(address)", + params: [PRIME_V2], + }, + + // 5. Configure PrimeV2 markets (supply/borrow multipliers mirror the legacy Prime) + ...PRIME_MARKETS.map(market => ({ + target: PRIME_V2, + signature: "addMarket(address,uint256,uint256)", + params: [market.vToken, market.supplyMultiplier, market.borrowMultiplier], + })), + + // 6. Open the permissionless mint window (mintThreshold must be > 0; reverts + // MintThresholdNotSet while 0). Second param is the mint deadline (unix ts, 0 = no expiry). + { + target: PRIME_V2, + signature: "setMintThreshold(uint256,uint256)", + params: [MINT_THRESHOLD, MINT_DEADLINE], + }, + + // 7. Switch the XVS Vault hook from the legacy Prime to PrimeLeaderboard, so vault + // deposit/withdraw events update the leaderboard (which in turn calls PrimeV2). + // setPrimeToken on the vault is onlyAdmin (admin = NormalTimelock). + { + target: XVS_VAULT, + signature: "setPrimeToken(address,address,uint256)", + params: [PRIME_LEADERBOARD, XVS, XVS_VAULT_POOL_ID], + }, + + // 8. Point the Core pool Comptroller at PrimeV2 so market hooks call the new contract. + // setPrimeToken is admin-gated (admin = NormalTimelock); no ACM grant needed. + { + target: COMPTROLLER, + signature: "setPrimeToken(address)", + params: [PRIME_V2], + }, + + // Note: PrimeLeaderboard staker seeding (initializeStakers + finalizeInitialization) is + // NOT done in this VIP. The full staker snapshot (addresses, amounts, timestamps) must be + // built off-chain from XVS vault logs and submitted in batches by the Guardian, which holds + // the initializeStakers / finalizeInitialization ACM permissions granted above. + + // 9. Decommission the legacy Prime: pause it (halts claim / score updates / issuance). + // NormalTimelock already holds the togglePause ACM permission, so no grant is needed. + // Legacy Prime is currently unpaused, so a single togglePause pauses it. + { + target: LEGACY_PRIME, + signature: "togglePause()", + params: [], + }, + ], + meta, + ProposalType.REGULAR, + ); +}; + +export default vip675;