From b98ca31197bd5f55080aa83616bc24dceb8b14c9 Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Mon, 27 Jul 2026 23:06:01 +0800 Subject: [PATCH] feat: add rVirtual conversion via RVirtualConverter Adds an open, permissionless 1:1 VIRTUAL -> rVirtual converter (RVirtualConverter, UUPS upgradeable), pre-funded with the rVirtual supply. veVirtual gains convertVeVirtualToRVirtual(id), which deletes a staking position (regardless of maturity or autoRenew state) and routes its underlying VIRTUAL through the same open converter entrypoint any wallet can call directly - no backend distribution step required. Co-Authored-By: Claude Sonnet 5 --- contracts/token/IRVirtualConverter.sol | 9 + contracts/token/RVirtualConverter.sol | 93 ++++++++ contracts/token/RVirtualConverterV2Mock.sol | 16 ++ contracts/token/veVirtual.sol | 53 +++++ test/rvirtual-converter.js | 227 ++++++++++++++++++++ test/vevirtual-rvirtual-conversion.js | 173 +++++++++++++++ 6 files changed, 571 insertions(+) create mode 100644 contracts/token/IRVirtualConverter.sol create mode 100644 contracts/token/RVirtualConverter.sol create mode 100644 contracts/token/RVirtualConverterV2Mock.sol create mode 100644 test/rvirtual-converter.js create mode 100644 test/vevirtual-rvirtual-conversion.js diff --git a/contracts/token/IRVirtualConverter.sol b/contracts/token/IRVirtualConverter.sol new file mode 100644 index 00000000..39b89c10 --- /dev/null +++ b/contracts/token/IRVirtualConverter.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IRVirtualConverter { + function convertVirtualToRVirtual( + uint256 amount, + address rVirtualReceiver + ) external; +} diff --git a/contracts/token/RVirtualConverter.sol b/contracts/token/RVirtualConverter.sol new file mode 100644 index 00000000..606b5f5d --- /dev/null +++ b/contracts/token/RVirtualConverter.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Open, permissionless 1:1 converter from VIRTUAL to rVirtual. +/// +/// Pre-funded with the full rVirtual supply before launch - conversions draw down that +/// balance rather than minting on demand. Any caller (a regular wallet, or veVirtual's +/// convertVeVirtualToRVirtual()) uses the exact same convertVirtualToRVirtual() entrypoint; +/// there is no privileged "veVirtual-only" path here. +contract RVirtualConverter is + Initializable, + ReentrancyGuardUpgradeable, + AccessControlUpgradeable, + UUPSUpgradeable +{ + using SafeERC20 for IERC20; + + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + + address public virtualToken; + address public rVirtualToken; + address public adminWallet; + + event ConvertedVirtualToRVirtual( + address indexed caller, + address indexed rVirtualReceiver, + uint256 amount + ); + event AdminWalletUpdated(address adminWallet); + event VirtualWithdrawn(address adminWallet, uint256 amount); + + function initialize( + address virtualToken_, + address rVirtualToken_ + ) external initializer { + __ReentrancyGuard_init(); + __AccessControl_init(); + __UUPSUpgradeable_init(); + + require(virtualToken_ != address(0), "Invalid virtual token"); + require(rVirtualToken_ != address(0), "Invalid rVirtual token"); + virtualToken = virtualToken_; + rVirtualToken = rVirtualToken_; + + _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); + _grantRole(ADMIN_ROLE, _msgSender()); + } + + /// @notice Convert `amount` VIRTUAL (pulled from the caller) into `amount` rVirtual, + /// sent to `rVirtualReceiver`. Fully open - no allowlist, no cap. + function convertVirtualToRVirtual( + uint256 amount, + address rVirtualReceiver + ) external nonReentrant { + require(amount > 0, "Amount must be greater than 0"); + require(rVirtualReceiver != address(0), "Invalid receiver"); + + IERC20(virtualToken).safeTransferFrom( + _msgSender(), + address(this), + amount + ); + IERC20(rVirtualToken).safeTransfer(rVirtualReceiver, amount); + + emit ConvertedVirtualToRVirtual(_msgSender(), rVirtualReceiver, amount); + } + + function setAdminWallet(address adminWallet_) external onlyRole(ADMIN_ROLE) { + require(adminWallet_ != address(0), "Invalid admin wallet"); + adminWallet = adminWallet_; + emit AdminWalletUpdated(adminWallet_); + } + + /// @notice Withdraw accumulated VIRTUAL out of this contract. Only VIRTUAL - there is + /// intentionally no withdrawal path for rVirtual or any other token here, so + /// adminWallet is never exposed to rVirtual's transfer tax. + function withdrawVirtual(uint256 amount) external nonReentrant { + require(_msgSender() == adminWallet, "Only admin wallet"); + IERC20(virtualToken).safeTransfer(adminWallet, amount); + emit VirtualWithdrawn(adminWallet, amount); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyRole(ADMIN_ROLE) {} +} diff --git a/contracts/token/RVirtualConverterV2Mock.sol b/contracts/token/RVirtualConverterV2Mock.sol new file mode 100644 index 00000000..b5e1ae19 --- /dev/null +++ b/contracts/token/RVirtualConverterV2Mock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "./RVirtualConverter.sol"; + +/// @notice Test-only V2 used to verify RVirtualConverter's UUPS upgrade path round-trips +/// cleanly. Adds one new event + trigger function on top of V1; never deployed to +/// production - exists purely so a test can upgrade forward, prove the new code is +/// live, then upgrade back and confirm the final bytecode matches the original V1. +contract RVirtualConverterV2Mock is RVirtualConverter { + event V2UpgradeMarker(string message); + + function triggerV2Marker() external { + emit V2UpgradeMarker("upgraded"); + } +} diff --git a/contracts/token/veVirtual.sol b/contracts/token/veVirtual.sol index 1de0c488..f7a74755 100644 --- a/contracts/token/veVirtual.sol +++ b/contracts/token/veVirtual.sol @@ -7,6 +7,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; import "@openzeppelin/contracts-upgradeable/governance/utils/VotesUpgradeable.sol"; +import "./IRVirtualConverter.sol"; contract veVirtual is Initializable, @@ -47,6 +48,14 @@ contract veVirtual is event AdminUnlocked(bool adminUnlocked); bool public adminUnlocked; + address public rVirtualConverter; + event RVirtualConverterUpdated(address rVirtualConverter); + event ConvertedVeVirtualToRVirtual( + address indexed user, + uint256 id, + uint256 amount + ); + function initialize( address baseToken_, uint8 maxWeeks_ @@ -298,4 +307,48 @@ contract veVirtual is } return amount; } + + /** + * @notice Set the RVirtualConverter contract that convertVeVirtualToRVirtual() forwards to. + */ + function setRVirtualConverter( + address rVirtualConverter_ + ) external onlyRole(ADMIN_ROLE) { + require(rVirtualConverter_ != address(0), "Invalid converter"); + rVirtualConverter = rVirtualConverter_; + emit RVirtualConverterUpdated(rVirtualConverter_); + } + + /** + * @notice Voluntarily give up a lock's underlying VIRTUAL (and its voting power) in + * exchange for an equal amount of rVirtual. Unlike withdraw(), this does not + * require the lock to be matured - the user is explicitly forfeiting the + * remaining lock time. The lock is deleted regardless of its autoRenew state. + * @dev Approves RVirtualConverter for exactly this lock's amount and calls its + * convertVirtualToRVirtual() - the same open entrypoint any wallet can call directly. + * There is no veVirtual-specific path on the converter side. + */ + function convertVeVirtualToRVirtual(uint256 id) external nonReentrant { + require(rVirtualConverter != address(0), "Converter not set"); + address account = _msgSender(); + uint256 index = _indexOf(account, id); + Lock memory lock = locks[account][index]; + + uint256 amount = lock.amount; + + uint256 lastIndex = locks[account].length - 1; + if (index != lastIndex) { + locks[account][index] = locks[account][lastIndex]; + } + locks[account].pop(); + + IERC20(baseToken).approve(rVirtualConverter, amount); + IRVirtualConverter(rVirtualConverter).convertVirtualToRVirtual( + amount, + account + ); + + emit ConvertedVeVirtualToRVirtual(account, id, amount); + _transferVotingUnits(account, address(0), amount); + } } diff --git a/test/rvirtual-converter.js b/test/rvirtual-converter.js new file mode 100644 index 00000000..39ddb9bf --- /dev/null +++ b/test/rvirtual-converter.js @@ -0,0 +1,227 @@ +/* +Test RVirtualConverter: an open, permissionless 1:1 VIRTUAL -> rVirtual converter. +Also verifies the UUPS upgrade path round-trips cleanly (upgrade forward, verify new code +is live, upgrade back, confirm final bytecode matches the original). +*/ +const { expect } = require("chai"); +const { ethers, upgrades } = require("hardhat"); +const { parseEther } = ethers; + +describe("RVirtualConverter", function () { + let virtual, rVirtual, converter; + let deployer, user, other, adminWallet; + + before(async function () { + [deployer, user, other, adminWallet] = await ethers.getSigners(); + }); + + beforeEach(async function () { + virtual = await ethers.deployContract("VirtualToken", [ + parseEther("1000000000"), + deployer.address, + ]); + rVirtual = await ethers.deployContract("MockERC20", [ + "rVirtual", + "rVIRTUAL", + deployer.address, + parseEther("1000000000"), + ]); + + const Converter = await ethers.getContractFactory("RVirtualConverter"); + converter = await upgrades.deployProxy(Converter, [ + virtual.target, + rVirtual.target, + ]); + + // Pre-fund the converter with rVirtual liquidity for these tests (production pre-funds + // the full 1B supply, but a smaller amount here leaves deployer enough spare VIRTUAL + // balance to fully drain it in the "insufficient liquidity" test below). + await rVirtual.transfer(converter.target, parseEther("10000")); + + await virtual.transfer(user.address, parseEther("1000")); + await virtual.connect(user).approve(converter.target, parseEther("1000")); + }); + + describe("convertVirtualToRVirtual", function () { + it("should convert at a flat 1:1 rate for any caller", async function () { + await expect( + converter.connect(user).convertVirtualToRVirtual(parseEther("100"), user.address) + ) + .to.emit(converter, "ConvertedVirtualToRVirtual") + .withArgs(user.address, user.address, parseEther("100")); + + expect(await virtual.balanceOf(user.address)).to.be.equal(parseEther("900")); + expect(await virtual.balanceOf(converter.target)).to.be.equal(parseEther("100")); + expect(await rVirtual.balanceOf(user.address)).to.be.equal(parseEther("100")); + }); + + it("should allow sending the rVirtual to a different receiver than the caller", async function () { + await converter + .connect(user) + .convertVirtualToRVirtual(parseEther("100"), other.address); + + expect(await rVirtual.balanceOf(other.address)).to.be.equal(parseEther("100")); + expect(await rVirtual.balanceOf(user.address)).to.be.equal(0); + }); + + it("should be fully open - no allowlist, no cap", async function () { + // Large relative to a normal user conversion, but within the test pool's pre-funded + // rVirtual liquidity (10000) - the point here is the absence of any allowlist/cap + // check, not exercising the liquidity limit (covered separately below). + await virtual.transfer(other.address, parseEther("5000")); + await virtual.connect(other).approve(converter.target, parseEther("5000")); + + await expect( + converter.connect(other).convertVirtualToRVirtual(parseEther("5000"), other.address) + ).to.not.be.reverted; + expect(await rVirtual.balanceOf(other.address)).to.be.equal(parseEther("5000")); + }); + + it("should revert without prior VIRTUAL approval", async function () { + await virtual.connect(user).approve(converter.target, 0); + await expect( + converter.connect(user).convertVirtualToRVirtual(parseEther("100"), user.address) + ).to.be.reverted; + }); + + it("should revert if the converter has insufficient rVirtual liquidity", async function () { + // Drain the pre-funded rVirtual balance first. + const drained = await rVirtual.balanceOf(converter.target); + // Impersonate is unnecessary - just have deployer pull it out via a huge legit conversion + // from a funded wallet to exhaust the pool, then attempt one more. + await virtual.transfer(other.address, drained); + await virtual.connect(other).approve(converter.target, drained); + await converter.connect(other).convertVirtualToRVirtual(drained, other.address); + + expect(await rVirtual.balanceOf(converter.target)).to.be.equal(0); + + await expect( + converter.connect(user).convertVirtualToRVirtual(parseEther("100"), user.address) + ).to.be.reverted; + }); + + it("should reject a zero receiver address", async function () { + await expect( + converter.connect(user).convertVirtualToRVirtual(parseEther("100"), ethers.ZeroAddress) + ).to.be.revertedWith("Invalid receiver"); + }); + + it("should reject a zero amount", async function () { + await expect( + converter.connect(user).convertVirtualToRVirtual(0, user.address) + ).to.be.revertedWith("Amount must be greater than 0"); + }); + }); + + describe("withdrawVirtual", function () { + beforeEach(async function () { + await converter.setAdminWallet(adminWallet.address); + await converter.connect(user).convertVirtualToRVirtual(parseEther("100"), user.address); + }); + + it("should allow only adminWallet to withdraw the accumulated VIRTUAL", async function () { + await expect( + converter.connect(adminWallet).withdrawVirtual(parseEther("100")) + ) + .to.emit(converter, "VirtualWithdrawn") + .withArgs(adminWallet.address, parseEther("100")); + + expect(await virtual.balanceOf(adminWallet.address)).to.be.equal(parseEther("100")); + expect(await virtual.balanceOf(converter.target)).to.be.equal(0); + }); + + it("should allow withdrawing up to the full current balance, no reserve floor", async function () { + const full = await virtual.balanceOf(converter.target); + await expect(converter.connect(adminWallet).withdrawVirtual(full)).to.not.be.reverted; + expect(await virtual.balanceOf(converter.target)).to.be.equal(0); + }); + + it("should reject withdrawal from anyone other than adminWallet", async function () { + await expect( + converter.connect(user).withdrawVirtual(parseEther("100")) + ).to.be.revertedWith("Only admin wallet"); + await expect( + converter.connect(deployer).withdrawVirtual(parseEther("100")) + ).to.be.revertedWith("Only admin wallet"); + }); + + it("should reject non-admin-role setting of adminWallet", async function () { + await expect( + converter.connect(user).setAdminWallet(other.address) + ).to.be.reverted; + }); + }); + + it("should provide no path to withdraw rVirtual or any other token", async function () { + // The contract intentionally only exposes withdrawVirtual() - there is no generic + // rescue/withdraw function for rVirtual or arbitrary tokens. + expect(converter.withdrawRVirtual).to.be.undefined; + expect(converter.rescueToken).to.be.undefined; + expect(converter.recoverToken).to.be.undefined; + }); + + describe("UUPS upgradeability", function () { + it("should upgrade to V2, run new code, upgrade back to V1, and end up with identical bytecode", async function () { + const implBefore = await upgrades.erc1967.getImplementationAddress(converter.target); + const codeBefore = await ethers.provider.getCode(implBefore); + expect(codeBefore).to.not.equal("0x"); + + // Upgrade forward to V2. RVirtualConverterV2Mock adds no new storage or initializer - + // it only appends a function/event - so the plugin's "missing initializer" heuristic + // (which fires for any child contract without its own initialize()) is a false + // positive here and safe to bypass. + const V2 = await ethers.getContractFactory("RVirtualConverterV2Mock"); + const upgraded = await upgrades.upgradeProxy(converter.target, V2, { + unsafeAllow: ["missing-initializer"], + }); + + const implAfterV2 = await upgrades.erc1967.getImplementationAddress(upgraded.target); + expect(implAfterV2).to.not.equal(implBefore); + + // Prove the new code is actually live. + await expect(upgraded.triggerV2Marker()) + .to.emit(upgraded, "V2UpgradeMarker") + .withArgs("upgraded"); + + // Existing state and functionality must survive the upgrade untouched. + expect(await upgraded.virtualToken()).to.be.equal(virtual.target); + expect(await upgraded.rVirtualToken()).to.be.equal(rVirtual.target); + await expect( + upgraded.connect(user).convertVirtualToRVirtual(parseEther("50"), user.address) + ).to.emit(upgraded, "ConvertedVirtualToRVirtual"); + + // Upgrade back to V1. + const V1 = await ethers.getContractFactory("RVirtualConverter"); + const backToV1 = await upgrades.upgradeProxy(upgraded.target, V1); + + const implAfterRoundTrip = await upgrades.erc1967.getImplementationAddress( + backToV1.target + ); + const codeAfterRoundTrip = await ethers.provider.getCode(implAfterRoundTrip); + + // Final deployed code must match the original V1 bytecode exactly - the round trip + // (V1 -> V2 -> V1) leaves the contract functionally and byte-for-byte identical to + // where it started, even though the implementation address itself differs (each + // upgrade deploys a fresh implementation contract). + expect(codeAfterRoundTrip).to.be.equal(codeBefore); + + // V2-only function must no longer be part of the (V1) interface/ABI. + expect(backToV1.triggerV2Marker).to.be.undefined; + + // Original functionality still works post-round-trip. + expect(await backToV1.virtualToken()).to.be.equal(virtual.target); + await expect( + backToV1.connect(user).convertVirtualToRVirtual(parseEther("50"), user.address) + ).to.emit(backToV1, "ConvertedVirtualToRVirtual"); + }); + + it("should reject upgrade attempts from non-admin accounts", async function () { + const V2 = await ethers.getContractFactory("RVirtualConverterV2Mock", user); + await expect( + upgrades.upgradeProxy(converter.target, V2, { + unsafeAllow: ["missing-initializer"], + }) + ).to.be.reverted; + }); + }); +}); diff --git a/test/vevirtual-rvirtual-conversion.js b/test/vevirtual-rvirtual-conversion.js new file mode 100644 index 00000000..64a36faf --- /dev/null +++ b/test/vevirtual-rvirtual-conversion.js @@ -0,0 +1,173 @@ +/* +Test veVirtual.convertVeVirtualToRVirtual(): deletes a staking position and routes its +underlying VIRTUAL through RVirtualConverter for a flat 1:1 rVirtual payout. No maturity +requirement, no autoRenew restriction - any position can be converted directly. +*/ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { parseEther } = ethers; +const { time } = require("@nomicfoundation/hardhat-network-helpers"); + +describe("veVIRTUAL - convertVeVirtualToRVirtual", function () { + let virtual, rVirtual, veVirtual, converter; + let deployer, staker, staker2, other; + + before(async function () { + [deployer, staker, staker2, other] = await ethers.getSigners(); + }); + + beforeEach(async function () { + virtual = await ethers.deployContract("VirtualToken", [ + parseEther("1000000000"), + deployer.address, + ]); + rVirtual = await ethers.deployContract("MockERC20", [ + "rVirtual", + "rVIRTUAL", + deployer.address, + parseEther("1000000000"), + ]); + + const VeVirtualContract = await ethers.getContractFactory("veVirtual"); + veVirtual = await upgrades.deployProxy(VeVirtualContract, [virtual.target, 104]); + + const ConverterContract = await ethers.getContractFactory("RVirtualConverter"); + converter = await upgrades.deployProxy(ConverterContract, [ + virtual.target, + rVirtual.target, + ]); + await rVirtual.transfer(converter.target, parseEther("1000000000")); + + await virtual.transfer(staker.address, parseEther("1000")); + await virtual.connect(staker).approve(veVirtual.target, parseEther("1000")); + }); + + it("should reject conversion when rVirtualConverter is not set", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + + await expect( + veVirtual.connect(staker).convertVeVirtualToRVirtual(id) + ).to.be.revertedWith("Converter not set"); + }); + + it("should reject non-admin setting rVirtualConverter", async function () { + await expect( + veVirtual.connect(staker).setRVirtualConverter(converter.target) + ).to.be.reverted; + }); + + describe("with rVirtualConverter configured", function () { + beforeEach(async function () { + await veVirtual.setRVirtualConverter(converter.target); + }); + + it("should convert a NOT-yet-matured lock at a flat 1:1 rate", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + + // Confirm it's genuinely not matured - withdraw() would revert here. + await expect(veVirtual.connect(staker).withdraw(id)).to.be.revertedWith( + "Lock is not expired" + ); + + await expect(veVirtual.connect(staker).convertVeVirtualToRVirtual(id)) + .to.emit(veVirtual, "ConvertedVeVirtualToRVirtual") + .withArgs(staker.address, id, parseEther("100")); + + expect(await veVirtual.numPositions(staker.address)).to.be.equal(0); + expect(await veVirtual.balanceOf(staker.address)).to.be.equal(0); + expect(await rVirtual.balanceOf(staker.address)).to.be.equal(parseEther("100")); + expect(await virtual.balanceOf(converter.target)).to.be.equal(parseEther("100")); + }); + + it("should also convert an already-matured lock", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + await time.increase(53 * 7 * 24 * 60 * 60); + + await veVirtual.connect(staker).convertVeVirtualToRVirtual(id); + expect(await rVirtual.balanceOf(staker.address)).to.be.equal(parseEther("100")); + }); + + it("should convert an auto-renewing lock too - no autoRenew restriction", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, true); + const id = (await veVirtual.locks(staker.address, 0)).id; + + await expect(veVirtual.connect(staker).convertVeVirtualToRVirtual(id)).to.not.be + .reverted; + expect(await rVirtual.balanceOf(staker.address)).to.be.equal(parseEther("100")); + expect(await veVirtual.numPositions(staker.address)).to.be.equal(0); + }); + + it("should convert regardless of remaining lock time, always at 1:1", async function () { + await virtual.transfer(staker.address, parseEther("100")); + await veVirtual.connect(staker).stake(parseEther("100"), 104, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + + // Fresh position, maximum remaining lock time - still full 1:1, no discount. + await veVirtual.connect(staker).convertVeVirtualToRVirtual(id); + expect(await rVirtual.balanceOf(staker.address)).to.be.equal(parseEther("100")); + }); + + it("should not allow converting someone else's lock id", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + + await expect( + veVirtual.connect(staker2).convertVeVirtualToRVirtual(id) + ).to.be.revertedWith("Lock not found"); + }); + + it("should not allow withdrawing or re-converting after conversion", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + await veVirtual.connect(staker).convertVeVirtualToRVirtual(id); + + await expect(veVirtual.connect(staker).withdraw(id)).to.be.revertedWith( + "Lock not found" + ); + await expect( + veVirtual.connect(staker).convertVeVirtualToRVirtual(id) + ).to.be.revertedWith("Lock not found"); + }); + + it("should only remove the converted lock, leaving other positions untouched", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); // id 1 + await veVirtual.connect(staker).stake(parseEther("50"), 52, false); // id 2 + expect(await veVirtual.numPositions(staker.address)).to.be.equal(2); + + const firstId = (await veVirtual.locks(staker.address, 0)).id; + await veVirtual.connect(staker).convertVeVirtualToRVirtual(firstId); + + expect(await veVirtual.numPositions(staker.address)).to.be.equal(1); + expect(await rVirtual.balanceOf(staker.address)).to.be.equal(parseEther("100")); + }); + + it("should remove voting power on conversion but preserve historical snapshots", async function () { + await veVirtual.connect(staker).delegate(staker.address); + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + + expect(await veVirtual.getVotes(staker.address)).to.be.equal(parseEther("100")); + const blockBeforeConversion = await ethers.provider.getBlockNumber(); + + await veVirtual.connect(staker).convertVeVirtualToRVirtual(id); + + expect(await veVirtual.getVotes(staker.address)).to.be.equal(0); + expect( + await veVirtual.getPastVotes(staker.address, blockBeforeConversion) + ).to.be.equal(parseEther("100")); + }); + + it("should not leave any residual VIRTUAL allowance on veVirtual toward the converter", async function () { + await veVirtual.connect(staker).stake(parseEther("100"), 52, false); + const id = (await veVirtual.locks(staker.address, 0)).id; + await veVirtual.connect(staker).convertVeVirtualToRVirtual(id); + + expect( + await virtual.allowance(veVirtual.target, converter.target) + ).to.be.equal(0); + }); + }); +});