diff --git a/test/integration/Pool/pool.js b/test/integration/Pool/pool.js new file mode 100644 index 0000000000..bbea5a92bd --- /dev/null +++ b/test/integration/Pool/pool.js @@ -0,0 +1,108 @@ +const { ethers, nexus } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const setup = require('../setup'); + +const { parseEther, parseUnits } = ethers; +const { Assets, PoolAsset, AggregatorType } = nexus.constants; +const { getInternalPrice } = nexus.protocol; +const { BigIntMath } = nexus.helpers; + +describe('Pool functions', function () { + it('getInternalTokenPriceInAsset returns spot price for all assets', async function () { + const fixture = await loadFixture(setup); + const { + pool, + ramm, + tokenController, + chainlinkEthUsd, + chainlinkDAI, + chainlinkUSDC, + chainlinkSteth, + chainlinkReth, + chainlinkEnzymeVault, + chainlinkCbBTC, + safeTracker, + } = fixture.contracts; + + const { timestamp } = await ethers.provider.getBlock('latest'); + const expectedTokenPriceInEth = await getInternalPrice(ramm, pool, tokenController, timestamp); + const ethUsdRate = await chainlinkEthUsd.latestAnswer(); + const ethOracle = { latestAnswer: () => ethers.parseEther('1') }; + + const assetTests = [ + { assetId: PoolAsset.ETH, name: 'ETH', oracle: ethOracle, decimals: 18 }, + { assetId: PoolAsset.DAI, name: 'DAI', oracle: chainlinkDAI, decimals: 18 }, + { assetId: PoolAsset.stETH, name: 'stETH', oracle: chainlinkSteth, decimals: 18 }, + { assetId: PoolAsset.NXMTY, name: 'NXMTY', oracle: chainlinkEnzymeVault, decimals: 18 }, + { assetId: PoolAsset.rETH, name: 'rETH', oracle: chainlinkReth, decimals: 18 }, + { assetId: PoolAsset.SafeTracker, name: 'SafeTracker', oracle: safeTracker, decimals: 18 }, + { assetId: PoolAsset.USDC, name: 'USDC', oracle: chainlinkUSDC, decimals: 6 }, + { assetId: PoolAsset.cbBTC, name: 'cbBTC', oracle: chainlinkCbBTC, rateType: AggregatorType.USD, decimals: 8 }, + ]; + + for (const testCase of assetTests) { + const actualTokenPrice = await pool.getInternalTokenPriceInAsset(testCase.assetId); + const assetRate = await testCase.oracle.latestAnswer(); + + let expectedTokenPrice = expectedTokenPriceInEth; // rateType is ETH by default + if (testCase.rateType === AggregatorType.USD) { + // convert ETH rate to USD rate if USD rateType + expectedTokenPrice = (expectedTokenPrice * ethUsdRate) / parseEther('1'); + } + + const expectedPrice = (expectedTokenPrice * parseUnits('1', testCase.decimals)) / assetRate; + const errMessage = `${testCase.name} token price mismatch. Expected: ${expectedPrice}, Got: ${actualTokenPrice}`; + + expect(actualTokenPrice).to.be.equal(expectedPrice, errMessage); + } + }); + + it('getPoolValueInEth calculates pool value correctly', async function () { + const fixture = await loadFixture(setup); + const { pool, dai, usdc, stETH, rETH, safeTracker, enzymeVault, cbBTC } = fixture.contracts; + + const totalAssetValue = await pool.getPoolValueInEth(); + const poolAssets = await pool.getAssets(); + const ethAsset = { + target: Assets.ETH, + balanceOf: address => ethers.provider.getBalance(address), + }; + + const expectedAssets = [ethAsset, dai, stETH, enzymeVault, rETH, safeTracker, usdc, cbBTC]; + const expectedAssetAddresses = expectedAssets.map(({ target }) => target); + + // verify all expected assets are present + const poolAssetAddresses = poolAssets.map(([assetAddress]) => assetAddress); + expectedAssetAddresses.forEach(addr => expect(poolAssetAddresses).to.include(addr)); + + // get all asset balances and convert to ETH values + const assetValuesInEth = await Promise.all( + expectedAssets.map(async asset => { + const balance = await asset.balanceOf(pool.target); + return asset.target === Assets.ETH ? balance : pool.getEthForAsset(asset.target, balance); + }), + ); + + expect(totalAssetValue).to.be.equal(BigIntMath.sum(assetValuesInEth)); + }); + + it('getMCRRatio calculates MCR ratio correctly', async function () { + const fixture = await loadFixture(setup); + const { pool } = fixture.contracts; + + const totalAssetValue = await pool.getPoolValueInEth(); + const mcr = await pool.getMCR(); + + expect(totalAssetValue).to.be.gt(0n); + expect(mcr).to.be.gt(0n); + + const MCR_RATIO_DECIMALS = await pool.MCR_RATIO_DECIMALS(); + const mcrRatio = await pool.getMCRRatio(); + const expectedMcrRatio = (totalAssetValue * 10n ** MCR_RATIO_DECIMALS) / mcr; + + expect(mcrRatio).to.be.gt(0n); + expect(mcrRatio).to.be.equal(expectedMcrRatio); + }); +}); diff --git a/test/integration/Ramm/swap.js b/test/integration/Ramm/swap.js new file mode 100644 index 0000000000..506e1ab0f9 --- /dev/null +++ b/test/integration/Ramm/swap.js @@ -0,0 +1,284 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { + setNextBlockBaseFeePerGas, + setBalance, + impersonateAccount, + time, +} = require('@nomicfoundation/hardhat-network-helpers'); +const { getEventsFromTxReceipt } = require('../utils/helpers'); +const setup = require('../setup'); + +const { parseEther, ZeroAddress } = ethers; + +async function getCapitalSupplyAndBalances(pool, tokenController, token, memberAddress) { + return { + ethCapital: await pool.getPoolValueInEth(), + nxmSupply: await tokenController.totalSupply(), + ethBalance: await ethers.provider.getBalance(memberAddress), + nxmBalance: await token.balanceOf(memberAddress), + }; +} + +async function calculateExpectedSwapOutput(ramm, pool, tokenController, input, isEthToNxm, timestamp) { + const readOnlyInitState = await ramm.loadState(); + + const initState = { + nxmA: readOnlyInitState.nxmA, + nxmB: readOnlyInitState.nxmB, + eth: readOnlyInitState.eth, + budget: readOnlyInitState.budget, + ratchetSpeedB: readOnlyInitState.ratchetSpeedB, + timestamp: readOnlyInitState.timestamp, + }; + + const context = { + capital: await pool.getPoolValueInEth(), + supply: await tokenController.totalSupply(), + mcr: await pool.getMCR(), + }; + const [readOnlyState] = await ramm._getReserves(initState, context, timestamp); + + const state = { + eth: readOnlyState.eth, + nxmA: readOnlyState.nxmA, + nxmB: readOnlyState.nxmB, + budget: readOnlyState.budget, + ratchetSpeedB: readOnlyState.ratchetSpeedB, + timestamp: readOnlyState.timestamp, + }; + + if (isEthToNxm) { + // ETH -> NXM: k = eth * nxmA + const k = state.eth * state.nxmA; + const newEth = state.eth + input; + const newNxmA = k / newEth; + return state.nxmA - newNxmA; + } else { + // NXM -> ETH: k = eth * nxmB + const k = state.eth * state.nxmB; + const newNxmB = state.nxmB + input; + const newEth = k / newNxmB; + return state.eth - newEth; + } +} + +async function swapSetup() { + const fixture = await loadFixture(setup); + const { token, pool, tokenController } = fixture.contracts; + const [member] = fixture.accounts.members; + + await impersonateAccount(tokenController.target); + const tokenControllerSigner = await ethers.getSigner(tokenController.target); + await setBalance(tokenController.target, parseEther('10000')); + await setBalance(member.address, parseEther('10000')); + await setBalance(pool.target, parseEther('145000')); + + await token.connect(tokenControllerSigner).mint(member.address, parseEther('10000')); + await token.connect(member).approve(tokenController.target, parseEther('10000')); + + return fixture; +} + +describe('swap', function () { + it('should revert if both NXM and ETH values are 0', async function () { + const fixture = await loadFixture(swapSetup); + const { ramm } = fixture.contracts; + const [member] = fixture.accounts.members; + + const swap = ramm.connect(member).swap(0, 0, 0, { value: 0 }); + await expect(swap).to.be.revertedWithCustomError(ramm, 'OneInputRequired'); + }); + + it('should revert if both NXM and ETH values are greater then 0', async function () { + const fixture = await loadFixture(setup); + const { ramm } = fixture.contracts; + const [member] = fixture.accounts.members; + + const nxmIn = parseEther('1'); + const ethIn = parseEther('1'); + + const swap = ramm.connect(member).swap(nxmIn, 0, 0, { value: ethIn }); + await expect(swap).to.be.revertedWithCustomError(ramm, 'OneInputOnly'); + }); + + it('should revert if nxmOut < minAmountOut when swapping ETH for NXM', async function () { + const fixture = await loadFixture(setup); + const { ramm, pool, tokenController, token } = fixture.contracts; + const [member] = fixture.accounts.members; + + const ethIn = parseEther('1'); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = timestamp + 54 * 60 * 60; // +54 hours to stabilize price + const deadline = nextBlockTimestamp + 15 * 60; // add 15 minutes + + // Get expected book value + const failureTimestamp = nextBlockTimestamp + 2 * 60; // +2 minutes + const isEthToNxm = true; + const expectedNxmOut = await calculateExpectedSwapOutput( + ramm, + pool, + tokenController, + ethIn, + isEthToNxm, + failureTimestamp, + ); + + // InsufficientAmountOut (minNxmOut higher than expected) + const minNxmOutFail = expectedNxmOut + 1n; + await setNextBlockBaseFeePerGas(0); + await time.setNextBlockTimestamp(failureTimestamp); + const swapFail = ramm.connect(member).swap(0, minNxmOutFail, deadline, { + value: ethIn, + maxPriorityFeePerGas: 0, + }); + await expect(swapFail).to.be.revertedWithCustomError(ramm, 'InsufficientAmountOut'); + const before = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + + // Min amount out success: +3 minutes enough for price to adjust and execute the swap + await setNextBlockBaseFeePerGas(0); + await time.setNextBlockTimestamp(nextBlockTimestamp + 3 * 60); + const tx = await ramm.connect(member).swap(0, expectedNxmOut, deadline, { value: ethIn, maxPriorityFeePerGas: 0 }); + const swapTxReceipt = await tx.wait(); + + const after = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + const nxmReceived = after.nxmBalance - before.nxmBalance; + + const [nxmTransferEvent] = getEventsFromTxReceipt(swapTxReceipt, token, 'Transfer', { + from: ZeroAddress, + to: member.address, + }); + const nxmOut = nxmTransferEvent?.args?.value; + + expect(after.ethCapital).to.be.equal(before.ethCapital + ethIn); // ETH goes into capital pool + expect(after.nxmSupply).to.be.equal(before.nxmSupply + nxmReceived); // NXM out is minted + expect(after.ethBalance).to.be.equal(before.ethBalance - ethIn); // member sends ETH + expect(after.nxmBalance).to.be.equal(before.nxmBalance + nxmOut); // member receives NXM + }); + + it('should revert if ethOut < minAmountOut when swapping NXM for ETH', async function () { + const fixture = await loadFixture(swapSetup); + const { ramm, pool, tokenController, token } = fixture.contracts; + const [member] = fixture.accounts.members; + + const nxmIn = parseEther('1'); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = timestamp + 3 * 60 * 60; // +3 hours to stabilize price + const deadline = nextBlockTimestamp + 15 * 60; // add 15 minutes + + // Get expected book value + const failureTimestamp = nextBlockTimestamp + 2 * 60; // +2 minutes + const isEthToNxm = false; + const expectedEthOut = await calculateExpectedSwapOutput( + ramm, + pool, + tokenController, + nxmIn, + isEthToNxm, + failureTimestamp, + ); + + // InsufficientAmountOut (minEthOut higher than expected) + const minEthOutFail = expectedEthOut + 1n; + await setNextBlockBaseFeePerGas(0); + await time.setNextBlockTimestamp(failureTimestamp); + const swapFail = ramm.connect(member).swap(nxmIn, minEthOutFail, deadline, { + maxPriorityFeePerGas: 0, + }); + await expect(swapFail).to.be.revertedWithCustomError(ramm, 'InsufficientAmountOut'); + + const before = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + + // Min amount out success: +3 minutes enough for price to adjust and execute the swap + await setNextBlockBaseFeePerGas(0); + await time.setNextBlockTimestamp(nextBlockTimestamp + 3 * 60); + await ramm.connect(member).swap(nxmIn, expectedEthOut, deadline, { + maxPriorityFeePerGas: 0, + }); + + const after = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + const ethReceived = after.ethBalance - before.ethBalance; + + expect(after.nxmSupply).to.be.equal(before.nxmSupply - nxmIn); // nxmIn is burned + expect(after.ethCapital).to.be.equal(before.ethCapital - ethReceived); // ETH goes out of capital pool + expect(after.nxmBalance).to.be.equal(before.nxmBalance - nxmIn); // member sends NXM + expect(after.ethBalance).to.be.equal(before.ethBalance + ethReceived); // member receives ETH + }); + + it('should revert if block timestamp surpasses deadline', async function () { + const fixture = await loadFixture(setup); + const { ramm } = fixture.contracts; + const [member] = fixture.accounts.members; + + const nxmIn = parseEther('1'); + const minAmountOut = parseEther('0.015'); // 0.0152 ETH initial spot price + const { timestamp } = await ethers.provider.getBlock('latest'); + const deadline = timestamp - 1; + + const swap = ramm.connect(member).swap(nxmIn, minAmountOut, deadline); + await expect(swap).to.be.revertedWithCustomError(ramm, 'SwapExpired'); + }); + + it('should swap ETH for NXM', async function () { + const fixture = await loadFixture(setup); + const { ramm, token, pool, tokenController } = fixture.contracts; + const [member] = fixture.accounts.members; + + const ethIn = parseEther('1'); + const minNxmOut = parseEther('28.8'); + const before = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const deadline = timestamp + 15 * 60; // add 15 minutes + + await setNextBlockBaseFeePerGas(0); + const tx = await ramm.connect(member).swap(0, minNxmOut, deadline, { value: ethIn, maxPriorityFeePerGas: 0 }); + const swapTxReceipt = await tx.wait(); + + const after = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + const nxmReceived = after.nxmBalance - before.nxmBalance; + const [nxmTransferEvent] = getEventsFromTxReceipt(swapTxReceipt, token, 'Transfer', { + from: ZeroAddress, + to: member.address, + }); + const nxmOut = nxmTransferEvent?.args?.value; + + expect(after.ethCapital).to.be.equal(before.ethCapital + ethIn); // ETH goes into capital pool + expect(after.nxmSupply).to.be.equal(before.nxmSupply + nxmReceived); // NXM out is minted + expect(after.ethBalance).to.be.equal(before.ethBalance - ethIn); // member sends ETH + expect(after.nxmBalance).to.be.equal(before.nxmBalance + nxmOut); // member receives NXM + }); + + it('should swap NXM for ETH', async function () { + const fixture = await loadFixture(swapSetup); + const { ramm, pool, tokenController, token } = fixture.contracts; + const [member] = fixture.accounts.members; + + const nxmIn = parseEther('1'); + const minEthOut = parseEther('0.0125'); // Lower minimum to account for different market conditions + const before = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const deadline = timestamp + 15 * 60; // +15 minutes + + await setNextBlockBaseFeePerGas(0); + const tx = await ramm.connect(member).swap(nxmIn, minEthOut, deadline, { maxPriorityFeePerGas: 0 }); + const swapTxReceipt = await tx.wait(); + + const after = await getCapitalSupplyAndBalances(pool, tokenController, token, member.address); + const ethReceived = after.ethBalance - before.ethBalance; + const [nxmSwappedForEthEvent] = getEventsFromTxReceipt(swapTxReceipt, ramm, 'NxmSwappedForEth', { + member: member.address, + }); + const ethOut = nxmSwappedForEthEvent?.args?.ethOut; + + expect(after.nxmBalance).to.be.equal(before.nxmBalance - nxmIn); // member sends NXM + expect(after.nxmSupply).to.be.equal(before.nxmSupply - nxmIn); // nxmIn is burned + expect(after.ethCapital).to.be.equal(before.ethCapital - ethReceived); // ETH goes out of capital pool + expect(after.ethBalance).to.be.equal(before.ethBalance + ethOut); // member receives ETH + }); +}); diff --git a/test/integration/StakingPool/stakingNFTDescriptor.js b/test/integration/StakingPool/stakingNFTDescriptor.js new file mode 100644 index 0000000000..7ab4a23d84 --- /dev/null +++ b/test/integration/StakingPool/stakingNFTDescriptor.js @@ -0,0 +1,197 @@ +const { ethers, nexus } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture, setBalance, impersonateAccount } = require('@nomicfoundation/hardhat-network-helpers'); +const { time } = require('@nomicfoundation/hardhat-network-helpers'); + +const setup = require('../setup'); +const { daysToSeconds } = require('../utils'); +const { mineNextBlock } = require('../../utils/evm'); +const base64 = require('base64-js'); + +const { calculateFirstTrancheId } = nexus.protocol; +const { parseEther, formatEther, MaxUint256 } = ethers; + +const svgHeader = 'data:image/svg+xml;base64,'; +const jsonHeader = 'data:application/json;base64,'; + +async function stakingNFTDescriptorSetup() { + const fixture = await loadFixture(setup); + const { members } = fixture.accounts; + + const [, , staker] = members; + const { stakingPool1, token, tokenController } = fixture.contracts; + const stakingAmount = parseEther('170.091'); + const block = await ethers.provider.getBlock('latest'); + const firstTrancheId = calculateFirstTrancheId(block, 60, 30); + + const operatorAddress = await token.operator(); + await impersonateAccount(operatorAddress); + const operator = await ethers.getSigner(operatorAddress); + await setBalance(operatorAddress, parseEther('10000')); + await token.connect(operator).mint(staker.address, parseEther('10000')); + await token.connect(staker).approve(tokenController.target, MaxUint256); + + // tokenIdA large deposit + const largeDepositAmount = stakingAmount * 2n; + const tokenAParams = [largeDepositAmount, firstTrancheId, 0, staker.address]; + const tokenIdA = await stakingPool1.connect(staker).depositTo.staticCall(...tokenAParams); + await stakingPool1.connect(staker).depositTo(...tokenAParams); + + // tokenIdB small deposit + const smallDepositAmount = stakingAmount; + const tokenBParams = [smallDepositAmount, firstTrancheId + 1, 0, staker.address]; + const tokenIdB = await stakingPool1.connect(staker).depositTo.staticCall(...tokenBParams); + await stakingPool1.connect(staker).depositTo(...tokenBParams); + + return { + ...fixture, + stakingAmount, + largeDepositAmount, + smallDepositAmount, + testTokenIdA: tokenIdA, + testTokenIdB: tokenIdB, + testStaker: staker, + firstTrancheId, + }; +} + +describe('StakingNFTDescriptor', function () { + it('tokenURI json output should be formatted properly', async function () { + const fixture = await loadFixture(stakingNFTDescriptorSetup); + const { stakingNFT } = fixture.contracts; + + const uri = await stakingNFT.tokenURI(fixture.testTokenIdA); + + const jsonHeader = 'data:application/json;base64,'; + expect(uri.slice(0, jsonHeader.length)).to.be.equal(jsonHeader); + + const decodedJson = JSON.parse(new TextDecoder().decode(base64.toByteArray(uri.slice(jsonHeader.length)))); + expect(decodedJson.name).to.be.equal('Nexus Mutual Deposit'); + expect(decodedJson.description.length).to.be.gt(0); + + const { description } = decodedJson; + const expectedTotalStake = formatEther(fixture.largeDepositAmount); + expect(description).to.contain(`Staked amount: ${Number(expectedTotalStake).toFixed(2)} NXM`); + expect(description).to.contain('Pending rewards: 0.00 NXM'); + + // active deposits + expect(description).to.contain('Active deposits:'); + const largeDepositFormatted = Number(formatEther(fixture.largeDepositAmount)).toFixed(2); + expect(description).to.contain(`-${largeDepositFormatted} NXM will expire at tranche:`); + + // tranche + const expectedTrancheA = fixture.firstTrancheId.toString(); + expect(description).to.contain(`-${largeDepositFormatted} NXM will expire at tranche: ${expectedTrancheA}`); + + const depositMatches = description.match(new RegExp(`-${largeDepositFormatted} NXM will expire at tranche:`, 'g')); + expect(depositMatches).to.have.length(1, 'Should show single large deposit in active deposits section'); + + const expectedAmount = formatEther(fixture.largeDepositAmount); + expect(decodedJson.image.slice(0, svgHeader.length)).to.be.equal(svgHeader); + const decodedSvg = new TextDecoder().decode(base64.toByteArray(decodedJson.image.slice(svgHeader.length))); + expect(decodedSvg).to.match(/4<\/tspan>/); // tokenId A is 4 + expect(decodedSvg).to.contain(Number(expectedAmount).toFixed(2)); + }); + + it('tokenURI with single deposit should be formatted properly', async function () { + const fixture = await loadFixture(stakingNFTDescriptorSetup); + const { stakingNFT } = fixture.contracts; + const uri = await stakingNFT.tokenURI(Number(fixture.testTokenIdB)); + + const jsonHeader = 'data:application/json;base64,'; + expect(uri.slice(0, jsonHeader.length)).to.be.equal(jsonHeader); + + const expectedDepositAmount = Number(formatEther(fixture.stakingAmount)).toFixed(2); + const decodedJson = JSON.parse(new TextDecoder().decode(base64.toByteArray(uri.slice(jsonHeader.length)))); + expect(decodedJson.name).to.be.equal('Nexus Mutual Deposit'); + expect(decodedJson.description.length).to.be.gt(0); + + const { description } = decodedJson; + + const expectedTotalStake = formatEther(fixture.smallDepositAmount); + expect(description).to.contain(`Staked amount: ${Number(expectedTotalStake).toFixed(2)} NXM`); + expect(description).to.contain('Pending rewards: 0.00 NXM'); + + // active deposits + expect(description).to.contain('Active deposits:'); + expect(description).to.contain(`-${expectedDepositAmount} NXM will expire at tranche:`); + + // trancheId + const expectedTrancheB = (fixture.firstTrancheId + 1).toString(); + expect(description).to.contain(`-${expectedDepositAmount} NXM will expire at tranche: ${expectedTrancheB}`); + + const depositMatches = description.match(new RegExp(`-${expectedDepositAmount} NXM will expire at tranche:`, 'g')); + expect(depositMatches).to.have.length(1, 'Should show single small deposit in active deposits section'); + + expect(decodedJson.image.slice(0, svgHeader.length)).to.be.equal(svgHeader); + const decodedSvg = new TextDecoder().decode(base64.toByteArray(decodedJson.image.slice(svgHeader.length))); + expect(decodedSvg).to.match(/5<\/tspan>/); // tokenId B is 5 + expect(decodedSvg).to.contain(Number(expectedDepositAmount).toFixed(2)); + }); + + it('should handle expired tokens', async function () { + const fixture = await loadFixture(stakingNFTDescriptorSetup); + const { stakingNFT } = fixture.contracts; + + const { timestamp } = await ethers.provider.getBlock('latest'); + await time.setNextBlockTimestamp(timestamp + daysToSeconds(1000)); + await mineNextBlock(); + + const uri = await stakingNFT.tokenURI(Number(fixture.testTokenIdA)); + const decodedJson = JSON.parse(new TextDecoder().decode(base64.toByteArray(uri.slice(jsonHeader.length)))); + expect(decodedJson.description).to.contain('Deposit has expired'); + + const decodedSvg = new TextDecoder().decode(base64.toByteArray(decodedJson.image.slice(svgHeader.length))); + // poolId + tokenID + expect(decodedSvg).to.match(/1<\/tspan>/); + expect(decodedSvg).to.match(/0.00 NXM<\/tspan>/); + }); + + it('should parse random decimals properly', async function () { + const fixture = await loadFixture(stakingNFTDescriptorSetup); + const { stakingNFTDescriptor } = fixture.contracts; + + const promises = []; + for (let i = 0; i < 100; i++) { + const random = Math.random().toFixed(18); + const randomWei = parseEther(random.toString()); + + const expected = formatEther(randomWei); + promises.push( + stakingNFTDescriptor.toFloat(randomWei, 18).then(res => { + expect(res).to.be.equal(expected.slice(0, 4)); + }), + ); + } + await Promise.all(promises); + }); + + it('should parse decimals properly', async function () { + const fixture = await loadFixture(stakingNFTDescriptorSetup); + const { stakingNFTDescriptor } = fixture.contracts; + expect(await stakingNFTDescriptor.toFloat(614955363329695600n, 18)).to.be.equal('0.61'); + expect('0.00').to.be.equal(await stakingNFTDescriptor.toFloat(1, 3)); + expect('1.00').to.be.equal(await stakingNFTDescriptor.toFloat(1000000, 6)); + expect('1.11').to.be.equal(await stakingNFTDescriptor.toFloat(111111, 5)); + expect('1.01').to.be.equal(await stakingNFTDescriptor.toFloat(1011111, 6)); + expect('103.00').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('103'), 18)); + expect('123.00').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('123'), 18)); + expect('0.00').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('.001'), 18)); + expect('0.01').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('.01'), 18)); + expect('0.10').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('.1'), 18)); + expect('1.00').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('1'), 18)); + expect('0.00').to.be.equal(await stakingNFTDescriptor.toFloat(0, 18)); + expect('12345.67').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('12345.6789'), 18)); + expect('17.09').to.be.equal(await stakingNFTDescriptor.toFloat('17090000000000000000', 18)); + expect('0.00').to.be.equal(await stakingNFTDescriptor.toFloat(0, 0)); + expect('1111110.00').to.be.equal(await stakingNFTDescriptor.toFloat(1111110, 0)); + expect('1.00').to.be.equal(await stakingNFTDescriptor.toFloat(1, 0)); + expect('0.10').to.be.equal(await stakingNFTDescriptor.toFloat(1, 1)); + expect('0.90').to.be.equal(await stakingNFTDescriptor.toFloat(9, 1)); + expect('0.00').to.be.equal(await stakingNFTDescriptor.toFloat(0, 2)); + expect('0.01').to.be.equal(await stakingNFTDescriptor.toFloat(1, 2)); + expect('0.99').to.be.equal(await stakingNFTDescriptor.toFloat(99, 2)); + expect('0.09').to.be.equal(await stakingNFTDescriptor.toFloat(9, 2)); + expect('987654321012.00').to.be.equal(await stakingNFTDescriptor.toFloat(parseEther('987654321012'), 18)); + }); +}); diff --git a/test/integration/setup.js b/test/integration/setup.js index fbbf989a7d..e291015492 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -1,5 +1,5 @@ const { ethers, nexus } = require('hardhat'); -const { setBalance, impersonateAccount } = require('@nomicfoundation/hardhat-network-helpers'); +const { setBalance, impersonateAccount, setStorageAt } = require('@nomicfoundation/hardhat-network-helpers'); const { parseEther, parseUnits, ZeroAddress, MaxUint256 } = ethers; const { ContractIndexes, ClaimMethod, AggregatorType, Assets, PoolAsset } = nexus.constants; @@ -586,6 +586,18 @@ async function setup() { await fixture.contracts.stakingPool2.connect(staker).depositTo(stakeAmount, trancheId, 0, staker.address); await fixture.contracts.stakingPool3.connect(staker).depositTo(stakeAmount, trancheId, 0, staker.address); + // Set pool MCR + const mcrStorageSlot = 3; + const storedMcr = parseEther('5000'); + const desiredMcr = parseEther('3000'); + + const packedMcrData = ethers.solidityPacked( + ['uint80', 'uint80', 'uint32'], + [storedMcr, desiredMcr, latestBlock.timestamp], + ); + + await setStorageAt(pool.target, mcrStorageSlot, ethers.zeroPadValue(packedMcrData, 32)); + const config = { MAX_RENEWABLE_PERIOD_BEFORE_EXPIRATION: await fixture.contracts.limitOrders.MAX_RENEWABLE_PERIOD_BEFORE_EXPIRATION(), @@ -605,6 +617,9 @@ async function setup() { GLOBAL_CAPACITY_DENOMINATOR: await fixture.contracts.stakingPool1.GLOBAL_CAPACITY_DENOMINATOR(), CAPACITY_REDUCTION_DENOMINATOR: await fixture.contracts.stakingPool1.CAPACITY_REDUCTION_DENOMINATOR(), WEIGHT_DENOMINATOR: await fixture.contracts.stakingPool1.WEIGHT_DENOMINATOR(), + // MCR values set in setup + INITIAL_STORED_MCR: storedMcr, + INITIAL_DESIRED_MCR: desiredMcr, }; fixture.config = config; diff --git a/test/integration/utils/helpers.js b/test/integration/utils/helpers.js index 4ad0e8d19b..bd84b377b6 100644 --- a/test/integration/utils/helpers.js +++ b/test/integration/utils/helpers.js @@ -1,5 +1,37 @@ +/** + * Parse events from transaction receipt using ethers v6 native functionality + * @param {Object} txReceipt - Transaction receipt from tx.wait() + * @param {Object} filterContract - Contract instance to parse logs with + * @param {string|null} filterName - Optional event name filter + * @param {Object|null} filterArgs - Optional event arguments filter + * @returns {Array} Parsed events matching the filters + */ +const getEventsFromTxReceipt = (txReceipt, filterContract, filterName = null, filterArgs = null) => { + let events = txReceipt.logs + .filter(log => log.address === filterContract.target) + .map(log => { + try { + return filterContract.interface.parseLog(log); + } catch { + return null; + } + }) + .filter(event => event !== null); + + if (filterName) { + events = events.filter(event => event.name === filterName); + } + + if (filterArgs) { + events = events.filter(event => Object.entries(filterArgs).every(([key, value]) => event.args[key] === value)); + } + + return events; +}; + const daysToSeconds = days => days * 24 * 3600; module.exports = { + getEventsFromTxReceipt, daysToSeconds, };