From 3f216d0004f2697fd6cf9a82806ff203fe6ef408 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 1 Dec 2023 11:57:55 +0200 Subject: [PATCH 01/88] Add MembershipWithdrawn event --- contracts/interfaces/IMemberRoles.sol | 2 ++ contracts/modules/governance/MemberRoles.sol | 2 ++ 2 files changed, 4 insertions(+) diff --git a/contracts/interfaces/IMemberRoles.sol b/contracts/interfaces/IMemberRoles.sol index 76555da302..e3c672e38b 100644 --- a/contracts/interfaces/IMemberRoles.sol +++ b/contracts/interfaces/IMemberRoles.sol @@ -45,4 +45,6 @@ interface IMemberRoles { event MemberJoined(address indexed newMember, uint indexed nonce); event switchedMembership(address indexed previousMember, address indexed newMember, uint timeStamp); + + event MembershipWithdrawn(address indexed member, uint timestamp); } diff --git a/contracts/modules/governance/MemberRoles.sol b/contracts/modules/governance/MemberRoles.sol index 8eb84adb64..92d0b59720 100644 --- a/contracts/modules/governance/MemberRoles.sol +++ b/contracts/modules/governance/MemberRoles.sol @@ -253,6 +253,8 @@ contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 { _tokenController.burnFrom(msg.sender, token.balanceOf(msg.sender)); _updateRole(msg.sender, uint(Role.Member), false); _tokenController.removeFromWhitelist(msg.sender); // need clarification on whitelist + + emit MembershipWithdrawn(msg.sender, block.timestamp); } /// Switches membership from the sender's address to a new address. From 54dcc6c79931ee852686979996c79a2f67639952 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 1 Dec 2023 12:01:04 +0200 Subject: [PATCH 02/88] Add MembershipWithdrawn event unit/integration tests --- .../MemberRoles/withdrawMembership.js | 20 +++++++++++++++-- test/unit/MemberRoles/withdrawMembership.js | 22 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/test/integration/MemberRoles/withdrawMembership.js b/test/integration/MemberRoles/withdrawMembership.js index 2b31d783bb..9e740241c4 100644 --- a/test/integration/MemberRoles/withdrawMembership.js +++ b/test/integration/MemberRoles/withdrawMembership.js @@ -1,8 +1,11 @@ -const { enrollMember } = require('../utils/enroll'); +const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const setup = require('../setup'); + +const { enrollMember } = require('../utils/enroll'); const { Role } = require('../utils').constants; +const { setNextBlockTime } = require('../utils').evm; +const setup = require('../setup'); describe('withdrawMembership', function () { it('withdraws membership for current member', async function () { @@ -27,6 +30,19 @@ describe('withdrawMembership', function () { expect(balance).to.be.equal(0); }); + it("emits MembershipWithdrawn event with the withdrawn member's address and timestamp", async function () { + const fixture = await loadFixture(setup); + const { mr: memberRoles } = fixture.contracts; + const [member1] = fixture.accounts.members; + + const { timestamp } = await ethers.provider.getBlock('latest'); + await setNextBlockTime(timestamp + 1); + + await expect(memberRoles.connect(member1).withdrawMembership()) + .to.emit(memberRoles, 'MembershipWithdrawn') + .withArgs(member1.address, timestamp + 1); + }); + it('reverts when withdrawing membership for non-member', async function () { const fixture = await loadFixture(setup); const { mr: memberRoles } = fixture.contracts; diff --git a/test/unit/MemberRoles/withdrawMembership.js b/test/unit/MemberRoles/withdrawMembership.js index e78b0f1bf4..49cf194296 100644 --- a/test/unit/MemberRoles/withdrawMembership.js +++ b/test/unit/MemberRoles/withdrawMembership.js @@ -1,8 +1,11 @@ -const { Role } = require('../utils').constants; const { expect } = require('chai'); const { ethers } = require('hardhat'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + const { setup } = require('./setup'); +const { Role } = require('../utils').constants; +const { setNextBlockTime } = require('../utils').evm; + const { formatBytes32String } = ethers.utils; describe('withdrawMembership', function () { @@ -158,7 +161,7 @@ describe('withdrawMembership', function () { expect(membersAfter).to.be.equal(membersBefore - 1); }); - it("removes the role of member from the mebmber's address", async function () { + it("removes the role of member from the member's address", async function () { const fixture = await loadFixture(setup); const { memberRoles } = fixture.contracts; const { @@ -171,4 +174,19 @@ describe('withdrawMembership', function () { expect(hadMemberRoleBefore).to.be.equal(true); expect(hasMemberRoleAfter).to.be.equal(false); }); + + it("emits MembershipWithdrawn event with the withdrawn member's address and timestamp", async function () { + const fixture = await loadFixture(setup); + const { memberRoles } = fixture.contracts; + const { + members: [member1], + } = fixture.accounts; + + const { timestamp } = await ethers.provider.getBlock('latest'); + await setNextBlockTime(timestamp + 1); + + await expect(memberRoles.connect(member1).withdrawMembership()) + .to.emit(memberRoles, 'MembershipWithdrawn') + .withArgs(member1.address, timestamp + 1); + }); }); From faacdffb68697ab12652494237f60307fca6f25a Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Fri, 24 Nov 2023 10:03:14 +0100 Subject: [PATCH 03/88] Add a check on expandDeposit if tokens are locked --- contracts/modules/staking/StakingPool.sol | 4 ++++ test/unit/StakingPool/extendDeposit.js | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index 94c4d79dc7..ae0a72608e 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -1127,6 +1127,10 @@ contract StakingPool is IStakingPool, Multicall { revert NotTokenOwnerOrApproved(); } + if (block.timestamp <= nxm.isLockedForMV(msg.sender)) { + revert NxmIsLockedForGovernanceVote(); + } + uint _firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; { diff --git a/test/unit/StakingPool/extendDeposit.js b/test/unit/StakingPool/extendDeposit.js index 8d494fa559..195d4d4a41 100644 --- a/test/unit/StakingPool/extendDeposit.js +++ b/test/unit/StakingPool/extendDeposit.js @@ -141,6 +141,26 @@ describe('extendDeposit', function () { ).to.be.revertedWithCustomError(stakingPool, 'NotTokenOwnerOrApproved'); }); + it('should revert if trying to extend the deposit, while nxm is locked for governance vote', async function () { + const fixture = await loadFixture(extendDepositSetup); + const { stakingPool, nxm } = fixture; + const [user] = fixture.accounts.members; + + const { firstActiveTrancheId, maxTranche } = await getTranches(); + + await generateRewards(stakingPool, fixture.coverSigner); + const topUpAmount = parseEther('50'); + + // Simulate member vote lock + await nxm.setLock(user.address, topUpAmount); + + const extendDeposit = stakingPool + .connect(user) + .extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, topUpAmount); + + await expect(extendDeposit).to.be.revertedWithCustomError(stakingPool, 'NxmIsLockedForGovernanceVote'); + }); + it('withdraws and make a new deposit if initial tranche is expired', async function () { const fixture = await loadFixture(extendDepositSetup); const { stakingPool } = fixture; From 5354a4333136e05c9d88822ce349224722ed3edc Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 29 Nov 2023 13:21:21 +0100 Subject: [PATCH 04/88] Allow user to extend deposit if he voted, but not add amount --- contracts/modules/staking/StakingPool.sol | 2 +- test/unit/StakingPool/extendDeposit.js | 31 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index ae0a72608e..5956e433aa 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -1127,7 +1127,7 @@ contract StakingPool is IStakingPool, Multicall { revert NotTokenOwnerOrApproved(); } - if (block.timestamp <= nxm.isLockedForMV(msg.sender)) { + if (block.timestamp <= nxm.isLockedForMV(msg.sender) && topUpAmount > 0) { revert NxmIsLockedForGovernanceVote(); } diff --git a/test/unit/StakingPool/extendDeposit.js b/test/unit/StakingPool/extendDeposit.js index 195d4d4a41..a8d9d7b1b5 100644 --- a/test/unit/StakingPool/extendDeposit.js +++ b/test/unit/StakingPool/extendDeposit.js @@ -141,7 +141,7 @@ describe('extendDeposit', function () { ).to.be.revertedWithCustomError(stakingPool, 'NotTokenOwnerOrApproved'); }); - it('should revert if trying to extend the deposit, while nxm is locked for governance vote', async function () { + it('should revert if trying to extend the deposit amount, while nxm is locked', async function () { const fixture = await loadFixture(extendDepositSetup); const { stakingPool, nxm } = fixture; const [user] = fixture.accounts.members; @@ -161,6 +161,35 @@ describe('extendDeposit', function () { await expect(extendDeposit).to.be.revertedWithCustomError(stakingPool, 'NxmIsLockedForGovernanceVote'); }); + it('should not revert if trying to extend the deposit duration, while nxm is locked', async function () { + const fixture = await loadFixture(extendDepositSetup); + const { stakingPool, nxm } = fixture; + const [user] = fixture.accounts.members; + + const { firstActiveTrancheId, maxTranche } = await getTranches(); + + await generateRewards(stakingPool, fixture.coverSigner); + + const accNxmPerRewardsShareBefore = await stakingPool.getAccNxmPerRewardsShare(); + const lastAccNxmUpdateBefore = await stakingPool.getLastAccNxmUpdate(); + + // Simulate member vote lock + const topUpAmount = parseEther('50'); + await nxm.setLock(user.address, topUpAmount); + + await stakingPool.connect(user).extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, 0); + + const accNxmPerRewardsShareAfter = await stakingPool.getAccNxmPerRewardsShare(); + const lastAccNxmUpdateAfter = await stakingPool.getLastAccNxmUpdate(); + const { timestamp } = await ethers.provider.getBlock('latest'); + const depositData = await stakingPool.deposits(depositNftId, maxTranche); + + expect(accNxmPerRewardsShareAfter).to.gt(accNxmPerRewardsShareBefore); + expect(accNxmPerRewardsShareAfter).to.equal(depositData.lastAccNxmPerRewardShare); + expect(lastAccNxmUpdateAfter).to.gt(lastAccNxmUpdateBefore); + expect(lastAccNxmUpdateAfter).to.equal(timestamp); + }); + it('withdraws and make a new deposit if initial tranche is expired', async function () { const fixture = await loadFixture(extendDepositSetup); const { stakingPool } = fixture; From aa63727ee577c0e2fa73b4f64317024a3652d80b Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Tue, 9 Jan 2024 13:32:58 +0200 Subject: [PATCH 05/88] [gas] Reverse order of checks in if clause --- contracts/modules/staking/StakingPool.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index 5956e433aa..035fc59b9a 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -1127,7 +1127,7 @@ contract StakingPool is IStakingPool, Multicall { revert NotTokenOwnerOrApproved(); } - if (block.timestamp <= nxm.isLockedForMV(msg.sender) && topUpAmount > 0) { + if (topUpAmount > 0 && block.timestamp <= nxm.isLockedForMV(msg.sender)) { revert NxmIsLockedForGovernanceVote(); } From 9b40b6dbd5fd707f38a19964b6f7574374bef6eb Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Tue, 9 Jan 2024 13:42:34 +0200 Subject: [PATCH 06/88] Replace topUpAmount with a time variable when calling setLock in tests --- test/unit/StakingPool/extendDeposit.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/unit/StakingPool/extendDeposit.js b/test/unit/StakingPool/extendDeposit.js index a8d9d7b1b5..a541999deb 100644 --- a/test/unit/StakingPool/extendDeposit.js +++ b/test/unit/StakingPool/extendDeposit.js @@ -149,14 +149,13 @@ describe('extendDeposit', function () { const { firstActiveTrancheId, maxTranche } = await getTranches(); await generateRewards(stakingPool, fixture.coverSigner); - const topUpAmount = parseEther('50'); // Simulate member vote lock - await nxm.setLock(user.address, topUpAmount); + await nxm.setLock(user.address, 3 * 24 * 60 * 60); // 3 days in seconds const extendDeposit = stakingPool .connect(user) - .extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, topUpAmount); + .extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, parseEther('50')); await expect(extendDeposit).to.be.revertedWithCustomError(stakingPool, 'NxmIsLockedForGovernanceVote'); }); @@ -174,8 +173,7 @@ describe('extendDeposit', function () { const lastAccNxmUpdateBefore = await stakingPool.getLastAccNxmUpdate(); // Simulate member vote lock - const topUpAmount = parseEther('50'); - await nxm.setLock(user.address, topUpAmount); + await nxm.setLock(user.address, 3 * 24 * 60 * 60); // 3 days in seconds); await stakingPool.connect(user).extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, 0); From 7164ffd96ba5d178242c125d49bbf388a0d6dc14 Mon Sep 17 00:00:00 2001 From: shark0der Date: Mon, 27 Nov 2023 14:11:16 +0200 Subject: [PATCH 07/88] Optimize process fraud by skipping redundant loops --- contracts/modules/assessment/Assessment.sol | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/modules/assessment/Assessment.sol b/contracts/modules/assessment/Assessment.sol index a358c7315a..83227d6d73 100644 --- a/contracts/modules/assessment/Assessment.sol +++ b/contracts/modules/assessment/Assessment.sol @@ -403,6 +403,7 @@ contract Assessment is IAssessment, MasterAwareV2 { // Make sure we don't burn beyond lastFraudulentVoteIndex uint processUntil = _stake.rewardsWithdrawableFromIndex + voteBatchSize; + if (processUntil >= lastFraudulentVoteIndex) { processUntil = lastFraudulentVoteIndex + 1; } @@ -418,7 +419,6 @@ contract Assessment is IAssessment, MasterAwareV2 { } } - if (vote.accepted) { poll.accepted -= vote.stakedAmount; } else { @@ -445,15 +445,18 @@ contract Assessment is IAssessment, MasterAwareV2 { // burn from a different merkle tree. burnAmount = burnAmount > _stake.amount ? _stake.amount : burnAmount; _stake.amount -= burnAmount; + _stake.fraudCount++; + // TODO: consider burning the tokens in the token controller contract ramm().updateTwap(); nxm.burn(burnAmount); - _stake.fraudCount++; } - _stake.rewardsWithdrawableFromIndex = uint104(processUntil); - stakeOf[assessor] = _stake; + if (uint104(processUntil) > _stake.rewardsWithdrawableFromIndex) { + _stake.rewardsWithdrawableFromIndex = uint104(processUntil); + } + stakeOf[assessor] = _stake; } /// Updates configurable parameters through governance From 1a15cb8734261d9d34d75cc84638e8ac20f239f9 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 29 Nov 2023 12:08:25 +0100 Subject: [PATCH 08/88] Add test for multiple processFraud calls --- test/integration/Assessment/processFraud.js | 72 ++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/test/integration/Assessment/processFraud.js b/test/integration/Assessment/processFraud.js index 7278092156..b392bce2d9 100644 --- a/test/integration/Assessment/processFraud.js +++ b/test/integration/Assessment/processFraud.js @@ -6,7 +6,7 @@ const setup = require('../setup'); const { getProof, submitFraud } = require('../../unit/Assessment/helpers'); const { calculateFirstTrancheId } = require('../utils/staking'); const { daysToSeconds } = require('../../../lib/helpers'); -const { setEtherBalance } = require('../../utils/evm'); +const { setEtherBalance, increaseTime } = require('../../utils/evm'); const { parseEther } = ethers.utils; const { MaxUint256 } = ethers.constants; @@ -27,8 +27,27 @@ async function processFraudSetup() { .connect(staker) .depositTo(parseEther('1000'), firstTrancheId + 1, 0 /* new stake */, staker.address); - // buy cover + // buy multiple covers const amount = parseEther('1'); + + await cover.buyCover( + { + coverId: 0, + owner: staker.address, + productId: 0, + coverAsset: 0b0, + amount, + period: daysToSeconds(30), + maxPremiumInAsset: MaxUint256, + paymentAsset: 0b0, + commissionRatio: 0, + commissionDestination: staker.address, + ipfsData: 'ipfs data', + }, + [{ poolId: 1, coverAmountInAsset: amount }], + { value: amount }, + ); + await cover.buyCover( { coverId: 0, @@ -84,4 +103,53 @@ describe('processFraud', function () { // TODO: this is a temporary value..what fees should be summed? expect(receipt.gasUsed).to.be.eq(92691); }); + + it('should not reset index when process fraud is called again after good vote', async function () { + const fixture = await loadFixture(processFraudSetup); + const { as: assessment, ci: individualClaims, gv: governanceContact, tk: nxm } = fixture.contracts; + const governance = await ethers.getImpersonatedSigner(governanceContact.address); + const [fraudulentMember] = fixture.accounts.members; + await setEtherBalance(governance.address, parseEther('1000')); + await assessment.connect(fraudulentMember).stake(fixture.amount.mul(100)); + const { minVotingPeriodInDays, payoutCooldownInDays } = await assessment.config(); + + // Fradelante vote + await individualClaims.submitClaim(1, 0, fixture.amount, '', { value: fixture.amount }); + await assessment.connect(fraudulentMember).castVotes([0], [true], ['Assessment data hash'], 0); + const merkleTree = await submitFraud({ + assessment, + signer: governance, + addresses: [fraudulentMember.address], + amounts: [fixture.amount], + }); + const { rewardsWithdrawableFromIndex: indexAtStart } = await assessment.stakeOf(fraudulentMember.address); + expect(indexAtStart).to.be.eq(0); + + const proof = getProof({ + address: fraudulentMember.address, + lastFraudulentVoteIndex: 0, + amount: fixture.amount, + fraudCount: 0, + merkleTree, + }); + + await assessment.processFraud(0, proof, fraudulentMember.address, 0, fixture.amount, 0, 100); + const { rewardsWithdrawableFromIndex: indexAfterFraudProcess } = await assessment.stakeOf(fraudulentMember.address); + expect(indexAfterFraudProcess).to.be.eq(1); + + // Good vote + await individualClaims.submitClaim(2, 0, fixture.amount, '', { value: fixture.amount }); + await assessment.connect(fraudulentMember).castVotes([1], [true], ['Assessment data hash'], 0); + + await increaseTime(daysToSeconds(minVotingPeriodInDays + payoutCooldownInDays + 1)); + await assessment.withdrawRewards(fraudulentMember.address, 1); + const { rewardsWithdrawableFromIndex: indexAfterGoodVote } = await assessment.stakeOf(fraudulentMember.address); + expect(indexAfterGoodVote).to.be.eq(2); + + await assessment.processFraud(0, proof, fraudulentMember.address, 0, fixture.amount, 0, 100); + const { rewardsWithdrawableFromIndex: indexAfterSameFraudProcess } = await assessment.stakeOf( + fraudulentMember.address, + ); + expect(indexAfterSameFraudProcess).to.be.eq(2); + }); }); From d02ba382fb895a6d571ba7fbd16ec700cc362fd9 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 29 Nov 2023 12:29:53 +0100 Subject: [PATCH 09/88] Fix linting --- test/integration/Assessment/processFraud.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/Assessment/processFraud.js b/test/integration/Assessment/processFraud.js index b392bce2d9..fd684c5853 100644 --- a/test/integration/Assessment/processFraud.js +++ b/test/integration/Assessment/processFraud.js @@ -106,7 +106,7 @@ describe('processFraud', function () { it('should not reset index when process fraud is called again after good vote', async function () { const fixture = await loadFixture(processFraudSetup); - const { as: assessment, ci: individualClaims, gv: governanceContact, tk: nxm } = fixture.contracts; + const { as: assessment, ci: individualClaims, gv: governanceContact } = fixture.contracts; const governance = await ethers.getImpersonatedSigner(governanceContact.address); const [fraudulentMember] = fixture.accounts.members; await setEtherBalance(governance.address, parseEther('1000')); From f1bb39ba56cd3de83164a83763b875f0252ee700 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Tue, 9 Jan 2024 13:50:32 +0200 Subject: [PATCH 10/88] Fix typo --- test/integration/Assessment/processFraud.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/Assessment/processFraud.js b/test/integration/Assessment/processFraud.js index fd684c5853..833e65fd49 100644 --- a/test/integration/Assessment/processFraud.js +++ b/test/integration/Assessment/processFraud.js @@ -113,7 +113,7 @@ describe('processFraud', function () { await assessment.connect(fraudulentMember).stake(fixture.amount.mul(100)); const { minVotingPeriodInDays, payoutCooldownInDays } = await assessment.config(); - // Fradelante vote + // Fraudulent vote await individualClaims.submitClaim(1, 0, fixture.amount, '', { value: fixture.amount }); await assessment.connect(fraudulentMember).castVotes([0], [true], ['Assessment data hash'], 0); const merkleTree = await submitFraud({ From eeccef0370bb595edf628df32ec3cfee5c2c9727 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 29 Nov 2023 13:19:13 +0100 Subject: [PATCH 11/88] Remove change of the activeCoverExpirationBuckets from expireCover and do the recalc of totalCover in buyCover --- contracts/modules/cover/Cover.sol | 9 --------- test/integration/Cover/expireCover.js | 22 +++++++++++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index 56346b0a96..dded681ef1 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -310,15 +310,6 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu 0, // previous premium allocationRequest ); - - } - - uint currentBucketId = block.timestamp / BUCKET_SIZE; - uint bucketAtExpiry = Math.divCeil(expiration, BUCKET_SIZE); - - if (currentBucketId < bucketAtExpiry) { - // remove cover amount from from expiration buckets - activeCoverExpirationBuckets[cover.coverAsset][bucketAtExpiry] -= lastSegment.amount; } } diff --git a/test/integration/Cover/expireCover.js b/test/integration/Cover/expireCover.js index 62f1e162cd..8f4f3f12e3 100644 --- a/test/integration/Cover/expireCover.js +++ b/test/integration/Cover/expireCover.js @@ -90,11 +90,14 @@ describe('expireCover', function () { it('should expire a cover', async function () { const fixture = await loadFixture(expireCoverSetup); - const { cover, stakingPool1 } = fixture.contracts; + const { cover, stakingPool1, ra: ramm } = fixture.contracts; + const { BUCKET_DURATION, NXM_PER_ALLOCATION_UNIT } = fixture.config; const [coverBuyer] = fixture.accounts.members; const { amount, period, productId } = buyCoverFixture; const coverBuyerAddress = await coverBuyer.getAddress(); + // skip time so we would have less ratchet impact on internal price + await increaseTime(period); const initialAllocations = await stakingPool1.getActiveAllocations(productId); await cover @@ -113,8 +116,25 @@ describe('expireCover', function () { await cover.connect(coverBuyer).expireCover(coverId); const allocationsAfter = await stakingPool1.getActiveAllocations(productId); + await increaseTime(BUCKET_DURATION.toNumber()); // go to next bucket + const internalPrice = await ramm.getInternalPrice(); + await cover + .connect(coverBuyer) + .buyCover( + { ...buyCoverFixture, owner: coverBuyerAddress }, + [{ poolId: 1, coverAmountInAsset: buyCoverFixture.amount }], + { value: amount }, + ); + + const totalCoverAmountAfter = await cover.totalActiveCoverInAsset(0); + const allocationsAfterBucketExpiration = await stakingPool1.getActiveAllocations(productId); + const coverAmountInNXM = sum(allocationsAfterBucketExpiration).mul(NXM_PER_ALLOCATION_UNIT); + const expectedTotalActiveAmount = internalPrice.mul(coverAmountInNXM).div(parseEther('1')); + expect(sum(allocationsWithCover)).not.to.be.equal(sum(allocationsAfter)); expect(sum(initialAllocations)).to.be.equal(sum(allocationsAfter)); + expect(sum(allocationsAfterBucketExpiration)).to.be.equal(sum(allocationsWithCover)); + expect(totalCoverAmountAfter).to.be.equal(expectedTotalActiveAmount); }); it('should emit an event on expire a cover', async function () { From 40db38245360eded0847e031da1db1295c1980c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jan 2024 01:56:49 +0000 Subject: [PATCH 12/88] Bump follow-redirects from 1.15.1 to 1.15.4 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.1 to 1.15.4. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.1...v1.15.4) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index fdb47b2921..4ff33595d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6266,9 +6266,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", - "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", @@ -18970,9 +18970,9 @@ } }, "follow-redirects": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", - "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==" }, "foreach": { "version": "2.0.5", From cd9cbca73f6199a8f07fb4b47de6e83e02da04fa Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 20 Dec 2023 09:11:12 +0100 Subject: [PATCH 13/88] Add `processExpiredCover` to Cover contract --- contracts/modules/cover/Cover.sol | 62 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index dded681ef1..b39b63c792 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -235,39 +235,11 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu ) ); - // Update totalActiveCover - { - ActiveCover memory _activeCover = activeCover[params.coverAsset]; - - uint currentBucketId = block.timestamp / BUCKET_SIZE; - uint totalActiveCover = _activeCover.totalActiveCoverInAsset; - - if (totalActiveCover != 0) { - totalActiveCover -= getExpiredCoverAmount( - params.coverAsset, - _activeCover.lastBucketUpdateId, - currentBucketId - ); - } - - totalActiveCover -= previousSegmentAmount; - totalActiveCover += coverAmountInCoverAsset; - - _activeCover.lastBucketUpdateId = currentBucketId.toUint64(); - _activeCover.totalActiveCoverInAsset = totalActiveCover.toUint192(); - - // update total active cover in storage - activeCover[params.coverAsset] = _activeCover; - - // update amount to expire at the end of this cover segment - uint bucketAtExpiry = Math.divCeil(block.timestamp + params.period, BUCKET_SIZE); - activeCoverExpirationBuckets[params.coverAsset][bucketAtExpiry] += coverAmountInCoverAsset; - } - // can pay with cover asset or nxm only if (params.paymentAsset != params.coverAsset && params.paymentAsset != NXM_ASSET_ID) { revert InvalidPaymentAsset(); } + _processExpiredCover(params.coverAsset, coverAmountInCoverAsset, params.period, previousSegmentAmount); retrievePayment( amountDueInNXM, @@ -487,6 +459,38 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu } } + function processExpiredCover(uint coverAsset) public { + _processExpiredCover(coverAsset, 0, 0, 0); + } + + function _processExpiredCover(uint coverAsset, uint coverAmountInCoverAsset, uint period, uint previousSegmentAmount) internal { + ActiveCover memory _activeCover = activeCover[coverAsset]; + + uint currentBucketId = block.timestamp / BUCKET_SIZE; + uint totalActiveCover = _activeCover.totalActiveCoverInAsset; + + if (totalActiveCover != 0) { + totalActiveCover -= getExpiredCoverAmount( + coverAsset, + _activeCover.lastBucketUpdateId, + currentBucketId + ); + } + + totalActiveCover -= previousSegmentAmount; + totalActiveCover += coverAmountInCoverAsset; + + _activeCover.lastBucketUpdateId = currentBucketId.toUint64(); + _activeCover.totalActiveCoverInAsset = totalActiveCover.toUint192(); + + // update total active cover in storage + activeCover[coverAsset] = _activeCover; + + // update amount to expire at the end of this cover segment + uint bucketAtExpiry = Math.divCeil(block.timestamp + period, BUCKET_SIZE); + activeCoverExpirationBuckets[coverAsset][bucketAtExpiry] += coverAmountInCoverAsset; + } + function addLegacyCover( uint productId, uint coverAsset, From 68988ca4f091d0bf0ebf80858aa1e269bb2b5da4 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Thu, 21 Dec 2023 09:41:54 +0100 Subject: [PATCH 14/88] Fix NatSpec comments --- contracts/modules/assessment/Assessment.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/modules/assessment/Assessment.sol b/contracts/modules/assessment/Assessment.sol index 83227d6d73..4b55d814fe 100644 --- a/contracts/modules/assessment/Assessment.sol +++ b/contracts/modules/assessment/Assessment.sol @@ -379,7 +379,7 @@ contract Assessment is IAssessment, MasterAwareV2 { /// @param fraudCount The number of times the assessor has taken part in fraudulent /// voting. /// @param voteBatchSize The number of iterations that prevents an unbounded loop and - /// allows chunked processing. Can also be 0 if chunking is not + /// allows chunked processing. Can also be very large number if chunking is not /// necessary. function processFraud( uint256 rootIndex, From 471bd27476210f2efb82409fd36909df9f6a28e3 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Thu, 21 Dec 2023 09:55:48 +0100 Subject: [PATCH 15/88] Add tests for `processExpiredCover` --- test/unit/Cover/processExpiredCover.js | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/unit/Cover/processExpiredCover.js diff --git a/test/unit/Cover/processExpiredCover.js b/test/unit/Cover/processExpiredCover.js new file mode 100644 index 0000000000..2f76c7566d --- /dev/null +++ b/test/unit/Cover/processExpiredCover.js @@ -0,0 +1,85 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { createStakingPool } = require('./helpers'); +const setup = require('./setup'); +const { increaseTime } = require('../utils').evm; +const { daysToSeconds } = require('../utils').helpers; + +const { BigNumber } = ethers; +const { parseEther } = ethers.utils; +const { AddressZero } = ethers.constants; + +const buyCoverFixture = { + productId: 0, + coverAsset: 0, // ETH + poolId: 1, + segmentId: 0, + period: 3600 * 24 * 30, // 30 days + amount: parseEther('1000'), + targetPriceRatio: 260, + priceDenominator: BigNumber.from(10000), + activeCover: parseEther('8000'), + capacity: parseEther('10000'), + expectedPremium: parseEther('1000').mul(260).div(10000), // amount * targetPriceRatio / priceDenominator +}; + +const poolAllocationRequest = [{ poolId: 1, coverAmountInAsset: buyCoverFixture.amount }]; + +async function processExpiredCoverSetup() { + const fixture = await loadFixture(setup); + const { cover } = fixture; + const [stakingPoolManager] = fixture.accounts.members; + const [coverBuyer] = fixture.accounts.members; + + await createStakingPool( + cover, + buyCoverFixture.productId, + buyCoverFixture.capacity, + buyCoverFixture.targetPriceRatio, + buyCoverFixture.activeCover, + stakingPoolManager, + buyCoverFixture.targetPriceRatio, + ); + + const { amount, productId, coverAsset, period, expectedPremium } = buyCoverFixture; + await cover.connect(coverBuyer).buyCover( + { + coverId: 0, + owner: coverBuyer.address, + productId, + coverAsset, + amount, + period, + maxPremiumInAsset: expectedPremium, + paymentAsset: coverAsset, + commissionRatio: parseEther('0'), + commissionDestination: AddressZero, + ipfsData: '', + }, + poolAllocationRequest, + { value: expectedPremium }, + ); + const coverId = await cover.coverDataCount(); + + await increaseTime(daysToSeconds(31)); + + await expect(cover.connect(coverBuyer).expireCover(coverId)); + + return fixture; +} + +describe('processExpiredCover', function () { + it('should recalculate totalCoverAmount', async function () { + const fixture = await loadFixture(processExpiredCoverSetup); + const { cover } = fixture; + const { coverAsset } = buyCoverFixture; + + await increaseTime(daysToSeconds(7)); // fastforward to next bucket + await cover.processExpiredCover(coverAsset); + const totalCoverAmount = await cover.totalActiveCoverInAsset(coverAsset); + + expect(totalCoverAmount).to.be.equal(0); + }); +}); From 476d14b065f266fb662741468ee5d6869cf0f949 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Wed, 10 Jan 2024 13:26:08 +0200 Subject: [PATCH 16/88] Fix lint --- contracts/modules/assessment/Assessment.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/modules/assessment/Assessment.sol b/contracts/modules/assessment/Assessment.sol index 4b55d814fe..83227d6d73 100644 --- a/contracts/modules/assessment/Assessment.sol +++ b/contracts/modules/assessment/Assessment.sol @@ -379,7 +379,7 @@ contract Assessment is IAssessment, MasterAwareV2 { /// @param fraudCount The number of times the assessor has taken part in fraudulent /// voting. /// @param voteBatchSize The number of iterations that prevents an unbounded loop and - /// allows chunked processing. Can also be very large number if chunking is not + /// allows chunked processing. Can also be 0 if chunking is not /// necessary. function processFraud( uint256 rootIndex, From 8f02256cad7db56c1dac44cb4f55403977976ef6 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 12 Jan 2024 11:49:21 +0200 Subject: [PATCH 17/88] Rename processExpiredCover in updateTotalActiveCoverAmount --- contracts/modules/cover/Cover.sol | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index b39b63c792..73f1154697 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -239,7 +239,7 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu if (params.paymentAsset != params.coverAsset && params.paymentAsset != NXM_ASSET_ID) { revert InvalidPaymentAsset(); } - _processExpiredCover(params.coverAsset, coverAmountInCoverAsset, params.period, previousSegmentAmount); + _updateTotalActiveCoverAmount(params.coverAsset, coverAmountInCoverAsset, params.period, previousSegmentAmount); retrievePayment( amountDueInNXM, @@ -459,11 +459,16 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu } } - function processExpiredCover(uint coverAsset) public { - _processExpiredCover(coverAsset, 0, 0, 0); + function updateTotalActiveCoverAmount(uint coverAsset) public { + _updateTotalActiveCoverAmount(coverAsset, 0, 0, 0); } - function _processExpiredCover(uint coverAsset, uint coverAmountInCoverAsset, uint period, uint previousSegmentAmount) internal { + function _updateTotalActiveCoverAmount( + uint coverAsset, + uint coverAmountInCoverAsset, + uint period, + uint previousSegmentAmount + ) internal { ActiveCover memory _activeCover = activeCover[coverAsset]; uint currentBucketId = block.timestamp / BUCKET_SIZE; From 4348d43286ef3ed9ff481189d9b62be5cc8206f9 Mon Sep 17 00:00:00 2001 From: shark0der Date: Fri, 12 Jan 2024 12:42:49 +0100 Subject: [PATCH 18/88] Rename _updateTotalActiveCoverAmount params --- contracts/modules/cover/Cover.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index 73f1154697..4708d3281d 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -465,9 +465,9 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu function _updateTotalActiveCoverAmount( uint coverAsset, - uint coverAmountInCoverAsset, - uint period, - uint previousSegmentAmount + uint newCoverAmountInAsset, + uint coverPeriod, + uint previousCoverSegmentAmount ) internal { ActiveCover memory _activeCover = activeCover[coverAsset]; From 7ca50ed8cfa22f2f3ddd1bad33bbdb7345d43a60 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 12 Jan 2024 13:48:57 +0200 Subject: [PATCH 19/88] Rename variables inside the function, according to the function params renames --- contracts/modules/cover/Cover.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index 4708d3281d..8a2659d078 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -482,8 +482,8 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu ); } - totalActiveCover -= previousSegmentAmount; - totalActiveCover += coverAmountInCoverAsset; + totalActiveCover -= previousCoverSegmentAmount; + totalActiveCover += newCoverAmountInAsset; _activeCover.lastBucketUpdateId = currentBucketId.toUint64(); _activeCover.totalActiveCoverInAsset = totalActiveCover.toUint192(); @@ -492,8 +492,8 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu activeCover[coverAsset] = _activeCover; // update amount to expire at the end of this cover segment - uint bucketAtExpiry = Math.divCeil(block.timestamp + period, BUCKET_SIZE); - activeCoverExpirationBuckets[coverAsset][bucketAtExpiry] += coverAmountInCoverAsset; + uint bucketAtExpiry = Math.divCeil(block.timestamp + coverPeriod, BUCKET_SIZE); + activeCoverExpirationBuckets[coverAsset][bucketAtExpiry] += newCoverAmountInAsset; } function addLegacyCover( From 701370aaa931b87bb9ed6f7c9e05b1f7f23b2d34 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 12 Jan 2024 14:19:38 +0200 Subject: [PATCH 20/88] Rename test file & functions --- ...essExpiredCover.js => updateTotalActiveCoverAmount.js} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename test/unit/Cover/{processExpiredCover.js => updateTotalActiveCoverAmount.js} (90%) diff --git a/test/unit/Cover/processExpiredCover.js b/test/unit/Cover/updateTotalActiveCoverAmount.js similarity index 90% rename from test/unit/Cover/processExpiredCover.js rename to test/unit/Cover/updateTotalActiveCoverAmount.js index 2f76c7566d..a7525fd649 100644 --- a/test/unit/Cover/processExpiredCover.js +++ b/test/unit/Cover/updateTotalActiveCoverAmount.js @@ -27,7 +27,7 @@ const buyCoverFixture = { const poolAllocationRequest = [{ poolId: 1, coverAmountInAsset: buyCoverFixture.amount }]; -async function processExpiredCoverSetup() { +async function updateTotalActiveCoverAmountSetup() { const fixture = await loadFixture(setup); const { cover } = fixture; const [stakingPoolManager] = fixture.accounts.members; @@ -70,14 +70,14 @@ async function processExpiredCoverSetup() { return fixture; } -describe('processExpiredCover', function () { +describe('updateTotalActiveCoverAmount', function () { it('should recalculate totalCoverAmount', async function () { - const fixture = await loadFixture(processExpiredCoverSetup); + const fixture = await loadFixture(updateTotalActiveCoverAmountSetup); const { cover } = fixture; const { coverAsset } = buyCoverFixture; await increaseTime(daysToSeconds(7)); // fastforward to next bucket - await cover.processExpiredCover(coverAsset); + await cover.updateTotalActiveCoverAmount(coverAsset); const totalCoverAmount = await cover.totalActiveCoverInAsset(coverAsset); expect(totalCoverAmount).to.be.equal(0); From c45d312e8af713b31094173aa701fc4791124895 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Mon, 4 Mar 2024 09:46:36 +0100 Subject: [PATCH 21/88] Remove deps, and modify contracts to support the change --- contracts/external/WETH9.sol | 2 +- contracts/interfaces/IERC20.sol | 76 ++++++ contracts/libraries/external/SafeMath.sol | 156 ++++++++++++ contracts/mocks/Pool/P1MockLido.sol | 4 +- .../SwapOperator/SOMockEnzymeV4Vault.sol | 5 +- .../TokenController/TCMockGovernance.sol | 2 +- .../mocks/Tokens/ERC20CustomDecimalsMock.sol | 4 +- .../mocks/Tokens/ERC20MintableDetailed.sol | 4 +- contracts/mocks/Tokens/ERC20Mock.sol | 4 +- contracts/mocks/Tokens/ERC20MockNameable.sol | 4 +- .../Tokens/ERC20RevertingBalanceOfMock.sol | 4 +- contracts/mocks/Tokens/ERC721.sol | 231 ++++++++++++++++++ contracts/mocks/Tokens/ERC721Mock.sol | 24 +- contracts/mocks/common/ERC20Detailed.sol | 54 ++++ contracts/mocks/common/ERC20Mintable.sol | 23 ++ contracts/mocks/common/NXMTokenMock.sol | 2 +- contracts/modules/governance/Governance.sol | 2 +- contracts/modules/legacy/LegacyClaimsData.sol | 2 +- .../modules/legacy/LegacyQuotationData.sol | 2 +- contracts/modules/token/external/Context.sol | 27 ++ contracts/modules/token/external/ERC20.sol | 230 +++++++++++++++++ package-lock.json | 22 -- package.json | 2 - 23 files changed, 830 insertions(+), 56 deletions(-) create mode 100644 contracts/interfaces/IERC20.sol create mode 100644 contracts/libraries/external/SafeMath.sol create mode 100644 contracts/mocks/Tokens/ERC721.sol create mode 100644 contracts/mocks/common/ERC20Detailed.sol create mode 100644 contracts/mocks/common/ERC20Mintable.sol create mode 100644 contracts/modules/token/external/Context.sol create mode 100644 contracts/modules/token/external/ERC20.sol diff --git a/contracts/external/WETH9.sol b/contracts/external/WETH9.sol index 9031b6a60e..574f6477cb 100644 --- a/contracts/external/WETH9.sol +++ b/contracts/external/WETH9.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../interfaces/IERC20.sol"; contract WETH9 is IERC20 { string public name = "Wrapped Ether"; diff --git a/contracts/interfaces/IERC20.sol b/contracts/interfaces/IERC20.sol new file mode 100644 index 0000000000..134ad2115d --- /dev/null +++ b/contracts/interfaces/IERC20.sol @@ -0,0 +1,76 @@ +pragma solidity ^0.5.0; + +/** + * @dev Interface of the ERC20 standard as defined in the EIP. Does not include + * the optional functions; to access them see {ERC20Detailed}. + */ +interface IERC20 { + /** + * @dev Returns the amount of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the amount of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves `amount` tokens from the caller's account to `recipient`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address recipient, uint256 amount) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) external view returns (uint256); + + /** + * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 amount) external returns (bool); + + /** + * @dev Moves `amount` tokens from `sender` to `recipient` using the + * allowance mechanism. `amount` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval(address indexed owner, address indexed spender, uint256 value); +} diff --git a/contracts/libraries/external/SafeMath.sol b/contracts/libraries/external/SafeMath.sol new file mode 100644 index 0000000000..476ca9f428 --- /dev/null +++ b/contracts/libraries/external/SafeMath.sol @@ -0,0 +1,156 @@ +pragma solidity ^0.5.0; + +/** + * @dev Wrappers over Solidity's arithmetic operations with added overflow + * checks. + * + * Arithmetic operations in Solidity wrap on overflow. This can easily result + * in bugs, because programmers usually assume that an overflow raises an + * error, which is the standard behavior in high level programming languages. + * `SafeMath` restores this intuition by reverting the transaction when an + * operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeMath { + /** + * @dev Returns the addition of two unsigned integers, reverting on + * overflow. + * + * Counterpart to Solidity's `+` operator. + * + * Requirements: + * - Addition cannot overflow. + */ + function add(uint256 a, uint256 b) internal pure returns (uint256) { + uint256 c = a + b; + require(c >= a, "SafeMath: addition overflow"); + + return c; + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting on + * overflow (when the result is negative). + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * - Subtraction cannot overflow. + */ + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + return sub(a, b, "SafeMath: subtraction overflow"); + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting with custom message on + * overflow (when the result is negative). + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * - Subtraction cannot overflow. + * + * _Available since v2.4.0._ + */ + function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + require(b <= a, errorMessage); + uint256 c = a - b; + + return c; + } + + /** + * @dev Returns the multiplication of two unsigned integers, reverting on + * overflow. + * + * Counterpart to Solidity's `*` operator. + * + * Requirements: + * - Multiplication cannot overflow. + */ + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + // Gas optimization: this is cheaper than requiring 'a' not being zero, but the + // benefit is lost if 'b' is also tested. + // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522 + if (a == 0) { + return 0; + } + + uint256 c = a * b; + require(c / a == b, "SafeMath: multiplication overflow"); + + return c; + } + + /** + * @dev Returns the integer division of two unsigned integers. Reverts on + * division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. Note: this function uses a + * `revert` opcode (which leaves remaining gas untouched) while Solidity + * uses an invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function div(uint256 a, uint256 b) internal pure returns (uint256) { + return div(a, b, "SafeMath: division by zero"); + } + + /** + * @dev Returns the integer division of two unsigned integers. Reverts with custom message on + * division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. Note: this function uses a + * `revert` opcode (which leaves remaining gas untouched) while Solidity + * uses an invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + * + * _Available since v2.4.0._ + */ + function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + // Solidity only automatically asserts when dividing by 0 + require(b > 0, errorMessage); + uint256 c = a / b; + // assert(a == b * c + a % b); // There is no case in which this doesn't hold + + return c; + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * Reverts when dividing by zero. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function mod(uint256 a, uint256 b) internal pure returns (uint256) { + return mod(a, b, "SafeMath: modulo by zero"); + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * Reverts with custom message when dividing by zero. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + * + * _Available since v2.4.0._ + */ + function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + require(b != 0, errorMessage); + return a % b; + } +} diff --git a/contracts/mocks/Pool/P1MockLido.sol b/contracts/mocks/Pool/P1MockLido.sol index 24394ef515..217cdd028f 100644 --- a/contracts/mocks/Pool/P1MockLido.sol +++ b/contracts/mocks/Pool/P1MockLido.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.17; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract P1MockLido is ERC20Mintable, ERC20Detailed { diff --git a/contracts/mocks/SwapOperator/SOMockEnzymeV4Vault.sol b/contracts/mocks/SwapOperator/SOMockEnzymeV4Vault.sol index bd09080498..67e3f28270 100644 --- a/contracts/mocks/SwapOperator/SOMockEnzymeV4Vault.sol +++ b/contracts/mocks/SwapOperator/SOMockEnzymeV4Vault.sol @@ -2,9 +2,10 @@ pragma solidity ^0.5.17; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + import "../../external/enzyme/IEnzymeV4Vault.sol"; +import "../../modules/token/external/ERC20.sol"; +import "../common/ERC20Detailed.sol"; contract SOMockEnzymeV4Vault is IEnzymeV4Vault, ERC20Detailed, ERC20 { diff --git a/contracts/mocks/TokenController/TCMockGovernance.sol b/contracts/mocks/TokenController/TCMockGovernance.sol index 0e244c3271..1b7718b81c 100644 --- a/contracts/mocks/TokenController/TCMockGovernance.sol +++ b/contracts/mocks/TokenController/TCMockGovernance.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../libraries/external/SafeMath.sol"; import "../../abstract/LegacyMasterAware.sol"; import "../../interfaces/IGovernance.sol"; import "../../interfaces/IMemberRoles.sol"; diff --git a/contracts/mocks/Tokens/ERC20CustomDecimalsMock.sol b/contracts/mocks/Tokens/ERC20CustomDecimalsMock.sol index 58ecefb313..0519849d7b 100644 --- a/contracts/mocks/Tokens/ERC20CustomDecimalsMock.sol +++ b/contracts/mocks/Tokens/ERC20CustomDecimalsMock.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract ERC20CustomDecimalsMock is ERC20Mintable, ERC20Detailed { constructor(uint8 decimals) public diff --git a/contracts/mocks/Tokens/ERC20MintableDetailed.sol b/contracts/mocks/Tokens/ERC20MintableDetailed.sol index ab34e66fec..f48cf12043 100644 --- a/contracts/mocks/Tokens/ERC20MintableDetailed.sol +++ b/contracts/mocks/Tokens/ERC20MintableDetailed.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract ERC20MintableDetailed is ERC20Mintable, ERC20Detailed { diff --git a/contracts/mocks/Tokens/ERC20Mock.sol b/contracts/mocks/Tokens/ERC20Mock.sol index 6814438cd6..62d4412580 100644 --- a/contracts/mocks/Tokens/ERC20Mock.sol +++ b/contracts/mocks/Tokens/ERC20Mock.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract ERC20Mock is ERC20Mintable, ERC20Detailed { constructor() public ERC20Detailed("ERC20 mock", "MOCK", 18) { diff --git a/contracts/mocks/Tokens/ERC20MockNameable.sol b/contracts/mocks/Tokens/ERC20MockNameable.sol index 55c6e6ebd6..8d852b1d21 100644 --- a/contracts/mocks/Tokens/ERC20MockNameable.sol +++ b/contracts/mocks/Tokens/ERC20MockNameable.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract ERC20MockNameable is ERC20Mintable, ERC20Detailed { constructor(string memory name, string memory symbol) public ERC20Detailed(name, symbol, 18) { diff --git a/contracts/mocks/Tokens/ERC20RevertingBalanceOfMock.sol b/contracts/mocks/Tokens/ERC20RevertingBalanceOfMock.sol index 5c2e3943e3..30a2043dbd 100644 --- a/contracts/mocks/Tokens/ERC20RevertingBalanceOfMock.sol +++ b/contracts/mocks/Tokens/ERC20RevertingBalanceOfMock.sol @@ -2,8 +2,8 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol"; -import "@openzeppelin/contracts/token/ERC20/ERC20Mintable.sol"; +import "../common/ERC20Detailed.sol"; +import "../common/ERC20Mintable.sol"; contract ERC20RevertingBalanceOfMock is ERC20Mintable, ERC20Detailed { diff --git a/contracts/mocks/Tokens/ERC721.sol b/contracts/mocks/Tokens/ERC721.sol new file mode 100644 index 0000000000..5ce81483bc --- /dev/null +++ b/contracts/mocks/Tokens/ERC721.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +/// @notice Modern, minimalist, and gas efficient ERC-721 implementation. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC721.sol) +abstract contract ERC721 { + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event Transfer(address indexed from, address indexed to, uint256 indexed id); + + event Approval(address indexed owner, address indexed spender, uint256 indexed id); + + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /*////////////////////////////////////////////////////////////// + METADATA STORAGE/LOGIC + //////////////////////////////////////////////////////////////*/ + + string public name; + + string public symbol; + + function tokenURI(uint256 id) public view virtual returns (string memory); + + /*////////////////////////////////////////////////////////////// + ERC721 BALANCE/OWNER STORAGE + //////////////////////////////////////////////////////////////*/ + + mapping(uint256 => address) internal _ownerOf; + + mapping(address => uint256) internal _balanceOf; + + function ownerOf(uint256 id) public view virtual returns (address owner) { + require((owner = _ownerOf[id]) != address(0), "NOT_MINTED"); + } + + function balanceOf(address owner) public view virtual returns (uint256) { + require(owner != address(0), "ZERO_ADDRESS"); + + return _balanceOf[owner]; + } + + /*////////////////////////////////////////////////////////////// + ERC721 APPROVAL STORAGE + //////////////////////////////////////////////////////////////*/ + + mapping(uint256 => address) public getApproved; + + mapping(address => mapping(address => bool)) public isApprovedForAll; + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + /*////////////////////////////////////////////////////////////// + ERC721 LOGIC + //////////////////////////////////////////////////////////////*/ + + function approve(address spender, uint256 id) public virtual { + address owner = _ownerOf[id]; + + require(msg.sender == owner || isApprovedForAll[owner][msg.sender], "NOT_AUTHORIZED"); + + getApproved[id] = spender; + + emit Approval(owner, spender, id); + } + + function setApprovalForAll(address operator, bool approved) public virtual { + isApprovedForAll[msg.sender][operator] = approved; + + emit ApprovalForAll(msg.sender, operator, approved); + } + + function transferFrom( + address from, + address to, + uint256 id + ) public virtual { + require(from == _ownerOf[id], "WRONG_FROM"); + + require(to != address(0), "INVALID_RECIPIENT"); + + require( + msg.sender == from || isApprovedForAll[from][msg.sender] || msg.sender == getApproved[id], + "NOT_AUTHORIZED" + ); + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + unchecked { + _balanceOf[from]--; + + _balanceOf[to]++; + } + + _ownerOf[id] = to; + + delete getApproved[id]; + + emit Transfer(from, to, id); + } + + function safeTransferFrom( + address from, + address to, + uint256 id + ) public virtual { + transferFrom(from, to, id); + + require( + to.code.length == 0 || + ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") == + ERC721TokenReceiver.onERC721Received.selector, + "UNSAFE_RECIPIENT" + ); + } + + function safeTransferFrom( + address from, + address to, + uint256 id, + bytes calldata data + ) public virtual { + transferFrom(from, to, id); + + require( + to.code.length == 0 || + ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, data) == + ERC721TokenReceiver.onERC721Received.selector, + "UNSAFE_RECIPIENT" + ); + } + + /*////////////////////////////////////////////////////////////// + ERC165 LOGIC + //////////////////////////////////////////////////////////////*/ + + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return + interfaceId == 0x01ffc9a7 || // ERC165 Interface ID for ERC165 + interfaceId == 0x80ac58cd || // ERC165 Interface ID for ERC721 + interfaceId == 0x5b5e139f; // ERC165 Interface ID for ERC721Metadata + } + + /*////////////////////////////////////////////////////////////// + INTERNAL MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + + function _mint(address to, uint256 id) internal virtual { + require(to != address(0), "INVALID_RECIPIENT"); + + require(_ownerOf[id] == address(0), "ALREADY_MINTED"); + + // Counter overflow is incredibly unrealistic. + unchecked { + _balanceOf[to]++; + } + + _ownerOf[id] = to; + + emit Transfer(address(0), to, id); + } + + function _burn(uint256 id) internal virtual { + address owner = _ownerOf[id]; + + require(owner != address(0), "NOT_MINTED"); + + // Ownership check above ensures no underflow. + unchecked { + _balanceOf[owner]--; + } + + delete _ownerOf[id]; + + delete getApproved[id]; + + emit Transfer(owner, address(0), id); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL SAFE MINT LOGIC + //////////////////////////////////////////////////////////////*/ + + function _safeMint(address to, uint256 id) internal virtual { + _mint(to, id); + + require( + to.code.length == 0 || + ERC721TokenReceiver(to).onERC721Received(msg.sender, address(0), id, "") == + ERC721TokenReceiver.onERC721Received.selector, + "UNSAFE_RECIPIENT" + ); + } + + function _safeMint( + address to, + uint256 id, + bytes memory data + ) internal virtual { + _mint(to, id); + + require( + to.code.length == 0 || + ERC721TokenReceiver(to).onERC721Received(msg.sender, address(0), id, data) == + ERC721TokenReceiver.onERC721Received.selector, + "UNSAFE_RECIPIENT" + ); + } +} + +/// @notice A generic interface for a contract which properly accepts ERC721 tokens. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/tokens/ERC721.sol) +abstract contract ERC721TokenReceiver { + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external virtual returns (bytes4) { + return ERC721TokenReceiver.onERC721Received.selector; + } +} diff --git a/contracts/mocks/Tokens/ERC721Mock.sol b/contracts/mocks/Tokens/ERC721Mock.sol index 846ec61172..111f236d4f 100644 --- a/contracts/mocks/Tokens/ERC721Mock.sol +++ b/contracts/mocks/Tokens/ERC721Mock.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.18; -import "solmate/src/tokens/ERC721.sol"; +import "./ERC721.sol"; contract ERC721Mock is ERC721 { @@ -23,20 +23,20 @@ contract ERC721Mock is ERC721 { function _operatorTransferFrom(address from, address to, uint256 tokenId) internal { - require(from == _ownerOf[tokenId], "WRONG_FROM"); - require(to != address(0), "INVALID_RECIPIENT"); + require(from == _ownerOf[tokenId], "WRONG_FROM"); + require(to != address(0), "INVALID_RECIPIENT"); - // Underflow of the sender's balance is impossible because we check for - // ownership above and the recipient's balance can't realistically overflow. - unchecked { - _balanceOf[from]--; - _balanceOf[to]++; - } + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + unchecked { + _balanceOf[from]--; + _balanceOf[to]++; + } - _ownerOf[tokenId] = to; - delete getApproved[tokenId]; + _ownerOf[tokenId] = to; + delete getApproved[tokenId]; - emit Transfer(from, to, tokenId); + emit Transfer(from, to, tokenId); } } diff --git a/contracts/mocks/common/ERC20Detailed.sol b/contracts/mocks/common/ERC20Detailed.sol new file mode 100644 index 0000000000..bb8f47ad56 --- /dev/null +++ b/contracts/mocks/common/ERC20Detailed.sol @@ -0,0 +1,54 @@ +pragma solidity ^0.5.0; + +import "../../interfaces/IERC20.sol"; + +/** + * @dev Optional functions from the ERC20 standard. + */ +contract ERC20Detailed is IERC20 { + string private _name; + string private _symbol; + uint8 private _decimals; + + /** + * @dev Sets the values for `name`, `symbol`, and `decimals`. All three of + * these values are immutable: they can only be set once during + * construction. + */ + constructor (string memory name, string memory symbol, uint8 decimals) public { + _name = name; + _symbol = symbol; + _decimals = decimals; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view returns (string memory) { + return _name; + } + + /** + * @dev Returns the symbol of the token, usually a shorter version of the + * name. + */ + function symbol() public view returns (string memory) { + return _symbol; + } + + /** + * @dev Returns the number of decimals used to get its user representation. + * For example, if `decimals` equals `2`, a balance of `505` tokens should + * be displayed to a user as `5,05` (`505 / 10 ** 2`). + * + * Tokens usually opt for a value of 18, imitating the relationship between + * Ether and Wei. + * + * NOTE: This information is only used for _display_ purposes: it in + * no way affects any of the arithmetic of the contract, including + * {IERC20-balanceOf} and {IERC20-transfer}. + */ + function decimals() public view returns (uint8) { + return _decimals; + } +} diff --git a/contracts/mocks/common/ERC20Mintable.sol b/contracts/mocks/common/ERC20Mintable.sol new file mode 100644 index 0000000000..6cdea9aae5 --- /dev/null +++ b/contracts/mocks/common/ERC20Mintable.sol @@ -0,0 +1,23 @@ +pragma solidity ^0.5.0; + +import "../../modules/token/external/ERC20.sol"; + +/** + * @dev Extension of {ERC20} that adds a set of accounts with the {MinterRole}, + * which have permission to mint (create) new tokens as they see fit. + * + * At construction, the deployer of the contract is the only minter. + */ +contract ERC20Mintable is ERC20 { + /** + * @dev See {ERC20-_mint}. + * + * Requirements: + * + * - the caller must have the {MinterRole}. + */ + function mint(address account, uint256 amount) public returns (bool) { + _mint(account, amount); + return true; + } +} diff --git a/contracts/mocks/common/NXMTokenMock.sol b/contracts/mocks/common/NXMTokenMock.sol index 6a04462261..f8a8ff4c70 100644 --- a/contracts/mocks/common/NXMTokenMock.sol +++ b/contracts/mocks/common/NXMTokenMock.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.17; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../../modules/token/external/ERC20.sol"; import "../../interfaces/INXMToken.sol"; contract NXMTokenMock is INXMToken, ERC20 { diff --git a/contracts/modules/governance/Governance.sol b/contracts/modules/governance/Governance.sol index 6c7957ed52..6e6136ee5d 100644 --- a/contracts/modules/governance/Governance.sol +++ b/contracts/modules/governance/Governance.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../libraries/external/SafeMath.sol"; import "../../abstract/LegacyMasterAware.sol"; import "../../interfaces/IGovernance.sol"; import "../../interfaces/IMemberRoles.sol"; diff --git a/contracts/modules/legacy/LegacyClaimsData.sol b/contracts/modules/legacy/LegacyClaimsData.sol index faf76947db..39a555f92f 100644 --- a/contracts/modules/legacy/LegacyClaimsData.sol +++ b/contracts/modules/legacy/LegacyClaimsData.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../libraries/external/SafeMath.sol"; import "../../abstract/LegacyMasterAware.sol"; import "../../interfaces/ILegacyClaimsData.sol"; diff --git a/contracts/modules/legacy/LegacyQuotationData.sol b/contracts/modules/legacy/LegacyQuotationData.sol index fa610a19ac..e150efd82a 100644 --- a/contracts/modules/legacy/LegacyQuotationData.sol +++ b/contracts/modules/legacy/LegacyQuotationData.sol @@ -2,7 +2,7 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../libraries/external/SafeMath.sol"; import "../../abstract/LegacyMasterAware.sol"; contract LegacyQuotationData is LegacyMasterAware { diff --git a/contracts/modules/token/external/Context.sol b/contracts/modules/token/external/Context.sol new file mode 100644 index 0000000000..4db874da61 --- /dev/null +++ b/contracts/modules/token/external/Context.sol @@ -0,0 +1,27 @@ +pragma solidity ^0.5.0; + +/* + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with GSN meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +contract Context { + // Empty internal constructor, to prevent people from mistakenly deploying + // an instance of this contract, which should be used via inheritance. + constructor () internal { } + // solhint-disable-previous-line no-empty-blocks + + function _msgSender() internal view returns (address payable) { + return msg.sender; + } + + function _msgData() internal view returns (bytes memory) { + this; // silence state mutability warning without generating bytecode - see https://github.com/ethereum/solidity/issues/2691 + return msg.data; + } +} diff --git a/contracts/modules/token/external/ERC20.sol b/contracts/modules/token/external/ERC20.sol new file mode 100644 index 0000000000..b72196b449 --- /dev/null +++ b/contracts/modules/token/external/ERC20.sol @@ -0,0 +1,230 @@ +pragma solidity ^0.5.0; + +import "./Context.sol"; +import "../../../interfaces/IERC20.sol"; +import "../../../libraries/external/SafeMath.sol"; + +/** + * @dev Implementation of the {IERC20} interface. + * + * This implementation is agnostic to the way tokens are created. This means + * that a supply mechanism has to be added in a derived contract using {_mint}. + * For a generic mechanism see {ERC20Mintable}. + * + * TIP: For a detailed writeup see our guide + * https://forum.zeppelin.solutions/t/how-to-implement-erc20-supply-mechanisms/226[How + * to implement supply mechanisms]. + * + * We have followed general OpenZeppelin guidelines: functions revert instead + * of returning `false` on failure. This behavior is nonetheless conventional + * and does not conflict with the expectations of ERC20 applications. + * + * Additionally, an {Approval} event is emitted on calls to {transferFrom}. + * This allows applications to reconstruct the allowance for all accounts just + * by listening to said events. Other implementations of the EIP may not emit + * these events, as it isn't required by the specification. + * + * Finally, the non-standard {decreaseAllowance} and {increaseAllowance} + * functions have been added to mitigate the well-known issues around setting + * allowances. See {IERC20-approve}. + */ +contract ERC20 is Context, IERC20 { + using SafeMath for uint256; + + mapping (address => uint256) private _balances; + + mapping (address => mapping (address => uint256)) private _allowances; + + uint256 private _totalSupply; + + /** + * @dev See {IERC20-totalSupply}. + */ + function totalSupply() public view returns (uint256) { + return _totalSupply; + } + + /** + * @dev See {IERC20-balanceOf}. + */ + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `recipient` cannot be the zero address. + * - the caller must have a balance of at least `amount`. + */ + function transfer(address recipient, uint256 amount) public returns (bool) { + _transfer(_msgSender(), recipient, amount); + return true; + } + + /** + * @dev See {IERC20-allowance}. + */ + function allowance(address owner, address spender) public view returns (uint256) { + return _allowances[owner][spender]; + } + + /** + * @dev See {IERC20-approve}. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(address spender, uint256 amount) public returns (bool) { + _approve(_msgSender(), spender, amount); + return true; + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Emits an {Approval} event indicating the updated allowance. This is not + * required by the EIP. See the note at the beginning of {ERC20}; + * + * Requirements: + * - `sender` and `recipient` cannot be the zero address. + * - `sender` must have a balance of at least `amount`. + * - the caller must have allowance for `sender`'s tokens of at least + * `amount`. + */ + function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) { + _transfer(sender, recipient, amount); + _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance")); + return true; + } + + /** + * @dev Atomically increases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function increaseAllowance(address spender, uint256 addedValue) public returns (bool) { + _approve(_msgSender(), spender, _allowances[_msgSender()][spender].add(addedValue)); + return true; + } + + /** + * @dev Atomically decreases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `spender` must have allowance for the caller of at least + * `subtractedValue`. + */ + function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) { + _approve(_msgSender(), spender, _allowances[_msgSender()][spender].sub(subtractedValue, "ERC20: decreased allowance below zero")); + return true; + } + + /** + * @dev Moves tokens `amount` from `sender` to `recipient`. + * + * This is internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Emits a {Transfer} event. + * + * Requirements: + * + * - `sender` cannot be the zero address. + * - `recipient` cannot be the zero address. + * - `sender` must have a balance of at least `amount`. + */ + function _transfer(address sender, address recipient, uint256 amount) internal { + require(sender != address(0), "ERC20: transfer from the zero address"); + require(recipient != address(0), "ERC20: transfer to the zero address"); + + _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance"); + _balances[recipient] = _balances[recipient].add(amount); + emit Transfer(sender, recipient, amount); + } + + /** @dev Creates `amount` tokens and assigns them to `account`, increasing + * the total supply. + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * Requirements + * + * - `to` cannot be the zero address. + */ + function _mint(address account, uint256 amount) internal { + require(account != address(0), "ERC20: mint to the zero address"); + + _totalSupply = _totalSupply.add(amount); + _balances[account] = _balances[account].add(amount); + emit Transfer(address(0), account, amount); + } + + /** + * @dev Destroys `amount` tokens from `account`, reducing the + * total supply. + * + * Emits a {Transfer} event with `to` set to the zero address. + * + * Requirements + * + * - `account` cannot be the zero address. + * - `account` must have at least `amount` tokens. + */ + function _burn(address account, uint256 amount) internal { + require(account != address(0), "ERC20: burn from the zero address"); + + _balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance"); + _totalSupply = _totalSupply.sub(amount); + emit Transfer(account, address(0), amount); + } + + /** + * @dev Sets `amount` as the allowance of `spender` over the `owner`s tokens. + * + * This is internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + */ + function _approve(address owner, address spender, uint256 amount) internal { + require(owner != address(0), "ERC20: approve from the zero address"); + require(spender != address(0), "ERC20: approve to the zero address"); + + _allowances[owner][spender] = amount; + emit Approval(owner, spender, amount); + } + + /** + * @dev Destroys `amount` tokens from `account`.`amount` is then deducted + * from the caller's allowance. + * + * See {_burn} and {_approve}. + */ + function _burnFrom(address account, uint256 amount) internal { + _burn(account, amount); + _approve(account, _msgSender(), _allowances[account][_msgSender()].sub(amount, "ERC20: burn amount exceeds allowance")); + } +} diff --git a/package-lock.json b/package-lock.json index 4ff33595d2..6b27a633d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@nexusmutual/deployments": "^2.4.2", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", - "@openzeppelin/contracts": "^2.5.1", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", "dotenv": "^8.6.0", "ethereum-cryptography": "^1.0.1", @@ -20,7 +19,6 @@ "hardhat-ignore-warnings": "^0.2.8", "merkletreejs": "^0.2.32", "node-fetch": "^2.6.7", - "solmate": "^6.6.1", "workerpool": "^6.2.0" }, "devDependencies": { @@ -2108,11 +2106,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@openzeppelin/contracts": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-2.5.1.tgz", - "integrity": "sha512-qIy6tLx8rtybEsIOAlrM4J/85s2q2nPkDqj/Rx46VakBZ0LwtFhXIVub96LXHczQX0vaqmAueDqNPXtbSXSaYQ==" - }, "node_modules/@openzeppelin/contracts-v4": { "name": "@openzeppelin/contracts", "version": "4.7.3", @@ -11834,11 +11827,6 @@ "node": ">=10" } }, - "node_modules/solmate": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/solmate/-/solmate-6.6.1.tgz", - "integrity": "sha512-WHvRXQvGtgR6R9nmkDTz/d+oULMqf/D33rlzQyadTX2SbuTmaW7ToEjGjGtWUVCQwZsZ/JP3vbOEVv7fB50btg==" - }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -15609,11 +15597,6 @@ } } }, - "@openzeppelin/contracts": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-2.5.1.tgz", - "integrity": "sha512-qIy6tLx8rtybEsIOAlrM4J/85s2q2nPkDqj/Rx46VakBZ0LwtFhXIVub96LXHczQX0vaqmAueDqNPXtbSXSaYQ==" - }, "@openzeppelin/contracts-v4": { "version": "npm:@openzeppelin/contracts@4.7.3", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.7.3.tgz", @@ -23236,11 +23219,6 @@ } } }, - "solmate": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/solmate/-/solmate-6.6.1.tgz", - "integrity": "sha512-WHvRXQvGtgR6R9nmkDTz/d+oULMqf/D33rlzQyadTX2SbuTmaW7ToEjGjGtWUVCQwZsZ/JP3vbOEVv7fB50btg==" - }, "source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", diff --git a/package.json b/package.json index 6d2b4ce7bf..daab870c17 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "dependencies": { "@nexusmutual/deployments": "^2.4.2", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", - "@openzeppelin/contracts": "^2.5.1", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", "dotenv": "^8.6.0", "ethereum-cryptography": "^1.0.1", @@ -31,7 +30,6 @@ "hardhat-ignore-warnings": "^0.2.8", "merkletreejs": "^0.2.32", "node-fetch": "^2.6.7", - "solmate": "^6.6.1", "workerpool": "^6.2.0" }, "devDependencies": { From 8e1dd53100ae754523f8e49ec7cfa8eb5de09a39 Mon Sep 17 00:00:00 2001 From: shark0der Date: Thu, 21 Mar 2024 19:27:40 +0200 Subject: [PATCH 22/88] Process expiration up to current timestamp when withdrawing staking rewards --- contracts/modules/staking/StakingPool.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index 035fc59b9a..92e5137bd5 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -533,7 +533,7 @@ contract StakingPool is IStakingPool, Multicall { uint managerLockedInGovernanceUntil = nxm.isLockedForMV(manager()); // pass false as it does not modify the share supply nor the reward per second - processExpirations(false); + processExpirations(true); uint _accNxmPerRewardsShare = accNxmPerRewardsShare; uint _firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; From c89474004faaf2a4a4fbbdcd59a03ee1e6ad7487 Mon Sep 17 00:00:00 2001 From: shark0der Date: Thu, 21 Mar 2024 19:27:48 +0200 Subject: [PATCH 23/88] Fix multi-withdraw test --- test/unit/StakingPool/withdraw.js | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/test/unit/StakingPool/withdraw.js b/test/unit/StakingPool/withdraw.js index a01aa0f8da..c3e381c09e 100644 --- a/test/unit/StakingPool/withdraw.js +++ b/test/unit/StakingPool/withdraw.js @@ -1,5 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); + const { increaseTime, mineNextBlock, setNextBlockTime } = require('../utils').evm; const { getTranches, @@ -15,7 +16,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); const { BigNumber } = ethers; -const { AddressZero } = ethers.constants; +const { AddressZero, WeiPerEther } = ethers.constants; const { parseEther } = ethers.utils; const product0 = { @@ -505,7 +506,7 @@ describe('withdraw', function () { const lastTokenId = await stakingNFT.totalSupply(); const [aliceTokenId, bobTokenId] = [lastTokenId.add(1), lastTokenId.add(2)]; - // alice creates 2 deposits at the same time + // creates 2 deposits at the same time: one for alice, one for bob await stakingPool .connect(alice) .multicall( @@ -551,15 +552,18 @@ describe('withdraw', function () { const withdrawRewards = true; // half way through rewards period - await increaseTime(rewardsPeriod / 2); + const { timestamp } = await ethers.provider.getBlock('latest'); + await setNextBlockTime(timestamp + rewardsPeriod / 2); await stakingPool.withdraw(aliceTokenId, withdrawStake, withdrawRewards, [lastTrancheId]); + const firstAccNxmPerRewardShare = await stakingPool.getAccNxmPerRewardsShare(); - // advance time untill after the rewards period has ended - await increaseTime(rewardsPeriod + BUCKET_DURATION); + // advance time until after the rewards period has ended + await setNextBlockTime(timestamp + rewardsPeriod + BUCKET_DURATION); await stakingPool.withdraw(aliceTokenId, withdrawStake, withdrawRewards, [lastTrancheId]); await stakingPool.withdraw(bobTokenId, withdrawStake, withdrawRewards, [lastTrancheId]); await stakingPool.withdraw(0, withdrawStake, withdrawRewards, [lastTrancheId]); + const secondAccNxmPerRewardShare = await stakingPool.getAccNxmPerRewardsShare(); const { rewards: rewardsLeft } = await tokenController.stakingPoolNXMBalances(poolId); const rewardPerSecond = premium.div(rewardStreamPeriod); @@ -577,15 +581,22 @@ describe('withdraw', function () { const rewardShareSupply = await stakingPool.getRewardsSharesSupply(); const expectedManagerRewards = expectedRewardsMinted.mul(managerDeposit.rewardsShares).div(rewardShareSupply); - const expectedPerStakerRewards = expectedRewardsMinted.mul(aliceDeposit.rewardsShares).div(rewardShareSupply); - const expectedWithdrawnRewards = expectedPerStakerRewards.mul(2).add(expectedManagerRewards); + const aliceFirstWithdraw = firstAccNxmPerRewardShare.mul(aliceDeposit.rewardsShares).div(WeiPerEther); + const aliceSecondWithdraw = secondAccNxmPerRewardShare + .sub(firstAccNxmPerRewardShare) + .mul(aliceDeposit.rewardsShares) + .div(WeiPerEther); + const expectedAliceRewards = aliceFirstWithdraw.add(aliceSecondWithdraw); + + const expectedBobRewards = expectedRewardsMinted.mul(aliceDeposit.rewardsShares).div(rewardShareSupply); + const expectedWithdrawnRewards = expectedBobRewards.add(expectedAliceRewards).add(expectedManagerRewards); const expectedLeftRewards = expectedRewardsMinted.sub(expectedWithdrawnRewards); + expect(managerRewards).to.eq(expectedManagerRewards); + expect(aliceRewards).to.eq(expectedAliceRewards); + expect(bobRewards).to.eq(expectedBobRewards); expect(actualRewardsMinted).to.eq(expectedRewardsMinted); expect(rewardsLeft).to.eq(expectedLeftRewards); - expect(managerRewards).to.eq(expectedManagerRewards); - expect(aliceRewards).to.eq(expectedPerStakerRewards); - expect(aliceRewards).to.eq(bobRewards); }); it('should emit some event', async function () { From 9155cdcc2ff67286b1010905349ad7a4dd202a63 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 5 Feb 2024 10:27:27 +0200 Subject: [PATCH 24/88] Add swapOperator.placeOrder asset -> asset support --- contracts/modules/capital/SwapOperator.sol | 75 ++++++++++++++-------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 492aa6a4ad..885f71c969 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -108,6 +108,27 @@ contract SwapOperator { return uid; } + function validateBuyAmoutOnMaxSlippage( + uint orderBuyAmount, + uint oracleBuyAmount, + uint16 maxSlippageRatio + ) internal pure { + // Calculate slippage and minimum amount we should accept + uint maxSlippageAmount = (oracleBuyAmount * maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; + uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; + require(orderBuyAmount >= minBuyAmountOnMaxSlippage, "SwapOp: order.buyAmount too low (oracle)"); + } + + function getToAssetForFromAsset( + IPriceFeedOracle priceFeedOracle, + address toAsset, + address fromAsset, + uint fromAssetAmount + ) internal view returns (uint) { + uint fromAssetInEth = priceFeedOracle.getEthForAsset(fromAsset, fromAssetAmount); + return priceFeedOracle.getAssetForEth(toAsset, fromAssetInEth); + } + /** * @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract * Only one order can be open at the same time, and one of the swapped assets must be ether @@ -128,29 +149,26 @@ contract SwapOperator { IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); uint totalOutAmount = order.sellAmount + order.feeAmount; + // TODO: replace requires with custom errors if (isSellingEth(order)) { - // Validate min/max setup for buyToken + // ETH -> asset + + // Validate minimum pool eth reserve when selling ETH + require(address(pool).balance - totalOutAmount >= minPoolEth, "SwapOp: Pool eth balance below min"); + SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(order.buyToken)); require(swapDetails.minAmount != 0 || swapDetails.maxAmount != 0, "SwapOp: buyToken is not enabled"); + uint buyTokenBalance = order.buyToken.balanceOf(address(pool)); require(buyTokenBalance < swapDetails.minAmount, "SwapOp: can only buy asset when < minAmount"); require(buyTokenBalance + order.buyAmount <= swapDetails.maxAmount, "SwapOp: swap brings buyToken above max"); validateSwapFrequency(swapDetails); - validateMaxFee(priceFeedOracle, ETH, order.feeAmount); - // Validate minimum pool eth reserve - require(address(pool).balance - totalOutAmount >= minPoolEth, "SwapOp: Pool eth balance below min"); - // Ask oracle how much of the other asset we should get uint oracleBuyAmount = priceFeedOracle.getAssetForEth(address(order.buyToken), order.sellAmount); - - // Calculate slippage and minimum amount we should accept - uint maxSlippageAmount = (oracleBuyAmount * swapDetails.maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; - uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; - - require(order.buyAmount >= minBuyAmountOnMaxSlippage, "SwapOp: order.buyAmount too low (oracle)"); + validateBuyAmoutOnMaxSlippage(order.buyAmount, oracleBuyAmount, swapDetails.maxSlippageRatio); refreshAssetLastSwapDate(pool, address(order.buyToken)); @@ -158,38 +176,45 @@ contract SwapOperator { pool.transferAssetToSwapOperator(ETH, totalOutAmount); weth.deposit{value: totalOutAmount}(); - // Set pool's swapValue + // Set the calculated oracle swapValue on the pool pool.setSwapValue(totalOutAmount); - } else if (isBuyingEth(order)) { - // Validate min/max setup for sellToken + + } else { + // asset -> ETH OR asset -> asset + SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(order.sellToken)); require(swapDetails.minAmount != 0 || swapDetails.maxAmount != 0, "SwapOp: sellToken is not enabled"); + uint sellTokenBalance = order.sellToken.balanceOf(address(pool)); require(sellTokenBalance > swapDetails.maxAmount, "SwapOp: can only sell asset when > maxAmount"); require(sellTokenBalance - totalOutAmount >= swapDetails.minAmount, "SwapOp: swap brings sellToken below min"); validateSwapFrequency(swapDetails); - validateMaxFee(priceFeedOracle, address(order.sellToken), order.feeAmount); - // Ask oracle how much ether we should get - uint oracleBuyAmount = priceFeedOracle.getEthForAsset(address(order.sellToken), order.sellAmount); + // Ask oracle how much we should get (oracleBuyAmount) and what is the expected swapValue + uint oracleBuyAmount; + uint swapValue; + + if (isBuyingEth(order)) { + // asset -> ETH + oracleBuyAmount = priceFeedOracle.getEthForAsset(address(order.sellToken), order.sellAmount); + swapValue = priceFeedOracle.getEthForAsset(address(order.sellToken), totalOutAmount); + } else { + // asset -> asset + oracleBuyAmount = getToAssetForFromAsset(priceFeedOracle, address(order.buyToken), address(order.sellToken), order.sellAmount); + swapValue = getToAssetForFromAsset(priceFeedOracle, address(order.buyToken), address(order.sellToken), totalOutAmount); + } - // Calculate slippage and minimum amount we should accept - uint maxSlippageAmount = (oracleBuyAmount * swapDetails.maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; - uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; - require(order.buyAmount >= minBuyAmountOnMaxSlippage, "SwapOp: order.buyAmount too low (oracle)"); + validateBuyAmoutOnMaxSlippage(order.buyAmount, oracleBuyAmount, swapDetails.maxSlippageRatio); refreshAssetLastSwapDate(pool, address(order.sellToken)); // Transfer ERC20 asset from Pool pool.transferAssetToSwapOperator(address(order.sellToken), totalOutAmount); - // Calculate swapValue using oracle and set it on the pool - uint swapValue = priceFeedOracle.getEthForAsset(address(order.sellToken), totalOutAmount); + // Set the calculated oracle swapValue on the pool pool.setSwapValue(swapValue); - } else { - revert("SwapOp: Must either sell or buy eth"); } // Approve Cow's contract to spend sellToken From 8dfeaf88c35b311d2449fd973ac1075b553e8bc3 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 5 Feb 2024 13:59:29 +0200 Subject: [PATCH 25/88] Update swapOperator.placeOrder unit tests and setup --- test/unit/SwapOperator/placeOrder.js | 405 +++++++++++++++++++-------- test/unit/SwapOperator/setup.js | 2 + 2 files changed, 290 insertions(+), 117 deletions(-) diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index b19367a731..ca285aae89 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -1,3 +1,9 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { domain: makeDomain, computeOrderUid } = require('@cowprotocol/contracts'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { setEtherBalance, setNextBlockTime } = require('../../utils/evm'); const { makeWrongValue, makeContractOrder, @@ -7,25 +13,15 @@ const { stethMaxAmount, daiMaxAmount, } = require('./helpers'); -const { ethers } = require('hardhat'); -const { expect } = require('chai'); -const { domain: makeDomain, computeOrderUid } = require('@cowprotocol/contracts'); - -const { setEtherBalance, setNextBlockTime } = require('../../utils/evm'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); const { parseEther, hexZeroPad, hexlify, randomBytes } = ethers.utils; -async function placeOrderSetup() { +/** + * weth -> dai swap + */ +async function placeSellWethOrderSetup() { const fixture = await loadFixture(setup); - const [controller, governance] = await ethers.getSigners(); - - // Assign contracts (destructuring isn't working) - const { dai, stEth, weth, pool, swapOperator, cowSettlement } = fixture.contracts; - - // Read constants - const MIN_TIME_BETWEEN_ORDERS = (await swapOperator.MIN_TIME_BETWEEN_ORDERS()).toNumber(); - + const { dai, weth, swapOperator } = fixture.contracts; // Build order struct, domain separator and calculate UID const order = { sellToken: weth.address, @@ -42,6 +38,43 @@ async function placeOrderSetup() { buyTokenBalance: 'erc20', }; + return placeOrderSetup(order); +} + +/** + * stEth -> dai swap + */ +async function placeNonEthOrderSetup() { + const fixture = await loadFixture(setup); + const { dai, stEth, swapOperator } = fixture.contracts; + + // Build order struct, domain separator and calculate UID + const order = { + sellToken: stEth.address, + buyToken: dai.address, + receiver: swapOperator.address, + sellAmount: parseEther('2'), + buyAmount: parseEther('10000'), + validTo: (await lastBlockTimestamp()) + 650, + appData: hexZeroPad(0, 32), + feeAmount: parseEther('0.001'), + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + }; + + return placeOrderSetup(order); +} + +async function placeOrderSetup(order) { + const fixture = await loadFixture(setup); + const [controller, governance] = await ethers.getSigners(); + + const { dai, stEth, pool, swapOperator, cowSettlement } = fixture.contracts; + // Read constants + const MIN_TIME_BETWEEN_ORDERS = (await swapOperator.MIN_TIME_BETWEEN_ORDERS()).toNumber(); + const contractOrder = makeContractOrder(order); const { chainId } = await ethers.provider.getNetwork(); @@ -67,7 +100,10 @@ async function placeOrderSetup() { }; } -describe('placeOrder', function () { +describe.only('placeOrder', function () { + /** + * dai -> weth swap + */ const setupSellDaiForEth = async (overrides = {}, { dai, pool, order, weth, domain }) => { // Set DAI balance above asset max, so we can sell it await dai.setBalance(pool.address, parseEther('25000')); @@ -94,7 +130,7 @@ describe('placeOrder', function () { controller, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // call with non-controller, should fail await expect(swapOperator.connect(governance).placeOrder(contractOrder, orderUID)).to.revertedWith( 'SwapOp: only controller can execute', @@ -109,7 +145,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // call with invalid UID, should fail const wrongUID = hexlify(randomBytes(56)); await expect(swapOperator.placeOrder(contractOrder, wrongUID)).to.revertedWith( @@ -136,7 +172,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // calling with valid data should succeed first time await swapOperator.placeOrder(contractOrder, orderUID); @@ -146,35 +182,12 @@ describe('placeOrder', function () { ); }); - it('fails if neither buyToken or sellToken are WETH', async function () { - const { - contracts: { swapOperator, dai, stEth, pool }, - order, - domain, - } = await loadFixture(placeOrderSetup); - const newOrder = { - ...order, - sellToken: dai.address, - sellAmount: parseEther('5000'), - buyToken: stEth.address, - buyAmount: parseEther('15'), - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - await dai.setBalance(pool.address, daiMaxAmount.add(1)); - - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: Must either sell or buy eth', - ); - }); - it('validates only erc20 is supported for sellTokenBalance', async function () { const { contracts: { swapOperator }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const newOrder = { ...order, sellTokenBalance: 'external', @@ -191,7 +204,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const newOrder = { ...order, buyTokenBalance: 'internal', @@ -210,7 +223,7 @@ describe('placeOrder', function () { governance, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const newOrder = { ...order, receiver: governance.address, @@ -227,7 +240,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const { timestamp } = await ethers.provider.getBlock('latest'); const newOrder = { @@ -241,12 +254,12 @@ describe('placeOrder', function () { ); }); - it('doesnt perform validation when sellToken is WETH, because eth is used', async function () { + it('does not perform validation when sellToken is WETH, because eth is used', async function () { const { contracts: { swapOperator, weth, pool }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Ensure eth (weth) is disabled by checking min and max amount const swapDetails = await pool.getAssetSwapDetails(weth.address); expect(swapDetails.minAmount).to.eq(0); @@ -262,7 +275,7 @@ describe('placeOrder', function () { order, domain, governance, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Set up an order to swap DAI for ETH const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); @@ -280,7 +293,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); // Try to run when balance is at maxAmount, @@ -299,7 +312,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); @@ -326,7 +339,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); @@ -345,7 +358,7 @@ describe('placeOrder', function () { contracts: { swapOperator, pool }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Set pool balance to 2 eth - 1 wei await setEtherBalance(pool.address, parseEther('2').sub(1)); @@ -360,12 +373,12 @@ describe('placeOrder', function () { await swapOperator.placeOrder(contractOrder, orderUID); }); - it('doesnt perform validation asset details when buyToken is WETH, because eth is used', async function () { + it('does not perform validation asset details when buyToken is WETH, because eth is used', async function () { const { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Ensure eth (weth) is disabled by checking min and max amount const swapDetails = await pool.getAssetSwapDetails(weth.address); expect(swapDetails.minAmount).to.eq(0); @@ -383,7 +396,7 @@ describe('placeOrder', function () { governance, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Since DAI was already registered on setup, set its details to 0 await pool.connect(governance).setSwapDetails(dai.address, 0, 0, 0); // otherSigner is governant @@ -398,7 +411,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, pool }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // set buyToken balance to be minAmount, txn should fail await dai.setBalance(pool.address, daiMinAmount); await expect(swapOperator.placeOrder(contractOrder, orderUID)).to.be.revertedWith( @@ -415,7 +428,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); await dai.setBalance(pool.address, 0); // try to place an order that will bring balance 1 wei above max, should fail @@ -438,7 +451,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); await dai.setBalance(pool.address, 0); // place an order that will bring balance 1 wei below min, should succeed @@ -459,7 +472,7 @@ describe('placeOrder', function () { orderUID, domain, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); await swapOperator.closeOrder(contractOrder); @@ -497,7 +510,7 @@ describe('placeOrder', function () { domain, governance, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); // Place and close an order @@ -533,12 +546,52 @@ describe('placeOrder', function () { await swapOperator.placeOrder(secondContractOrder, secondOrderUID); }); + it('validates minimum time between swaps when swapping asset to asset', async function () { + const { + contracts: { swapOperator, stEth, pool }, + governance, + contractOrder, + order, + orderUID, + domain, + MIN_TIME_BETWEEN_ORDERS, + } = await loadFixture(placeNonEthOrderSetup); + // Place and close an order + await swapOperator.placeOrder(contractOrder, orderUID); + await swapOperator.closeOrder(contractOrder); + + // Prepare valid pool params for allowing next order + await pool.connect(governance).setSwapDetails(stEth.address, stethMinAmount.mul(2), stethMaxAmount.mul(2), 0); + + // Read last swap time + const { lastSwapTime } = await pool.getAssetSwapDetails(stEth.address); + + // Set next block time to minimum - 2 + await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS - 2); + + // Build a new valid order + const secondOrder = { ...order, validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }; + const secondContractOrder = makeContractOrder(secondOrder); + const secondOrderUID = computeOrderUid(domain, secondOrder, secondOrder.receiver); + + // Try to place order, should revert because of frequency + await expect(swapOperator.placeOrder(secondContractOrder, secondOrderUID)).to.be.revertedWith( + 'SwapOp: already swapped this asset recently', + ); + + // Set next block time to minimum + await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS); + + // Placing the order should succeed now + await swapOperator.placeOrder(secondContractOrder, secondOrderUID); + }); + it('when selling ether, checks that feeAmount is not higher than maxFee', async function () { const { contracts: { swapOperator }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const maxFee = await swapOperator.maxFee(); // Place order with fee 1 wei higher than maximum, should fail @@ -560,19 +613,19 @@ describe('placeOrder', function () { it('when selling other asset, uses oracle to check fee in ether is not higher than maxFee', async function () { const { - contracts: { swapOperator, pool, dai, weth }, + contracts: { swapOperator, pool, priceFeedOracle, dai, weth }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const maxFee = await swapOperator.maxFee(); + const daiToEthRate = await priceFeedOracle.getAssetToEthRate(dai.address); + const ethToDaiRate = parseEther('1').div(daiToEthRate); // 1 ETH -> N DAI // Place order with fee 1 wei higher than maximum, should fail const { newContractOrder: badContractOrder, newOrderUID: badOrderUID } = await setupSellDaiForEth( - { - feeAmount: maxFee.add(1).mul(5000), - }, + { feeAmount: maxFee.add(1).mul(ethToDaiRate) }, { dai, pool, order, weth, domain }, - ); // because 1 eth = 5000 dai + ); await expect(swapOperator.placeOrder(badContractOrder, badOrderUID)).to.be.revertedWith( 'SwapOp: Fee amount is higher than configured max fee', @@ -580,25 +633,26 @@ describe('placeOrder', function () { // Place order with exactly maxFee, should succeed const { newContractOrder: goodContractOrder, newOrderUID: goodOrderUID } = await setupSellDaiForEth( - { - feeAmount: maxFee.mul(5000), - }, + { feeAmount: maxFee.mul(ethToDaiRate) }, { dai, pool, order, weth, domain }, - ); // because 1 eth = 5000 dai + ); await swapOperator.placeOrder(goodContractOrder, goodOrderUID); }); it('when selling eth takes oracle price into account', async function () { const { - contracts: { swapOperator }, + contracts: { swapOperator, priceFeedOracle, dai }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); + const sellAmount = parseEther('1'); // 1 ETH + const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); + const newOrder = { ...order, - sellAmount: parseEther('1'), - buyAmount: parseEther('5000').sub(1), // 5000e18 - 1 DAI wei + sellAmount, + buyAmount: buyAmount.sub(1), // 1 DAI wei less than oracle price }; let newContractOrder = makeContractOrder(newOrder); let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); @@ -620,18 +674,21 @@ describe('placeOrder', function () { it('when selling eth takes slippage into account', async function () { const { - contracts: { swapOperator, dai, pool }, + contracts: { swapOperator, pool, priceFeedOracle, dai }, governance, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); + const sellAmount = parseEther('1'); // 1 ETH + const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); + // 1% slippage await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); const newOrder = { ...order, - sellAmount: parseEther('1'), - buyAmount: parseEther('4950').sub(1), // 4950e18 - 1 DAI wei + sellAmount, + buyAmount: buyAmount.mul(99).div(100).sub(1), // -1% -1 wei (i.e. > 1% slippage) }; let newContractOrder = makeContractOrder(newOrder); let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); @@ -656,7 +713,7 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); let { newOrder, newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); // Since buyAmount is short by 1 wei, txn should revert @@ -682,7 +739,7 @@ describe('placeOrder', function () { governance, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); // 1% slippage await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); @@ -705,6 +762,76 @@ describe('placeOrder', function () { await swapOperator.placeOrder(newContractOrder, newOrderUID); }); + it('when swapping non-ETH asset to non-ETH asset takes oracle price into account', async function () { + const { + contracts: { swapOperator, priceFeedOracle, dai }, + order, + domain, + } = await loadFixture(placeNonEthOrderSetup); + const sellAmount = parseEther('1'); // 1 stETH + // use eth rate as proxy since 1 stETH : 1 ETH + const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); + + const newOrder = { + ...order, + sellAmount, + buyAmount: buyAmount.sub(1), // 1 DAI wei less than oracle price + }; + let newContractOrder = makeContractOrder(newOrder); + let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + + // Since buyAmount is short by 1 wei, txn should revert + await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( + 'SwapOp: order.buyAmount too low (oracle)', + ); + + // Add 1 wei to buyAmount + newOrder.buyAmount = newOrder.buyAmount.add(1); + + newContractOrder = makeContractOrder(newOrder); + newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + + // Now txn should not revert + await swapOperator.placeOrder(newContractOrder, newOrderUID); + }); + + it('when swapping non-ETH asset to non-ETH asset takes slippage into account', async function () { + const { + contracts: { swapOperator, pool, priceFeedOracle, stEth, dai }, + governance, + order, + domain, + } = await loadFixture(placeNonEthOrderSetup); + const sellAmount = parseEther('1'); // 1 stETH + // use eth rate as proxy since 1 stETH : 1 ETH + const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); + + // 1% slippage + await pool.connect(governance).setSwapDetails(stEth.address, stethMinAmount, stethMaxAmount, 100); + + const newOrder = { + ...order, + sellAmount: parseEther('1'), + buyAmount: buyAmount.mul(99).div(100).sub(1), // // -1% -1 wei (i.e. > 1% slippage) + }; + let newContractOrder = makeContractOrder(newOrder); + let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + + // Since buyAmount > 1% slippage, txn should revert + await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( + 'SwapOp: order.buyAmount too low (oracle)', + ); + + // Add 1 wei to make it exactly 1% slippage + newOrder.buyAmount = newOrder.buyAmount.add(1); + + newContractOrder = makeContractOrder(newOrder); + newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + + // Now txn should not revert + await swapOperator.placeOrder(newContractOrder, newOrderUID); + }); + // eslint-disable-next-line max-len it('pulling funds from pool: transfers ether from pool and wrap it into WETH when sellToken is WETH', async function () { const { @@ -712,7 +839,7 @@ describe('placeOrder', function () { order, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const poolEthBefore = await ethers.provider.getBalance(pool.address); const swapOpWethBefore = await weth.balanceOf(swapOperator.address); @@ -727,25 +854,22 @@ describe('placeOrder', function () { it('pulling funds from pool: transfer erc20 asset from pool to eth if sellToken is not WETH', async function () { const { - contracts: { swapOperator, dai, weth, pool }, + contracts: { swapOperator, stEth, pool }, order, - domain, - } = await loadFixture(placeOrderSetup); - const { newOrder, newContractOrder, newOrderUID } = await setupSellDaiForEth( - {}, - { dai, pool, order, weth, domain }, - ); + orderUID, + contractOrder, + } = await loadFixture(placeNonEthOrderSetup); - const poolDaiBefore = await dai.balanceOf(pool.address); - const swapOpDaiBefore = await dai.balanceOf(swapOperator.address); + const poolStEthBefore = await stEth.balanceOf(pool.address); + const swapOpStEthBefore = await stEth.balanceOf(swapOperator.address); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); - const poolDaiAfter = await dai.balanceOf(pool.address); - const swapOpDaiAfter = await dai.balanceOf(swapOperator.address); + const poolStEthAfter = await stEth.balanceOf(pool.address); + const swapOpStEthAfter = await stEth.balanceOf(swapOperator.address); - expect(poolDaiBefore.sub(poolDaiAfter)).to.eq(newOrder.sellAmount.add(newOrder.feeAmount)); - expect(swapOpDaiAfter.sub(swapOpDaiBefore)).to.eq(newOrder.sellAmount.add(newOrder.feeAmount)); + expect(poolStEthBefore.sub(poolStEthAfter)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(swapOpStEthAfter.sub(swapOpStEthBefore)).to.eq(order.sellAmount.add(order.feeAmount)); }); it('sets lastSwapDate on buyAsset when selling ETH', async function () { @@ -753,13 +877,13 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, pool }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); - let lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + } = await loadFixture(placeSellWethOrderSetup); + let { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); await swapOperator.placeOrder(contractOrder, orderUID); - lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + ({ lastSwapTime } = await pool.getAssetSwapDetails(dai.address)); expect(lastSwapTime).to.eq(await lastBlockTimestamp()); }); @@ -768,14 +892,29 @@ describe('placeOrder', function () { contracts: { swapOperator, dai, weth, pool }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); - let lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + let { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); await swapOperator.placeOrder(newContractOrder, newOrderUID); - lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + ({ lastSwapTime } = await pool.getAssetSwapDetails(dai.address)); + expect(lastSwapTime).to.eq(await lastBlockTimestamp()); + }); + + it('sets lastSwapDate on sellAsset when swapping asset to asset', async function () { + const { + contracts: { swapOperator, stEth, pool }, + contractOrder, + orderUID, + } = await loadFixture(placeNonEthOrderSetup); + let { lastSwapTime } = await pool.getAssetSwapDetails(stEth.address); + expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); + + await swapOperator.placeOrder(contractOrder, orderUID); + + ({ lastSwapTime } = await pool.getAssetSwapDetails(stEth.address)); expect(lastSwapTime).to.eq(await lastBlockTimestamp()); }); @@ -785,7 +924,7 @@ describe('placeOrder', function () { order, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); expect(await pool.swapValue()).to.eq(0); await swapOperator.placeOrder(contractOrder, orderUID); @@ -793,35 +932,67 @@ describe('placeOrder', function () { expect(await pool.swapValue()).to.eq(order.sellAmount.add(order.feeAmount)); }); - it('setting pools swapValue works when transfering ERC20', async function () { + it('setting pools swapValue works when transferring ERC20', async function () { const { - contracts: { swapOperator, pool, weth, dai }, + contracts: { swapOperator, pool, priceFeedOracle, weth, dai }, order, domain, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); expect(await pool.swapValue()).to.eq(0); await swapOperator.placeOrder(newContractOrder, newOrderUID); - expect(await pool.swapValue()).to.be.equal(parseEther('2.0002')); // (10000 + 1) / 5000 + const { sellAmount, feeAmount } = newContractOrder; + const expectedSwapValue = await priceFeedOracle.getEthForAsset(dai.address, sellAmount.add(feeAmount)); + expect(await pool.swapValue()).to.be.equal(expectedSwapValue); + }); + + it('setting pools swapValue works when swapping non-ETH asset to non-ETH asset', async function () { + const { + contracts: { swapOperator, pool, priceFeedOracle, stEth, dai }, + order: { sellAmount, feeAmount }, + orderUID, + contractOrder, + } = await loadFixture(placeNonEthOrderSetup); + + expect(await pool.swapValue()).to.eq(0); + + await swapOperator.placeOrder(contractOrder, orderUID); + + const sellAmountWithFee = sellAmount.add(feeAmount); + const sellAmountInEth = await priceFeedOracle.getEthForAsset(stEth.address, sellAmountWithFee); // stETH -> ETH + const expectedSwapValue = await priceFeedOracle.getAssetForEth(dai.address, sellAmountInEth); // ETH -> DAI + expect(await pool.swapValue()).to.eq(expectedSwapValue); }); - it('approves CoW vault relayer to spend the exact amount of sellToken', async function () { + it('approves CoW vault relayer to spend the exact amount of sellToken when selling ETH', async function () { const { contracts: { swapOperator, weth, cowVaultRelayer }, - order, + order: { sellAmount, feeAmount }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); await swapOperator.placeOrder(contractOrder, orderUID); - expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( - order.sellAmount.add(order.feeAmount), - ); + expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(sellAmount.add(feeAmount)); + }); + + it('approves CoW vault relayer to spend the exact amount of sellToken when selling non-ETH asset', async function () { + const { + contracts: { swapOperator, stEth, cowVaultRelayer }, + order: { sellAmount, feeAmount }, + contractOrder, + orderUID, + } = await loadFixture(placeNonEthOrderSetup); + expect(await stEth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); + + await swapOperator.placeOrder(contractOrder, orderUID); + + expect(await stEth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(sellAmount.add(feeAmount)); }); it('stores the current orderUID in the contract', async function () { @@ -829,7 +1000,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); expect(await swapOperator.currentOrderUID()).to.eq('0x'); await swapOperator.placeOrder(contractOrder, orderUID); @@ -842,7 +1013,7 @@ describe('placeOrder', function () { contracts: { swapOperator, cowSettlement }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); expect(await cowSettlement.presignatures(orderUID)).to.eq(false); await swapOperator.placeOrder(contractOrder, orderUID); @@ -855,7 +1026,7 @@ describe('placeOrder', function () { contracts: { swapOperator }, contractOrder, orderUID, - } = await loadFixture(placeOrderSetup); + } = await loadFixture(placeSellWethOrderSetup); await expect(swapOperator.placeOrder(contractOrder, orderUID)) .to.emit(swapOperator, 'OrderPlaced') .withArgs(Object.values(contractOrder)); diff --git a/test/unit/SwapOperator/setup.js b/test/unit/SwapOperator/setup.js index 784c5b5c67..58412fad46 100644 --- a/test/unit/SwapOperator/setup.js +++ b/test/unit/SwapOperator/setup.js @@ -121,6 +121,8 @@ async function setup() { await pool.connect(governance).addAsset(usdc.address, true, 0, parseEther('1000'), 0); + await stEth.mint(pool.address, parseEther('50')); + // Deploy SwapOperator const swapOperator = await SwapOperator.deploy( cowSettlement.address, From 896767b24cd20a49c3a7e3edfb1f5095197e0e97 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 19 Feb 2024 12:34:10 +0200 Subject: [PATCH 26/88] Add Sepolia and Gnosis to supported hardhat networks --- hardhat.config.js/networks.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hardhat.config.js/networks.js b/hardhat.config.js/networks.js index 42adffd3bf..0e1b60fba3 100644 --- a/hardhat.config.js/networks.js +++ b/hardhat.config.js/networks.js @@ -19,7 +19,7 @@ const getenv = (network, key, fallback, parser = i => i) => { return value ? parser(value) : fallback; }; -for (const network of ['MAINNET', 'GOERLI', 'KOVAN', 'RINKEBY', 'TENDERLY', 'LOCALHOST']) { +for (const network of ['MAINNET', 'GOERLI', 'SEPOLIA', 'GNOSIS', 'TENDERLY', 'LOCALHOST']) { const url = getenv(network, 'PROVIDER_URL', false); if (!url) { continue; From c11e19106111232c084cc7bbf6dc9f427dff60e5 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 19 Feb 2024 12:35:06 +0200 Subject: [PATCH 27/88] Use a random private key for cowswap fork test --- test/fork/cowswap-swaps.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/fork/cowswap-swaps.js b/test/fork/cowswap-swaps.js index ac61032947..55369f1871 100644 --- a/test/fork/cowswap-swaps.js +++ b/test/fork/cowswap-swaps.js @@ -1,5 +1,6 @@ const { ethers, network } = require('hardhat'); const { expect } = require('chai'); +const crypto = require('crypto'); const { parseEther, hexZeroPad, toUtf8Bytes } = ethers.utils; const evm = require('./evm')(); @@ -15,7 +16,7 @@ const COWSWAP_SETTLEMENT = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41'; const COWSWAP_RELAYER = '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110'; const STABLECOIN_WHALE = '0x66f62574ab04989737228d18c3624f7fc1edae14'; const COWSWAP_SOLVER = '0x423cEc87f19F0778f549846e0801ee267a917935'; -const TRADER_PKEY = '489cddb08499334cf55b9649459915dfc6606cb7aa50e0aef22259b08d6d6fe4'; +const TRADER_PKEY = crypto.randomBytes(32).toString('hex'); const NXM_TOKEN_ADDRESS = '0xd7c49CEE7E9188cCa6AD8FF264C1DA2e69D4Cf3B'; From f3ac8eb84666a5e179e1e7b83275d38e0e949f26 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 19 Feb 2024 12:35:27 +0200 Subject: [PATCH 28/88] Update ISwapOperator interface --- contracts/interfaces/ISwapOperator.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index d637149c48..1849c4e557 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -2,6 +2,17 @@ pragma solidity >=0.5.0; +import "../external/cow/GPv2Order.sol"; + interface ISwapOperator { + + function getDigest(GPv2Order.Data calldata order) external view returns (bytes32); + + function getUID(GPv2Order.Data calldata order) external view returns (bytes memory); + + function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) external; + function orderInProgress() external returns (bool); + + function recoverAsset(address assetAddress, address receiver) external; } From 02c2203bdcafc11f1a6c95513c3b0cafef915bf2 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Sun, 25 Feb 2024 20:17:53 +0200 Subject: [PATCH 29/88] Update ISwapOperator interface * add enum SwapOperationType * add struct SwapOperation * add events * add custom errors --- contracts/interfaces/ISwapOperator.sol | 47 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 1849c4e557..8e9eb44766 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -3,16 +3,59 @@ pragma solidity >=0.5.0; import "../external/cow/GPv2Order.sol"; +import "../interfaces/IPool.sol"; interface ISwapOperator { + enum SwapOperationType { WethToAsset, AssetToWeth, AssetToAsset } + + struct SwapOperation { + GPv2Order.Data order; + SwapDetails sellSwapDetails; + SwapDetails buySwapDetails; + SwapOperationType swapType; + } + + /* ========== VIEWS ========== */ + function getDigest(GPv2Order.Data calldata order) external view returns (bytes32); function getUID(GPv2Order.Data calldata order) external view returns (bytes memory); + + function orderInProgress() external view returns (bool); - function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) external; + /* ==== MUTATIVE FUNCTIONS ==== */ - function orderInProgress() external returns (bool); + function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) external; function recoverAsset(address assetAddress, address receiver) external; + + /* ========== EVENTS AND ERRORS ========== */ + + event OrderPlaced(GPv2Order.Data order); + event OrderClosed(GPv2Order.Data order, uint filledAmount); + event Swapped(address indexed fromAsset, address indexed toAsset, uint amountIn, uint amountOut); + + // Order + error OrderInProgress(); + error OrderUidMismatch(bytes providedOrderUID, bytes expectedOrderUID); + error UnsupportedTokenBalance(string kind); + error InvalidReceiver(); + error OrderTokenIsDisabled(address token); + + // Valid To + error BelowMinValidTo(uint minValidTo); + error AboveMaxValidTo(uint maxValidTo); + + // Cool down + error InsufficientTimeBetweenSwaps(uint minValidSwapTime); + + // Balance + error EthReserveBelowMin(uint ethPostSwap, uint minEthReserve); + error InvalidBalance(uint tokenBalance, uint limit, string limitType); + error InvalidPostSwapBalance(uint postSwapBalance, uint limit, string limitType); + error MaxSlippageExceeded(uint minAmount); + + // Fee + error AboveMaxFee(uint maxFee); } From 488675802a41d3850483e79f22d53fca7b1d8fac Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Sun, 25 Feb 2024 20:21:48 +0200 Subject: [PATCH 30/88] Add swapOperator.placeOrder unit tests --- test/unit/SwapOperator/helpers.js | 8 +- test/unit/SwapOperator/placeOrder.js | 1286 +++++++++++++------------- 2 files changed, 639 insertions(+), 655 deletions(-) diff --git a/test/unit/SwapOperator/helpers.js b/test/unit/SwapOperator/helpers.js index c78b925e2e..f42cb57059 100644 --- a/test/unit/SwapOperator/helpers.js +++ b/test/unit/SwapOperator/helpers.js @@ -9,8 +9,8 @@ const { const daiMinAmount = parseEther('3000'); const daiMaxAmount = parseEther('20000'); -const stethMinAmount = parseEther('10'); -const stethMaxAmount = parseEther('20'); +const stEthMinAmount = parseEther('10'); +const stEthMaxAmount = parseEther('20'); const makeContractOrder = order => { return { @@ -67,7 +67,7 @@ module.exports = { makeContractOrder, daiMaxAmount, daiMinAmount, - stethMaxAmount, - stethMinAmount, + stEthMaxAmount, + stEthMinAmount, lodashValues, }; diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index ca285aae89..ae877d9412 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -3,25 +3,73 @@ const { expect } = require('chai'); const { domain: makeDomain, computeOrderUid } = require('@cowprotocol/contracts'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { setEtherBalance, setNextBlockTime } = require('../../utils/evm'); const { makeWrongValue, makeContractOrder, lastBlockTimestamp, daiMinAmount, - stethMinAmount, - stethMaxAmount, + stEthMinAmount, + stEthMaxAmount, daiMaxAmount, } = require('./helpers'); const setup = require('./setup'); +const { setEtherBalance, setNextBlockTime } = require('../../utils/evm'); + const { parseEther, hexZeroPad, hexlify, randomBytes } = ethers.utils; +function createContractOrder(domain, order, overrides = {}) { + order = { ...order, ...overrides }; + const contractOrder = makeContractOrder(order); + const orderUID = computeOrderUid(domain, order, order.receiver); + return { contractOrder, orderUID }; +} + +async function placeOrderSetup(order, fixture) { + const [controller, governance] = await ethers.getSigners(); + + const { dai, stEth, pool, swapOperator, cowSettlement } = fixture.contracts; + // Read constants + const MIN_TIME_BETWEEN_ORDERS = (await swapOperator.MIN_TIME_BETWEEN_ORDERS()).toNumber(); + + const { chainId } = await ethers.provider.getNetwork(); + const domain = makeDomain(chainId, cowSettlement.address); + const { contractOrder, orderUID } = createContractOrder(domain, order); + + // Fund the pool contract + await setEtherBalance(pool.address, parseEther('100')); + + // Set asset details for DAI and stEth. 0% slippage + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 0); + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 0); + + return { + ...fixture, + domain, + contractOrder, + order, + orderUID, + MIN_TIME_BETWEEN_ORDERS, + controller, + governance, + }; +} + +const orderParams = { + appData: hexZeroPad(0, 32), + feeAmount: parseEther('0.001'), + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', +}; + /** - * weth -> dai swap + * WETH -> DAI swap */ -async function placeSellWethOrderSetup() { +async function placeSellWethOrderSetup(overrides = {}) { const fixture = await loadFixture(setup); const { dai, weth, swapOperator } = fixture.contracts; + // Build order struct, domain separator and calculate UID const order = { sellToken: weth.address, @@ -30,107 +78,66 @@ async function placeSellWethOrderSetup() { sellAmount: parseEther('0.999'), buyAmount: parseEther('4995'), validTo: (await lastBlockTimestamp()) + 650, - appData: hexZeroPad(0, 32), - feeAmount: parseEther('0.001'), - kind: 'sell', - partiallyFillable: false, - sellTokenBalance: 'erc20', - buyTokenBalance: 'erc20', + ...orderParams, + ...overrides, }; - return placeOrderSetup(order); + return placeOrderSetup(order, fixture); } /** - * stEth -> dai swap + * stETH -> DAI swap */ async function placeNonEthOrderSetup() { const fixture = await loadFixture(setup); - const { dai, stEth, swapOperator } = fixture.contracts; + const { dai, stEth, swapOperator, priceFeedOracle } = fixture.contracts; + + const sellAmount = parseEther('2'); // Build order struct, domain separator and calculate UID const order = { sellToken: stEth.address, buyToken: dai.address, receiver: swapOperator.address, - sellAmount: parseEther('2'), - buyAmount: parseEther('10000'), + sellAmount, + buyAmount: await priceFeedOracle.getAssetForEth(dai.address, sellAmount), validTo: (await lastBlockTimestamp()) + 650, - appData: hexZeroPad(0, 32), - feeAmount: parseEther('0.001'), - kind: 'sell', - partiallyFillable: false, - sellTokenBalance: 'erc20', - buyTokenBalance: 'erc20', + ...orderParams, }; - return placeOrderSetup(order); + return placeOrderSetup(order, fixture); } -async function placeOrderSetup(order) { +/** + * DAI -> WETH swap + */ +async function placeBuyWethOrderSetup(overrides = {}) { const fixture = await loadFixture(setup); - const [controller, governance] = await ethers.getSigners(); - - const { dai, stEth, pool, swapOperator, cowSettlement } = fixture.contracts; - // Read constants - const MIN_TIME_BETWEEN_ORDERS = (await swapOperator.MIN_TIME_BETWEEN_ORDERS()).toNumber(); - - const contractOrder = makeContractOrder(order); + const { dai, weth, pool, swapOperator } = fixture.contracts; - const { chainId } = await ethers.provider.getNetwork(); - const domain = makeDomain(chainId, cowSettlement.address); - const orderUID = computeOrderUid(domain, order, order.receiver); + await dai.setBalance(pool.address, parseEther('25000')); - // Fund the pool contract - await setEtherBalance(pool.address, parseEther('100')); - - // Set asset details for DAI and stEth. 0% slippage - await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 0); - await pool.connect(governance).setSwapDetails(stEth.address, stethMinAmount, stethMaxAmount, 0); - - return { - ...fixture, - domain, - contractOrder, - order, - orderUID, - MIN_TIME_BETWEEN_ORDERS, - controller, - governance, + // Set reasonable amounts for DAI so selling does not bring balance below min + const order = { + sellToken: dai.address, + buyToken: weth.address, + receiver: swapOperator.address, + sellAmount: parseEther('10000'), + feeAmount: parseEther('1'), + buyAmount: parseEther('2'), + validTo: (await lastBlockTimestamp()) + 650, + ...orderParams, + ...overrides, }; -} -describe.only('placeOrder', function () { - /** - * dai -> weth swap - */ - const setupSellDaiForEth = async (overrides = {}, { dai, pool, order, weth, domain }) => { - // Set DAI balance above asset max, so we can sell it - await dai.setBalance(pool.address, parseEther('25000')); - - // Set reasonable amounts for DAI so selling doesnt bring balance below min - const newOrder = { - ...order, - sellToken: dai.address, - buyToken: weth.address, - sellAmount: parseEther('10000'), - feeAmount: parseEther('1'), - buyAmount: parseEther('2'), - ...overrides, - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - return { newOrder, newContractOrder, newOrderUID }; - }; + return placeOrderSetup(order, fixture); +} +describe('placeOrder', function () { it('is callable only by swap controller', async function () { - const { - contracts: { swapOperator }, - governance, - controller, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, governance, controller, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + // call with non-controller, should fail await expect(swapOperator.connect(governance).placeOrder(contractOrder, orderUID)).to.revertedWith( 'SwapOp: only controller can execute', @@ -141,16 +148,13 @@ describe.only('placeOrder', function () { }); it('computes order UID on-chain and validates against passed value', async function () { - const { - contracts: { swapOperator }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + // call with invalid UID, should fail const wrongUID = hexlify(randomBytes(56)); - await expect(swapOperator.placeOrder(contractOrder, wrongUID)).to.revertedWith( - 'SwapOp: Provided UID doesnt match calculated UID', - ); + const placeOrder = swapOperator.placeOrder(contractOrder, wrongUID); + await expect(placeOrder).to.revertedWithCustomError(swapOperator, 'OrderUidMismatch'); // call with invalid struct, with each individual field modified, should fail for (const [key, value] of Object.entries(contractOrder)) { @@ -158,9 +162,8 @@ describe.only('placeOrder', function () { ...contractOrder, [key]: makeWrongValue(value), }; - await expect(swapOperator.placeOrder(wrongOrder, orderUID)).to.revertedWith( - 'SwapOp: Provided UID doesnt match calculated UID', - ); + const placeWrongOrder = swapOperator.placeOrder(wrongOrder, orderUID); + await expect(placeWrongOrder).to.revertedWithCustomError(swapOperator, 'OrderUidMismatch'); } // call with valid order and UID, should succeed @@ -168,98 +171,88 @@ describe.only('placeOrder', function () { }); it('validates theres no other order already placed', async function () { - const { - contracts: { swapOperator }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + // calling with valid data should succeed first time await swapOperator.placeOrder(contractOrder, orderUID); // calling with valid data should fail second time, because first order is still there - await expect(swapOperator.placeOrder(contractOrder, orderUID)).to.be.revertedWith( - 'SwapOp: an order is already in place', - ); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OrderInProgress'); }); it('validates only erc20 is supported for sellTokenBalance', async function () { - const { - contracts: { swapOperator }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const newOrder = { - ...order, - sellTokenBalance: 'external', - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: Only erc20 supported for sellTokenBalance', - ); + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + + const { contractOrder, orderUID } = createContractOrder(domain, order, { sellTokenBalance: 'external' }); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'UnsupportedTokenBalance').withArgs('sell'); }); it('validates only erc20 is supported for buyTokenBalance', async function () { - const { - contracts: { swapOperator }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const newOrder = { - ...order, - buyTokenBalance: 'internal', - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: Only erc20 supported for buyTokenBalance', - ); + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + + const { contractOrder, orderUID } = createContractOrder(domain, order, { buyTokenBalance: 'internal' }); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'UnsupportedTokenBalance').withArgs('buy'); }); it('validates the receiver of the swap is the swap operator contract', async function () { - const { - contracts: { swapOperator }, - governance, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const newOrder = { - ...order, - receiver: governance.address, - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: Receiver must be this contract', - ); + const { contracts, governance, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + + const { contractOrder, orderUID } = createContractOrder(domain, order, { receiver: governance.address }); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'InvalidReceiver'); }); - it('validates that deadline is at least 10 minutes in the future', async function () { - const { - contracts: { swapOperator }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + it('validates that order.validTo is at least 10 minutes in the future', async function () { + const MIN_VALID_TO_PERIOD_SECONDS = 60 * 10; // 10 min + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; const { timestamp } = await ethers.provider.getBlock('latest'); - const newOrder = { - ...order, - validTo: timestamp + 500, - }; - const newContractOrder = makeContractOrder(newOrder); - const newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: validTo must be at least 10 minutes in the future', - ); + // orders less than 10 minutes validTo should fail + const blockOneTimestamp = timestamp + 1; + const badOrder = createContractOrder(domain, order, { validTo: blockOneTimestamp + 500 }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + const expectMinValidTo = blockOneTimestamp + MIN_VALID_TO_PERIOD_SECONDS; + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'BelowMinValidTo').withArgs(expectMinValidTo); + + // order at least 10 minutes validTo should succeed + const blockTwoTimestamp = blockOneTimestamp + 1; + const correctValidTo = blockTwoTimestamp + MIN_VALID_TO_PERIOD_SECONDS; + const goodOrder = createContractOrder(domain, order, { validTo: correctValidTo }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('does not perform validation when sellToken is WETH, because eth is used', async function () { - const { - contracts: { swapOperator, weth, pool }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + it('validates that order.validTo is at most 60 minutes in the future', async function () { + const MAX_VALID_TO_PERIOD_SECONDS = 60 * 60; // 60 minutes + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + const { timestamp } = await ethers.provider.getBlock('latest'); + + // orders greater than 60 minutes validTo should fail + const blockOneTimestamp = timestamp + 1; + const exceedingMaxValidTo = blockOneTimestamp + MAX_VALID_TO_PERIOD_SECONDS + 10; + const badOrder = createContractOrder(domain, order, { validTo: exceedingMaxValidTo }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + const expectMaxValidTo = blockOneTimestamp + MAX_VALID_TO_PERIOD_SECONDS; + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxValidTo').withArgs(expectMaxValidTo); + + // orders within 60 minutes validTo should succeed + const blockTwoTimestamp = blockOneTimestamp + 1; + const goodOrder = createContractOrder(domain, order, { validTo: blockTwoTimestamp + MAX_VALID_TO_PERIOD_SECONDS }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); + }); + + it('does not perform token enabled validation when sellToken is WETH, because eth is used', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, weth, pool } = contracts; + // Ensure eth (weth) is disabled by checking min and max amount const swapDetails = await pool.getAssetSwapDetails(weth.address); expect(swapDetails.minAmount).to.eq(0); @@ -269,154 +262,171 @@ describe.only('placeOrder', function () { await swapOperator.placeOrder(contractOrder, orderUID); }); - it('performs the validation when sellToken is not WETH', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - governance, - } = await loadFixture(placeSellWethOrderSetup); - // Set up an order to swap DAI for ETH - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + it('performs token enabled validation when sellToken is not WETH', async function () { + const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, stEth, dai, pool } = contracts; - // Since DAI was already registered on setup, set its details to 0 - await pool.connect(governance).setSwapDetails(dai.address, 0, 0, 0); + // Since stEth was already registered on setup, set its details to 0 + await pool.connect(governance).setSwapDetails(stEth.address, 0, 0, 0); + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 0); - // Order selling DAI should fail - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: sellToken is not enabled', - ); + // Order selling stEth should fail + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'OrderTokenIsDisabled') + .withArgs(stEth.address); }); - it('only allows to sell when balance is above asset maxAmount', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + it('only allows to sell when sellToken balance is above asset maxAmount - ASSET -> WETH', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { swapOperator, dai, pool } = contracts; // Try to run when balance is at maxAmount, await dai.setBalance(pool.address, daiMaxAmount); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: can only sell asset when > maxAmount', - ); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(daiMaxAmount, daiMaxAmount, 'max'); // When balance > maxAmount, should succeed await dai.setBalance(pool.address, daiMaxAmount.add(1)); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); }); - it('selling cannot bring balance below minAmount', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + it('only allows to sell when sellToken balance is above asset maxAmount - ASSET -> ASSET', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, stEth, pool } = contracts; + + // Try to run when balance is at maxAmount, + await stEth.setBalance(pool.address, stEthMaxAmount); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(stEthMaxAmount, stEthMaxAmount, 'max'); + + // When balance > maxAmount, should succeed + await stEth.setBalance(pool.address, stEthMaxAmount.add(1)); + await swapOperator.placeOrder(contractOrder, orderUID); + }); + + it('only allows to buy when buyToken balance is below minAmount - ASSET -> ASSET', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, dai, pool } = contracts; + + // set buyToken balance to be minAmount, txn should fail + await dai.setBalance(pool.address, daiMinAmount); + + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(daiMinAmount, daiMinAmount, 'min'); + + // set buyToken balance to be < minAmount, txn should succeed + await dai.setBalance(pool.address, daiMinAmount.sub(1)); + await swapOperator.placeOrder(contractOrder, orderUID); + }); + + it('selling cannot bring sellToken balance below minAmount', async function () { const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth( - { sellAmount, feeAmount, buyAmount }, - { dai, pool, order, weth, domain }, - ); + const sellDaiForEthSetup = () => placeBuyWethOrderSetup({ sellAmount, feeAmount, buyAmount }); - // Set balance so that balance - totalAmountOut is 1 wei below asset minAmount - await dai.setBalance(pool.address, daiMinAmount.add(sellAmount).add(feeAmount).sub(1)); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: swap brings sellToken below min', - ); + const { contracts, contractOrder, orderUID } = await loadFixture(sellDaiForEthSetup); + const { swapOperator, dai, pool } = contracts; + + // Set balance so that balance - totalOutAmount is 1 wei below asset minAmount + const totalOutAmount = sellAmount.add(feeAmount); + const invalidBalance = daiMinAmount.add(totalOutAmount).sub(1); + await dai.setBalance(pool.address, invalidBalance); + + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(invalidBalance.sub(totalOutAmount), daiMinAmount, 'min'); // Set balance so it can exactly cover totalOutAmount - await dai.setBalance(pool.address, daiMinAmount.add(sellAmount).add(feeAmount)); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await dai.setBalance(pool.address, daiMinAmount.add(totalOutAmount)); + await swapOperator.placeOrder(contractOrder, orderUID); expect(await dai.balanceOf(pool.address)).to.eq(daiMinAmount); }); it('selling can leave balance above maxAmount', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth( - { sellAmount, feeAmount, buyAmount }, - { dai, pool, order, weth, domain }, - ); + const sellDaiForEthSetup = () => placeBuyWethOrderSetup({ sellAmount, feeAmount, buyAmount }); - // Set balance so that balance - totalAmountOut is 1 wei above asset maxAmount, should succeed + const { contracts, contractOrder, orderUID } = await loadFixture(sellDaiForEthSetup); + const { swapOperator, dai, pool } = contracts; + + // Set balance so that balance - totalOutAmount is 1 wei above asset maxAmount, should succeed await dai.setBalance(pool.address, daiMaxAmount.add(sellAmount).add(feeAmount).add(1)); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); }); - it('validates that pools eth balance is not brought below established minimum', async function () { - const { - contracts: { swapOperator, pool }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + it('validates that pools eth balance is not brought below established minimum when selling ETH', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, pool } = contracts; + // Set pool balance to 2 eth - 1 wei - await setEtherBalance(pool.address, parseEther('2').sub(1)); + const underTwoEthBalance = parseEther('2').sub(1); + await setEtherBalance(pool.address, underTwoEthBalance); // Execute trade for 1 eth, should fail expect(contractOrder.sellAmount.add(contractOrder.feeAmount)).to.eq(parseEther('1')); - await expect( - swapOperator.placeOrder(contractOrder, orderUID), // - ).to.revertedWith('SwapOp: Pool eth balance below min'); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + const ethPostSwap = underTwoEthBalance.sub(parseEther('1')); + const minPoolEth = parseEther('1'); + + await expect(placeOrder) + .to.revertedWithCustomError(swapOperator, 'EthReserveBelowMin') + .withArgs(ethPostSwap, minPoolEth); - // Add 1 wei to balance and it should succeed + // Set pool balance to 2 eth, should succeed await setEtherBalance(pool.address, parseEther('2')); await swapOperator.placeOrder(contractOrder, orderUID); }); - it('does not perform validation asset details when buyToken is WETH, because eth is used', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + it('does not perform WETH token enabled validation when buying WETH', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { swapOperator, weth, pool } = contracts; + // Ensure eth (weth) is disabled by checking min and max amount const swapDetails = await pool.getAssetSwapDetails(weth.address); expect(swapDetails.minAmount).to.eq(0); expect(swapDetails.maxAmount).to.eq(0); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); - // Order buying WETH (eth) still should succeed - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); }); - it('validates asset details when buyToken is not WETH', async function () { - const { - contracts: { swapOperator, dai, pool }, - governance, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + it('performs token enabled validation when not buying WETH', async function () { + const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, stEth, dai, pool } = contracts; + // Since DAI was already registered on setup, set its details to 0 - await pool.connect(governance).setSwapDetails(dai.address, 0, 0, 0); // otherSigner is governant + // Since stEth was already registered on setup, set its details to 0 + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 0); + await pool.connect(governance).setSwapDetails(dai.address, 0, 0, 0); // Order buying DAI should fail - await expect(swapOperator.placeOrder(contractOrder, orderUID)).to.be.revertedWith( - 'SwapOp: buyToken is not enabled', - ); + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OrderTokenIsDisabled').withArgs(dai.address); }); - it('only allows to buy when balance is below minAmount', async function () { - const { - contracts: { swapOperator, dai, pool }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + it('only allows to buy when buyToken balance is below minAmount (WETH -> ASSET)', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, dai, pool } = contracts; + // set buyToken balance to be minAmount, txn should fail await dai.setBalance(pool.address, daiMinAmount); - await expect(swapOperator.placeOrder(contractOrder, orderUID)).to.be.revertedWith( - 'SwapOp: can only buy asset when < minAmount', - ); + + const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(daiMinAmount, daiMinAmount, 'min'); // set buyToken balance to be < minAmount, txn should succeed await dai.setBalance(pool.address, daiMinAmount.sub(1)); @@ -424,46 +434,37 @@ describe.only('placeOrder', function () { }); it('the swap cannot bring buyToken above maxAmount', async function () { - const { - contracts: { swapOperator, dai, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + const exceedingMaxOrderSetup = () => placeSellWethOrderSetup({ buyAmount: daiMaxAmount.add(1) }); + const { contracts, order, domain } = await loadFixture(exceedingMaxOrderSetup); + const { swapOperator, dai, pool } = contracts; + await dai.setBalance(pool.address, 0); // try to place an order that will bring balance 1 wei above max, should fail - const bigOrder = { ...order, buyAmount: daiMaxAmount.add(1) }; - const bigContractOrder = makeContractOrder(bigOrder); - const bigOrderUID = computeOrderUid(domain, bigOrder, bigOrder.receiver); - await expect(swapOperator.placeOrder(bigContractOrder, bigOrderUID)).to.be.revertedWith( - 'SwapOp: swap brings buyToken above max', - ); + const exceedsMaxOrder = createContractOrder(domain, order, { buyAmount: daiMaxAmount.add(1) }); + const placeOrder = swapOperator.placeOrder(exceedsMaxOrder.contractOrder, exceedsMaxOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(order.buyAmount, daiMaxAmount, 'max'); // place an order that will bring balance exactly to maxAmount, should succeed - const okOrder = { ...order, buyAmount: daiMaxAmount }; - const okContractOrder = makeContractOrder(okOrder); - const okOrderUID = computeOrderUid(domain, okOrder, okOrder.receiver); - await swapOperator.placeOrder(okContractOrder, okOrderUID); + const withinMaxOrder = createContractOrder(domain, order, { buyAmount: daiMaxAmount }); + await swapOperator.placeOrder(withinMaxOrder.contractOrder, withinMaxOrder.orderUID); }); it('the swap can leave buyToken below minAmount', async function () { - const { - contracts: { swapOperator, dai, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - await dai.setBalance(pool.address, 0); - // place an order that will bring balance 1 wei below min, should succeed const buyAmount = daiMinAmount.sub(1); - const smallOrder = { ...order, buyAmount, sellAmount: buyAmount.div(5000) }; - const smallContractOrder = makeContractOrder(smallOrder); - const smallOrderUID = computeOrderUid(domain, smallOrder, smallOrder.receiver); + const buyTokenBelowMinOrderSetup = () => placeSellWethOrderSetup({ buyAmount, sellAmount: buyAmount.div(5000) }); + + const { contracts, contractOrder, orderUID } = await loadFixture(buyTokenBelowMinOrderSetup); + const { swapOperator, dai, pool } = contracts; + await dai.setBalance(pool.address, 0); - await swapOperator.placeOrder(smallContractOrder, smallOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); }); - it('validates minimum time between swaps when selling eth', async function () { + it('validates minimum time between swaps of buyToken when selling eth', async function () { const { contracts: { swapOperator, dai, pool }, governance, @@ -473,82 +474,119 @@ describe.only('placeOrder', function () { domain, MIN_TIME_BETWEEN_ORDERS, } = await loadFixture(placeSellWethOrderSetup); + // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); await swapOperator.closeOrder(contractOrder); + // ETH lastSwapTime should be 0 since it does not have set swapDetails + const { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); + const minValidSwapTime = lastSwapTime + MIN_TIME_BETWEEN_ORDERS; + // Prepare valid pool params for allowing next order await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount.mul(2), daiMaxAmount.mul(2), 0); - // Read last swap time - const lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + // Set next block time to minimum - 2 + await setNextBlockTime(minValidSwapTime - 2); + + // Try to place order, should revert because of frequency + const secondOrder = createContractOrder(domain, order, { validTo: minValidSwapTime + 650 }); + const placeOrder = swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InsufficientTimeBetweenSwaps') + .withArgs(minValidSwapTime); + + // Set next block time to minimum, should succeed now + await setNextBlockTime(minValidSwapTime); + await swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); + }); + + it('validates minimum time between swaps of sellToken when buying eth', async function () { + const { + contracts: { swapOperator, dai, pool }, + domain, + order, + contractOrder, + orderUID, + governance, + MIN_TIME_BETWEEN_ORDERS, + } = await loadFixture(placeBuyWethOrderSetup); + + // Place and close an order + await swapOperator.placeOrder(contractOrder, orderUID); + await swapOperator.closeOrder(contractOrder); + + // Prepare valid pool params for allowing next order + await pool.connect(governance).setSwapDetails(dai.address, 3000, 6000, 0); + + // ETH lastSwapTime should be 0 since it does not have set swapDetails + const { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); + const minValidSwapTime = lastSwapTime + MIN_TIME_BETWEEN_ORDERS; // Set next block time to minimum - 2 - await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS - 2); + await setNextBlockTime(minValidSwapTime - 2); // Build a new valid order - const secondOrder = { ...order, validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }; - const secondContractOrder = makeContractOrder(secondOrder); - const secondOrderUID = computeOrderUid(domain, secondOrder, secondOrder.receiver); + const secondOrder = createContractOrder(domain, order, { validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }); // Try to place order, should revert because of frequency - await expect(swapOperator.placeOrder(secondContractOrder, secondOrderUID)).to.be.revertedWith( - 'SwapOp: already swapped this asset recently', - ); + const placeOrder = swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InsufficientTimeBetweenSwaps') + .withArgs(minValidSwapTime); // Set next block time to minimum await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS); // Placing the order should succeed now - await swapOperator.placeOrder(secondContractOrder, secondOrderUID); + await swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); }); - it('validates minimum time between swaps when buying eth', async function () { + it('validates minimum time between swaps of sellToken when swapping asset to asset', async function () { const { - contracts: { swapOperator, dai, weth, pool }, + contracts: { swapOperator, stEth, dai, pool }, + governance, + contractOrder, order, + orderUID, domain, - governance, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeSellWethOrderSetup); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + } = await loadFixture(placeNonEthOrderSetup); // Place and close an order - await swapOperator.placeOrder(newContractOrder, newOrderUID); - await swapOperator.closeOrder(newContractOrder); + await swapOperator.placeOrder(contractOrder, orderUID); + await swapOperator.closeOrder(contractOrder); // Prepare valid pool params for allowing next order - await pool.connect(governance).setSwapDetails(dai.address, 3000, 6000, 0); + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount.mul(2), stEthMaxAmount.mul(2), 0); + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount.mul(2), daiMaxAmount.mul(2), 0); // Read last swap time - const lastSwapTime = (await pool.getAssetSwapDetails(dai.address)).lastSwapTime; + const { lastSwapTime } = await pool.getAssetSwapDetails(stEth.address); + const minValidSwapTime = lastSwapTime + MIN_TIME_BETWEEN_ORDERS; // Set next block time to minimum - 2 - await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS - 2); + await setNextBlockTime(minValidSwapTime - 2); // Build a new valid order - const { newContractOrder: secondContractOrder, newOrderUID: secondOrderUID } = await setupSellDaiForEth( - { - validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650, - }, - { dai, pool, order, weth, domain }, - ); + const secondOrder = createContractOrder(domain, order, { validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }); // Try to place order, should revert because of frequency - await expect(swapOperator.placeOrder(secondContractOrder, secondOrderUID)).to.be.revertedWith( - 'SwapOp: already swapped this asset recently', - ); + const placeOrder = swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InsufficientTimeBetweenSwaps') + .withArgs(minValidSwapTime); // Set next block time to minimum await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS); // Placing the order should succeed now - await swapOperator.placeOrder(secondContractOrder, secondOrderUID); + await swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); }); - it('validates minimum time between swaps when swapping asset to asset', async function () { + it('validates minimum time between swaps of buyToken when swapping asset to asset', async function () { const { - contracts: { swapOperator, stEth, pool }, + contracts: { swapOperator, stEth, dai, pool }, governance, contractOrder, order, @@ -556,477 +594,423 @@ describe.only('placeOrder', function () { domain, MIN_TIME_BETWEEN_ORDERS, } = await loadFixture(placeNonEthOrderSetup); + // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); await swapOperator.closeOrder(contractOrder); // Prepare valid pool params for allowing next order - await pool.connect(governance).setSwapDetails(stEth.address, stethMinAmount.mul(2), stethMaxAmount.mul(2), 0); + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount.mul(2), stEthMaxAmount.mul(2), 0); + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount.mul(2), daiMaxAmount.mul(2), 0); // Read last swap time - const { lastSwapTime } = await pool.getAssetSwapDetails(stEth.address); + const { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); + const minValidSwapTime = lastSwapTime + MIN_TIME_BETWEEN_ORDERS; // Set next block time to minimum - 2 - await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS - 2); + await setNextBlockTime(minValidSwapTime - 2); // Build a new valid order - const secondOrder = { ...order, validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }; - const secondContractOrder = makeContractOrder(secondOrder); - const secondOrderUID = computeOrderUid(domain, secondOrder, secondOrder.receiver); + const secondOrder = createContractOrder(domain, order, { validTo: lastSwapTime + MIN_TIME_BETWEEN_ORDERS + 650 }); // Try to place order, should revert because of frequency - await expect(swapOperator.placeOrder(secondContractOrder, secondOrderUID)).to.be.revertedWith( - 'SwapOp: already swapped this asset recently', - ); + const placeOrder = swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InsufficientTimeBetweenSwaps') + .withArgs(minValidSwapTime); // Set next block time to minimum await setNextBlockTime(lastSwapTime + MIN_TIME_BETWEEN_ORDERS); // Placing the order should succeed now - await swapOperator.placeOrder(secondContractOrder, secondOrderUID); + await swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); }); it('when selling ether, checks that feeAmount is not higher than maxFee', async function () { - const { - contracts: { swapOperator }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; const maxFee = await swapOperator.maxFee(); // Place order with fee 1 wei higher than maximum, should fail - const badOrder = { ...order, feeAmount: maxFee.add(1) }; - const badContractOrder = makeContractOrder(badOrder); - const badOrderUID = computeOrderUid(domain, badOrder, badOrder.receiver); - - await expect(swapOperator.placeOrder(badContractOrder, badOrderUID)).to.be.revertedWith( - 'SwapOp: Fee amount is higher than configured max fee', - ); + const badOrder = createContractOrder(domain, order, { feeAmount: maxFee.add(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee').withArgs(maxFee); // Place order with exactly maxFee, should succeed - const goodOrder = { ...order, feeAmount: maxFee }; - const goodContractOrder = makeContractOrder(goodOrder); - const goodOrderUID = computeOrderUid(domain, goodOrder, goodOrder.receiver); - - await swapOperator.placeOrder(goodContractOrder, goodOrderUID); + const goodOrder = createContractOrder(domain, order, { feeAmount: maxFee }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); it('when selling other asset, uses oracle to check fee in ether is not higher than maxFee', async function () { - const { - contracts: { swapOperator, pool, priceFeedOracle, dai, weth }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); + const { swapOperator, priceFeedOracle, dai } = contracts; const maxFee = await swapOperator.maxFee(); const daiToEthRate = await priceFeedOracle.getAssetToEthRate(dai.address); const ethToDaiRate = parseEther('1').div(daiToEthRate); // 1 ETH -> N DAI // Place order with fee 1 wei higher than maximum, should fail - const { newContractOrder: badContractOrder, newOrderUID: badOrderUID } = await setupSellDaiForEth( - { feeAmount: maxFee.add(1).mul(ethToDaiRate) }, - { dai, pool, order, weth, domain }, - ); - - await expect(swapOperator.placeOrder(badContractOrder, badOrderUID)).to.be.revertedWith( - 'SwapOp: Fee amount is higher than configured max fee', - ); + const badOrder = createContractOrder(domain, order, { feeAmount: maxFee.add(1).mul(ethToDaiRate) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee').withArgs(maxFee); // Place order with exactly maxFee, should succeed - const { newContractOrder: goodContractOrder, newOrderUID: goodOrderUID } = await setupSellDaiForEth( - { feeAmount: maxFee.mul(ethToDaiRate) }, - { dai, pool, order, weth, domain }, - ); + const goodOrder = createContractOrder(domain, order, { feeAmount: maxFee.mul(ethToDaiRate) }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); + }); + + it('when selling eth validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, priceFeedOracle, dai } = contracts; + + // order kind SELL, buyAmount is quoted + const daiBuyAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); - await swapOperator.placeOrder(goodContractOrder, goodOrderUID); + // Since quoted buyAmount is short by 1 DAI wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiBuyAmount); + + // Oracle price buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when selling eth takes oracle price into account', async function () { - const { - contracts: { swapOperator, priceFeedOracle, dai }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const sellAmount = parseEther('1'); // 1 ETH - const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); - - const newOrder = { - ...order, - sellAmount, - buyAmount: buyAmount.sub(1), // 1 DAI wei less than oracle price - }; - let newContractOrder = makeContractOrder(newOrder); - let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - // Since buyAmount is short by 1 wei, txn should revert - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + it('when selling eth validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, priceFeedOracle, dai } = contracts; - // Add 1 wei to buyAmount - newOrder.buyAmount = newOrder.buyAmount.add(1); + // order kind BUY, sellAmount is quoted + const ethSellAmount = await priceFeedOracle.getEthForAsset(dai.address, order.buyAmount); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since quoted sellAmount is short by 1 DAI wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: ethSellAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(ethSellAmount); - // Now txn should not revert - await swapOperator.placeOrder(newContractOrder, newOrderUID); + // Oracle price sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: ethSellAmount }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when selling eth takes slippage into account', async function () { - const { - contracts: { swapOperator, pool, priceFeedOracle, dai }, - governance, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const sellAmount = parseEther('1'); // 1 ETH - const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); + it('when selling eth validates quotedAmount against oracle price & 1% slippage (orderKind SELL)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, pool, dai } = contracts; - // 1% slippage + // order kind SELL, buyAmount is quoted + const buyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); - const newOrder = { - ...order, - sellAmount, - buyAmount: buyAmount.mul(99).div(100).sub(1), // -1% -1 wei (i.e. > 1% slippage) - }; - let newContractOrder = makeContractOrder(newOrder); - let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - // Since buyAmount is short by 1 wei, txn should revert - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const badOrderOverrides = { kind: 'sell', buyAmount: buyAmountOnePercentSlippage.sub(1) }; + const badOrder = createContractOrder(domain, order, badOrderOverrides); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') + .withArgs(buyAmountOnePercentSlippage); // 1% max slippage from oracle buy amount - // Add 1 wei to buyAmount - newOrder.buyAmount = newOrder.buyAmount.add(1); + // Exactly 1% slippage buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: buyAmountOnePercentSlippage }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); + }); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // ETH has no set swapDetails slippage, so no test for selling eth order kind BUY - // Now txn should not revert - await swapOperator.placeOrder(newContractOrder, newOrderUID); - }); + it('when buying eth validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { + const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); + const { dai, swapOperator, priceFeedOracle } = contracts; - it('when buying eth takes oracle price into account', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - let { newOrder, newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + // order kind SELL, buyAmount is quoted + const ethBuyAmount = await priceFeedOracle.getEthForAsset(dai.address, order.sellAmount); - // Since buyAmount is short by 1 wei, txn should revert - newOrder.buyAmount = newOrder.buyAmount.sub(1); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since quoted buyAmount is short by 1 wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: ethBuyAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(ethBuyAmount); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + // Oracle price buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'sell' }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); + }); + + it('when buying eth validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { + const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); + const { dai, swapOperator, priceFeedOracle } = contracts; + + // order kind BUY, sellAmount is quoted + const daiSellAmount = await priceFeedOracle.getAssetForEth(dai.address, order.buyAmount); - // Add 1 wei to buyAmount - newOrder.buyAmount = newOrder.buyAmount.add(1); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since quoted sellAmount is short by 1 wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: daiSellAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiSellAmount); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + // Oracle price sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'buy' }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when buying eth takes slippage into account', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - governance, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - // 1% slippage + it('when buying eth validates quotedAmount against oracle price & 1% slippage (order kind BUY)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeBuyWethOrderSetup); + const { swapOperator, pool, dai } = contracts; + + // order kind BUY, sellAmount is quoted + const daiSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); - let { newOrder, newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + // Since quoted sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const orderOverrides = { kind: 'buy', sellAmount: daiSellAmountOnePercentSlippage.sub(1) }; + const badOrder = createContractOrder(domain, order, orderOverrides); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') + .withArgs(daiSellAmountOnePercentSlippage); - // Set buyAmount to be (oracle amount * 0.99) - 1 wei - newOrder.buyAmount = newOrder.buyAmount.mul(99).div(100).sub(1); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Exactly 1% slippage sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: daiSellAmountOnePercentSlippage }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); + }); - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + // ETH has no set swapDetails slippage, so no test for buying eth order kind SELL + + it('non-ETH swap, validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { + const { contracts, order, domain } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, priceFeedOracle, dai } = contracts; + + // order kind SELL, buyAmount is quoted (use eth rate as proxy since 1 stETH : 1 ETH) + const daiBuyAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); - // Set buyAmount to be (oracle amount * 0.99) - newOrder.buyAmount = newOrder.buyAmount.add(1); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since quoted buyAmount is short by 1 DAI wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiBuyAmount); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + // Oracle price buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when swapping non-ETH asset to non-ETH asset takes oracle price into account', async function () { - const { - contracts: { swapOperator, priceFeedOracle, dai }, - order, - domain, - } = await loadFixture(placeNonEthOrderSetup); - const sellAmount = parseEther('1'); // 1 stETH - // use eth rate as proxy since 1 stETH : 1 ETH - const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); - - const newOrder = { - ...order, - sellAmount, - buyAmount: buyAmount.sub(1), // 1 DAI wei less than oracle price - }; - let newContractOrder = makeContractOrder(newOrder); - let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - // Since buyAmount is short by 1 wei, txn should revert - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + it('non-ETH swaps, validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { + const { contracts, order, domain } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, priceFeedOracle, dai } = contracts; - // Add 1 wei to buyAmount - newOrder.buyAmount = newOrder.buyAmount.add(1); + // order kind BUY, sellAmount is quoted (use eth rate as proxy since 1 stETH : 1 ETH) + const stEthSellAmount = await priceFeedOracle.getEthForAsset(dai.address, order.buyAmount); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since sellAmount is short by 1 wei, txn should revert + const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: stEthSellAmount.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') + .withArgs(stEthSellAmount); - // Now txn should not revert - await swapOperator.placeOrder(newContractOrder, newOrderUID); + // Oracle price sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: stEthSellAmount }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when swapping non-ETH asset to non-ETH asset takes slippage into account', async function () { - const { - contracts: { swapOperator, pool, priceFeedOracle, stEth, dai }, - governance, - order, - domain, - } = await loadFixture(placeNonEthOrderSetup); - const sellAmount = parseEther('1'); // 1 stETH - // use eth rate as proxy since 1 stETH : 1 ETH - const buyAmount = await priceFeedOracle.getAssetForEth(dai.address, sellAmount); - - // 1% slippage - await pool.connect(governance).setSwapDetails(stEth.address, stethMinAmount, stethMaxAmount, 100); - - const newOrder = { - ...order, - sellAmount: parseEther('1'), - buyAmount: buyAmount.mul(99).div(100).sub(1), // // -1% -1 wei (i.e. > 1% slippage) - }; - let newContractOrder = makeContractOrder(newOrder); - let newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); - - // Since buyAmount > 1% slippage, txn should revert - await expect(swapOperator.placeOrder(newContractOrder, newOrderUID)).to.be.revertedWith( - 'SwapOp: order.buyAmount too low (oracle)', - ); + it('non-ETH swap, validates quotedAmount against oracle price & 1% slippage (order kind SELL)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, pool, dai } = contracts; - // Add 1 wei to make it exactly 1% slippage - newOrder.buyAmount = newOrder.buyAmount.add(1); + // order kind SELL, buyAmount is quoted + const daiBuyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); - newContractOrder = makeContractOrder(newOrder); - newOrderUID = computeOrderUid(domain, newOrder, newOrder.receiver); + // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const badOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage.sub(1) }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') + .withArgs(daiBuyAmountOnePercentSlippage); // 1% max slippage from oracle buy amount - // Now txn should not revert - await swapOperator.placeOrder(newContractOrder, newOrderUID); + // Exactly 1% slippage buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage }); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - // eslint-disable-next-line max-len - it('pulling funds from pool: transfers ether from pool and wrap it into WETH when sellToken is WETH', async function () { - const { - contracts: { swapOperator, weth, pool }, - order, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - const poolEthBefore = await ethers.provider.getBalance(pool.address); - const swapOpWethBefore = await weth.balanceOf(swapOperator.address); + it('non-ETH swap, validates quotedAmount against oracle price & 1% slippage (order kind BUY)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, pool, stEth } = contracts; - await swapOperator.placeOrder(contractOrder, orderUID); + // order kind BUY, sellAmount is quoted + const stEthSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 100); - const poolEthAfter = await ethers.provider.getBalance(pool.address); - const swapOpWethAfter = await weth.balanceOf(swapOperator.address); + // Since sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const badOrderOverride = { kind: 'buy', sellAmount: stEthSellAmountOnePercentSlippage.sub(1) }; + const badOrder = createContractOrder(domain, order, badOrderOverride); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') + .withArgs(stEthSellAmountOnePercentSlippage); // 1% max slippage from oracle buy amount - expect(poolEthBefore.sub(poolEthAfter)).to.eq(order.sellAmount.add(order.feeAmount)); - expect(swapOpWethAfter.sub(swapOpWethBefore)).to.eq(order.sellAmount.add(order.feeAmount)); + // Exactly 1% slippage sellAmount should not revert + const goodOrderOverride = { kind: 'buy', sellAmount: stEthSellAmountOnePercentSlippage }; + const goodOrder = createContractOrder(domain, order, goodOrderOverride); + await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('pulling funds from pool: transfer erc20 asset from pool to eth if sellToken is not WETH', async function () { - const { - contracts: { swapOperator, stEth, pool }, - order, - orderUID, - contractOrder, - } = await loadFixture(placeNonEthOrderSetup); + it('pulling funds from pool: transfers ETH from pool and wrap it into WETH when sellToken is ETH', async function () { + const { contracts, order, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, weth, pool } = contracts; - const poolStEthBefore = await stEth.balanceOf(pool.address); - const swapOpStEthBefore = await stEth.balanceOf(swapOperator.address); + const ethPoolBefore = await ethers.provider.getBalance(pool.address); + const wethSwapOpBefore = await weth.balanceOf(swapOperator.address); await swapOperator.placeOrder(contractOrder, orderUID); - const poolStEthAfter = await stEth.balanceOf(pool.address); - const swapOpStEthAfter = await stEth.balanceOf(swapOperator.address); + const ethPoolAfter = await ethers.provider.getBalance(pool.address); + const wethSwapOpAfter = await weth.balanceOf(swapOperator.address); - expect(poolStEthBefore.sub(poolStEthAfter)).to.eq(order.sellAmount.add(order.feeAmount)); - expect(swapOpStEthAfter.sub(swapOpStEthBefore)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(ethPoolBefore.sub(ethPoolAfter)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(wethSwapOpAfter.sub(wethSwapOpBefore)).to.eq(order.sellAmount.add(order.feeAmount)); }); - it('sets lastSwapDate on buyAsset when selling ETH', async function () { - const { - contracts: { swapOperator, dai, pool }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - let { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); - expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); + it('pulling funds from pool: transfer erc20 asset from pool to eth if sellToken is not WETH', async function () { + const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, stEth, pool } = contracts; - await swapOperator.placeOrder(contractOrder, orderUID); + const stEthPoolBefore = await stEth.balanceOf(pool.address); + const stEthSwapOpBefore = await stEth.balanceOf(swapOperator.address); - ({ lastSwapTime } = await pool.getAssetSwapDetails(dai.address)); - expect(lastSwapTime).to.eq(await lastBlockTimestamp()); - }); - - it('sets lastSwapDate on sellAsset when buying ETH', async function () { - const { - contracts: { swapOperator, dai, weth, pool }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); - let { lastSwapTime } = await pool.getAssetSwapDetails(dai.address); - expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); + await swapOperator.placeOrder(contractOrder, orderUID); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + const stEthPoolAfter = await stEth.balanceOf(pool.address); + const stEthSwapOpAfter = await stEth.balanceOf(swapOperator.address); - ({ lastSwapTime } = await pool.getAssetSwapDetails(dai.address)); - expect(lastSwapTime).to.eq(await lastBlockTimestamp()); + expect(stEthPoolBefore.sub(stEthPoolAfter)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(stEthSwapOpAfter.sub(stEthSwapOpBefore)).to.eq(order.sellAmount.add(order.feeAmount)); }); - it('sets lastSwapDate on sellAsset when swapping asset to asset', async function () { - const { - contracts: { swapOperator, stEth, pool }, - contractOrder, - orderUID, - } = await loadFixture(placeNonEthOrderSetup); - let { lastSwapTime } = await pool.getAssetSwapDetails(stEth.address); - expect(lastSwapTime).to.not.eq(await lastBlockTimestamp()); + it('sets lastSwapDate on buyToken when selling ETH', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, dai, pool } = contracts; + + const before = await pool.getAssetSwapDetails(dai.address); + expect(before.lastSwapTime).to.not.eq(await lastBlockTimestamp()); await swapOperator.placeOrder(contractOrder, orderUID); - ({ lastSwapTime } = await pool.getAssetSwapDetails(stEth.address)); - expect(lastSwapTime).to.eq(await lastBlockTimestamp()); + const after = await pool.getAssetSwapDetails(dai.address); + expect(after.lastSwapTime).to.eq(await lastBlockTimestamp()); }); - it('setting pools swapValue works when selling eth', async function () { - const { - contracts: { swapOperator, pool }, - order, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - expect(await pool.swapValue()).to.eq(0); + it('sets lastSwapDate on sellToken when buying ETH', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { swapOperator, dai, pool } = contracts; + + const before = await pool.getAssetSwapDetails(dai.address); + expect(before.lastSwapTime).to.not.eq(await lastBlockTimestamp()); await swapOperator.placeOrder(contractOrder, orderUID); - expect(await pool.swapValue()).to.eq(order.sellAmount.add(order.feeAmount)); + const after = await pool.getAssetSwapDetails(dai.address); + expect(after.lastSwapTime).to.eq(await lastBlockTimestamp()); }); - it('setting pools swapValue works when transferring ERC20', async function () { - const { - contracts: { swapOperator, pool, priceFeedOracle, weth, dai }, - order, - domain, - } = await loadFixture(placeSellWethOrderSetup); - const { newContractOrder, newOrderUID } = await setupSellDaiForEth({}, { dai, pool, order, weth, domain }); + it('sets lastSwapDate on both sellToken / buyToken when swapping asset to asset', async function () { + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, stEth, dai, pool } = contracts; - expect(await pool.swapValue()).to.eq(0); + const stEthBefore = await pool.getAssetSwapDetails(stEth.address); + const daBefore = await pool.getAssetSwapDetails(dai.address); + expect(stEthBefore.lastSwapTime).to.not.eq(await lastBlockTimestamp()); + expect(daBefore.lastSwapTime).to.not.eq(await lastBlockTimestamp()); - await swapOperator.placeOrder(newContractOrder, newOrderUID); + await swapOperator.placeOrder(contractOrder, orderUID); - const { sellAmount, feeAmount } = newContractOrder; - const expectedSwapValue = await priceFeedOracle.getEthForAsset(dai.address, sellAmount.add(feeAmount)); - expect(await pool.swapValue()).to.be.equal(expectedSwapValue); + const stEthAfter = await pool.getAssetSwapDetails(stEth.address); + const daiAfter = await pool.getAssetSwapDetails(dai.address); + expect(stEthAfter.lastSwapTime).to.eq(await lastBlockTimestamp()); + expect(daiAfter.lastSwapTime).to.eq(await lastBlockTimestamp()); }); - it('setting pools swapValue works when swapping non-ETH asset to non-ETH asset', async function () { - const { - contracts: { swapOperator, pool, priceFeedOracle, stEth, dai }, - order: { sellAmount, feeAmount }, - orderUID, - contractOrder, - } = await loadFixture(placeNonEthOrderSetup); + // TODO: transfers assets to swapOperator tests + + it('should set totalOutAmount in ETH as pool.swapValue when selling ETH', async function () { + const { contracts, order, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, pool } = contracts; expect(await pool.swapValue()).to.eq(0); await swapOperator.placeOrder(contractOrder, orderUID); - const sellAmountWithFee = sellAmount.add(feeAmount); - const sellAmountInEth = await priceFeedOracle.getEthForAsset(stEth.address, sellAmountWithFee); // stETH -> ETH - const expectedSwapValue = await priceFeedOracle.getAssetForEth(dai.address, sellAmountInEth); // ETH -> DAI - expect(await pool.swapValue()).to.eq(expectedSwapValue); + // sellAmount & already in ETH + const totalOutAmountInEth = order.sellAmount.add(order.feeAmount); + expect(await pool.swapValue()).to.eq(totalOutAmountInEth); }); - it('approves CoW vault relayer to spend the exact amount of sellToken when selling ETH', async function () { - const { - contracts: { swapOperator, weth, cowVaultRelayer }, - order: { sellAmount, feeAmount }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); + it('should set totalOutAmount in ETH as pool.swapValue when selling non-ETH assets', async function () { + const orderSetupsToTest = [placeBuyWethOrderSetup, placeNonEthOrderSetup]; + for (const orderSetup of orderSetupsToTest) { + const { contracts, order, contractOrder, orderUID } = await loadFixture(orderSetup); + const { swapOperator, pool, priceFeedOracle } = contracts; - await swapOperator.placeOrder(contractOrder, orderUID); + expect(await pool.swapValue()).to.eq(0); - expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(sellAmount.add(feeAmount)); + await swapOperator.placeOrder(contractOrder, orderUID); + + // convert non-ETH sellAmount + fee to ETH + const { sellAmount, feeAmount } = contractOrder; + const totalOutAmountInEth = await priceFeedOracle.getEthForAsset(order.sellToken, sellAmount.add(feeAmount)); + expect(await pool.swapValue()).to.be.equal(totalOutAmountInEth); + } }); - it('approves CoW vault relayer to spend the exact amount of sellToken when selling non-ETH asset', async function () { - const { - contracts: { swapOperator, stEth, cowVaultRelayer }, - order: { sellAmount, feeAmount }, - contractOrder, - orderUID, - } = await loadFixture(placeNonEthOrderSetup); - expect(await stEth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); + it('should set totalOutAmount in ETH as pool.swapValue on non-ETH asset swaps', async function () { + const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSetup); + const { swapOperator, pool, priceFeedOracle, stEth } = contracts; + const { sellAmount, feeAmount } = order; + + expect(await pool.swapValue()).to.eq(0); await swapOperator.placeOrder(contractOrder, orderUID); - expect(await stEth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(sellAmount.add(feeAmount)); + // convert stETH sellAmount + fee to ETH + const totalOutAmountInEth = await priceFeedOracle.getEthForAsset(stEth.address, sellAmount.add(feeAmount)); + expect(await pool.swapValue()).to.eq(totalOutAmountInEth); + }); + + it('approves CoW vault relayer to spend exactly sellAmount + fee', async function () { + const orderSetupsToTest = [ + { sellTokenName: 'weth', orderSetup: placeSellWethOrderSetup }, + { sellTokenName: 'dai', orderSetup: placeBuyWethOrderSetup }, + { sellTokenName: 'stEth', orderSetup: placeNonEthOrderSetup }, + ]; + for (const { sellTokenName, orderSetup } of orderSetupsToTest) { + const { contracts, order, contractOrder, orderUID } = await loadFixture(orderSetup); + const { sellAmount, feeAmount } = order; + const { swapOperator, cowVaultRelayer } = contracts; + const sellToken = contracts[sellTokenName]; + + expect(await sellToken.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); + await swapOperator.placeOrder(contractOrder, orderUID); + expect(await sellToken.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(sellAmount.add(feeAmount)); + } }); it('stores the current orderUID in the contract', async function () { - const { - contracts: { swapOperator }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - expect(await swapOperator.currentOrderUID()).to.eq('0x'); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + expect(await swapOperator.currentOrderUID()).to.eq('0x'); await swapOperator.placeOrder(contractOrder, orderUID); - expect(await swapOperator.currentOrderUID()).to.eq(orderUID); }); it('calls setPreSignature on CoW settlement contract', async function () { - const { - contracts: { swapOperator, cowSettlement }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); - expect(await cowSettlement.presignatures(orderUID)).to.eq(false); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator, cowSettlement } = contracts; + expect(await cowSettlement.presignatures(orderUID)).to.eq(false); await swapOperator.placeOrder(contractOrder, orderUID); - expect(await cowSettlement.presignatures(orderUID)).to.eq(true); }); it('emits an OrderPlaced event', async function () { - const { - contracts: { swapOperator }, - contractOrder, - orderUID, - } = await loadFixture(placeSellWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + await expect(swapOperator.placeOrder(contractOrder, orderUID)) .to.emit(swapOperator, 'OrderPlaced') .withArgs(Object.values(contractOrder)); From cd0f8ed10b0d6fbc4a72562e3872bd1cf49d872c Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Sun, 25 Feb 2024 20:32:43 +0200 Subject: [PATCH 31/88] Refactor placeOrder + add custom errors --- contracts/modules/capital/SwapOperator.sol | 427 +++++++++++++-------- 1 file changed, 271 insertions(+), 156 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 885f71c969..b161199c14 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -12,6 +12,7 @@ import "../../interfaces/IPool.sol"; import "../../interfaces/IPriceFeedOracle.sol"; import "../../interfaces/IWeth.sol"; import "../../interfaces/IERC20Detailed.sol"; +import "../../interfaces/ISwapOperator.sol"; import "../../external/enzyme/IEnzymeFundValueCalculatorRouter.sol"; import "../../external/enzyme/IEnzymeV4Vault.sol"; @@ -22,7 +23,7 @@ import "../../external/enzyme/IEnzymePolicyManager.sol"; @title A contract for swapping Pool's assets using CoW protocol @dev This contract's address is set on the Pool's swapOperator variable via governance */ -contract SwapOperator { +contract SwapOperator is ISwapOperator { using SafeERC20 for IERC20; // Storage @@ -48,11 +49,6 @@ contract SwapOperator { uint public constant MIN_TIME_BETWEEN_ORDERS = 900; // 15 minutes uint public constant maxFee = 0.3 ether; - // Events - event OrderPlaced(GPv2Order.Data order); - event OrderClosed(GPv2Order.Data order, uint filledAmount); - event Swapped(address indexed fromAsset, address indexed toAsset, uint amountIn, uint amountOut); - modifier onlyController() { require(msg.sender == swapController, "SwapOp: only controller can execute"); _; @@ -107,126 +103,269 @@ contract SwapOperator { GPv2Order.packOrderUidParams(uid, digest, order.receiver, order.validTo); return uid; } + + /** + * @dev Validates that the quoted amount does not exceed minimum acceptable amount after accounting for max slippage + */ + function validateSlippageAndOracleAmount(address quotedAsset, uint quotedAmount, uint oracleAmount) internal view { + SwapDetails memory swapDetails = _pool().getAssetSwapDetails(quotedAsset); - function validateBuyAmoutOnMaxSlippage( - uint orderBuyAmount, - uint oracleBuyAmount, - uint16 maxSlippageRatio - ) internal pure { // Calculate slippage and minimum amount we should accept - uint maxSlippageAmount = (oracleBuyAmount * maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; - uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; - require(orderBuyAmount >= minBuyAmountOnMaxSlippage, "SwapOp: order.buyAmount too low (oracle)"); + uint maxSlippageAmount = (oracleAmount * swapDetails.maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; + uint minBuyAmountOnMaxSlippage = oracleAmount - maxSlippageAmount; + if (quotedAmount < minBuyAmountOnMaxSlippage) { + revert MaxSlippageExceeded(minBuyAmountOnMaxSlippage); + } } - - function getToAssetForFromAsset( - IPriceFeedOracle priceFeedOracle, - address toAsset, - address fromAsset, - uint fromAssetAmount - ) internal view returns (uint) { + + /** + * @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAssetAmount` in `fromAsset` + * Supports conversions for ETH to Asset, Asset to ETH, and Asset to Asset + */ + function getOracleAmount(address toAsset, address fromAsset, uint fromAssetAmount) internal view returns (uint) { + IPriceFeedOracle priceFeedOracle = _pool().priceFeedOracle(); + + if (fromAsset == address(weth)) { + // ETH -> toAsset + return priceFeedOracle.getAssetForEth(toAsset, fromAssetAmount); + } + if (toAsset == address(weth)) { + // fromAsset -> ETH + return priceFeedOracle.getEthForAsset(fromAsset, fromAssetAmount); + } + // fromAsset -> toAsset via ETH uint fromAssetInEth = priceFeedOracle.getEthForAsset(fromAsset, fromAssetAmount); return priceFeedOracle.getAssetForEth(toAsset, fromAssetInEth); } + + /** + * @dev Validates the quoteAmount with the oracle price and max slippage tolerances + * If KIND_SELL validates quoted buyAmount + * If KIND_BUY validates quoted sellAmount + */ + function validateQuotedAmount(GPv2Order.Data memory order) internal view { + if (order.kind == GPv2Order.KIND_SELL) { + // KIND_SELL - buyToken is quoted / sellToken is inputted + uint quotedAmount = order.buyAmount; + address quotedAsset = address(order.buyToken); + uint inputAssetAmount = order.sellAmount; + address inputAsset = address(order.sellToken); + + uint oracleAmount = getOracleAmount(quotedAsset, inputAsset, inputAssetAmount); + validateSlippageAndOracleAmount(quotedAsset, quotedAmount, oracleAmount); + } else { // GPv2Order.KIND_BUY + // KIND_BUY - sellToken is quoted / buyToken is inputted + uint quotedAmount = order.sellAmount; + address quotedAsset = address(order.sellToken); + uint inputAssetAmount = order.buyAmount; + address inputAsset = address(order.buyToken); + + uint oracleAmount = getOracleAmount(quotedAsset, inputAsset, inputAssetAmount); + validateSlippageAndOracleAmount(quotedAsset, quotedAmount, oracleAmount); + } + } /** - * @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract - * Only one order can be open at the same time, and one of the swapped assets must be ether - * @param order The order - * @param orderUID The order UID, for verification purposes + * @dev Validates if a token is enabled for swapping. + * WETH is excluded in validation since it does not have set swapDetails (i.e. SwapDetails(0,0,0,0)) */ - function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) public onlyController { - // Validate there's no current order going on - require(!orderInProgress(), "SwapOp: an order is already in place"); + function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal view { + if (token != address(weth) && swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { + revert OrderTokenIsDisabled(token); + } + } - // Order UID verification - validateUID(order, orderUID); + /** + * @dev Validates minimum pool ETH reserve is not breached after selling ETH + */ + function validateEthBalance(IPool pool, uint totalOutAmount) internal view { + uint ethPostSwap = address(pool).balance - totalOutAmount; + if (ethPostSwap < minPoolEth) { + revert EthReserveBelowMin(ethPostSwap, minPoolEth); + } + } - // Validate basic CoW params - validateBasicCowParams(order); + /** + * @dev Validates two conditions: + * 1. The current sellToken balance is greater than sellSwapDetails.maxAmount + * 2. The post-swap sellToken balance is greater than or equal to sellSwapDetails.minAmount + * Skips validation for WETH since it does not have set swapDetails + */ + function validateSellTokenBalance(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { + uint sellTokenBalance = swapOp.order.sellToken.balanceOf(address(pool)); - IPool pool = _pool(); - IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); - uint totalOutAmount = order.sellAmount + order.feeAmount; + // skip validation for WETH since it does not have set swapDetails + if (address(swapOp.order.sellToken) == address(weth)) { + return; + } + + if (sellTokenBalance <= swapOp.sellSwapDetails.maxAmount) { + revert InvalidBalance(sellTokenBalance, swapOp.sellSwapDetails.maxAmount, 'max'); + } + // NOTE: the totalOutAmount (i.e. sellAmount + fee) is used to get postSellTokenSwapBalance + uint postSellTokenSwapBalance = sellTokenBalance - totalOutAmount; + if (postSellTokenSwapBalance < swapOp.sellSwapDetails.minAmount) { + revert InvalidPostSwapBalance(postSellTokenSwapBalance, swapOp.sellSwapDetails.minAmount, 'min'); + } + } - // TODO: replace requires with custom errors - if (isSellingEth(order)) { - // ETH -> asset + /** + * @dev Validates two conditions: + * 1. The current buyToken balance is less than buySwapDetails.minAmount. + * 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount. + * Skip validation for WETH since it does not have set swapDetails + */ + function validateBuyTokenBalance(IPool pool, SwapOperation memory swapOp) internal view { + uint buyTokenBalance = swapOp.order.buyToken.balanceOf(address(pool)); + + // skip validation for WETH since it does not have set swapDetails + if (address(swapOp.order.buyToken) == address(weth)) { + return; + } + + if (buyTokenBalance >= swapOp.buySwapDetails.minAmount) { + revert InvalidBalance(buyTokenBalance, swapOp.buySwapDetails.minAmount, 'min'); + } + // NOTE: use order.buyAmount to get postBuyTokenSwapBalance + uint postBuyTokenSwapBalance = buyTokenBalance + swapOp.order.buyAmount; + if (postBuyTokenSwapBalance > swapOp.buySwapDetails.maxAmount) { + revert InvalidPostSwapBalance(postBuyTokenSwapBalance, swapOp.buySwapDetails.maxAmount, 'max'); + } + } + + /** + * @dev Helper function to determine the SwapOperationType of the order + */ + function getSwapOperationType(GPv2Order.Data memory order) internal view returns (SwapOperationType) { + if (address(order.sellToken) == address(weth)) { + return SwapOperationType.WethToAsset; + } else if (address(order.buyToken) == address(weth)) { + return SwapOperationType.AssetToWeth; + } else { + return SwapOperationType.AssetToAsset; + } + } - // Validate minimum pool eth reserve when selling ETH - require(address(pool).balance - totalOutAmount >= minPoolEth, "SwapOp: Pool eth balance below min"); + /** + * @dev NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) + */ + function prepareSwapDetails(IPool pool, GPv2Order.Data calldata order) internal view returns (SwapOperation memory) { + SwapDetails memory sellSwapDetails = pool.getAssetSwapDetails(address(order.sellToken)); + SwapDetails memory buySwapDetails = pool.getAssetSwapDetails(address(order.buyToken)); + + return SwapOperation({ + order: order, + sellSwapDetails: sellSwapDetails, + buySwapDetails: buySwapDetails, + swapType: getSwapOperationType(order) + }); + } - SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(order.buyToken)); - require(swapDetails.minAmount != 0 || swapDetails.maxAmount != 0, "SwapOp: buyToken is not enabled"); + /** + * @dev Performs pre-swap validation checks for a given swap operation + */ + function performPreSwapValidations(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { + address sellTokenAddress = address(swapOp.order.sellToken); + address buyTokenAddress = address(swapOp.order.buyToken); - uint buyTokenBalance = order.buyToken.balanceOf(address(pool)); - require(buyTokenBalance < swapDetails.minAmount, "SwapOp: can only buy asset when < minAmount"); - require(buyTokenBalance + order.buyAmount <= swapDetails.maxAmount, "SwapOp: swap brings buyToken above max"); + // validate both sell and buy tokens are enabled + validateTokenIsEnabled(sellTokenAddress, swapOp.sellSwapDetails); + validateTokenIsEnabled(buyTokenAddress, swapOp.buySwapDetails); - validateSwapFrequency(swapDetails); - validateMaxFee(priceFeedOracle, ETH, order.feeAmount); + // validate ETH balance is within ETH reserves after the swap + if (swapOp.swapType == SwapOperationType.WethToAsset) { + validateEthBalance(pool, totalOutAmount); + } + + // validate sell/buy token balances against swapDetails min/max + validateSellTokenBalance(pool, swapOp, totalOutAmount); + validateBuyTokenBalance(pool, swapOp); + + // validate swap frequency to enforce cool down periods + validateSwapFrequency(swapOp.sellSwapDetails); + validateSwapFrequency(swapOp.buySwapDetails); + + // validate max fee and max slippage + validateMaxFee(sellTokenAddress, swapOp.order.feeAmount); + validateQuotedAmount(swapOp.order); + } - // Ask oracle how much of the other asset we should get - uint oracleBuyAmount = priceFeedOracle.getAssetForEth(address(order.buyToken), order.sellAmount); - validateBuyAmoutOnMaxSlippage(order.buyAmount, oracleBuyAmount, swapDetails.maxSlippageRatio); + /** + * @dev Executes asset transfers from Pool to SwapOperator for CoW Swap order executions + * Additionally if selling ETH, wraps received Pool ETH to WETH + */ + function executeAssetTransfer(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal returns (uint swapValueEth) { + IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); + address sellTokenAddress = address(swapOp.order.sellToken); + address buyTokenAddress = address(swapOp.order.buyToken); + + if (swapOp.swapType == SwapOperationType.WethToAsset) { + // set lastSwapTime of buyToken only (sellToken WETH has no set swapDetails) + pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); + // transfer ETH from pool and wrap it (use ETH address here because swapOp.sellToken is WETH address) + pool.transferAssetToSwapOperator(ETH, totalOutAmount); + weth.deposit{value: totalOutAmount}(); + // no need to convert since totalOutAmount is already in ETH (i.e. WETH) + swapValueEth = totalOutAmount; + } else if (swapOp.swapType == SwapOperationType.AssetToWeth) { + // set lastSwapTime of sellToken only (buyToken WETH has no set swapDetails) + pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); + // transfer ERC20 asset from Pool + pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); + // convert totalOutAmount (sellAmount + fee) to ETH + swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); + } else { // SwapOperationType.AssetToAsset + // set lastSwapTime of sell / buy tokens + pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); + pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); + // transfer ERC20 asset from Pool + pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); + // convert totalOutAmount (sellAmount + fee) to ETH + swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); + } - refreshAssetLastSwapDate(pool, address(order.buyToken)); + return swapValueEth; + } - // Transfer ETH from pool and wrap it - pool.transferAssetToSwapOperator(ETH, totalOutAmount); - weth.deposit{value: totalOutAmount}(); + /** + * @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract + * The order is validated before the sellToken is transferred from the Pool to the SwapOperator for the CoW swap operation + * Only one order can be open at the same time + */ + function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) public onlyController { + if (orderInProgress()) { + revert OrderInProgress(); + } - // Set the calculated oracle swapValue on the pool - pool.setSwapValue(totalOutAmount); + // Order UID and basic CoW params validations + validateUID(order, orderUID); + validateBasicCowParams(order); - } else { - // asset -> ETH OR asset -> asset - - SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(order.sellToken)); - require(swapDetails.minAmount != 0 || swapDetails.maxAmount != 0, "SwapOp: sellToken is not enabled"); - - uint sellTokenBalance = order.sellToken.balanceOf(address(pool)); - require(sellTokenBalance > swapDetails.maxAmount, "SwapOp: can only sell asset when > maxAmount"); - require(sellTokenBalance - totalOutAmount >= swapDetails.minAmount, "SwapOp: swap brings sellToken below min"); - - validateSwapFrequency(swapDetails); - validateMaxFee(priceFeedOracle, address(order.sellToken), order.feeAmount); - - // Ask oracle how much we should get (oracleBuyAmount) and what is the expected swapValue - uint oracleBuyAmount; - uint swapValue; - - if (isBuyingEth(order)) { - // asset -> ETH - oracleBuyAmount = priceFeedOracle.getEthForAsset(address(order.sellToken), order.sellAmount); - swapValue = priceFeedOracle.getEthForAsset(address(order.sellToken), totalOutAmount); - } else { - // asset -> asset - oracleBuyAmount = getToAssetForFromAsset(priceFeedOracle, address(order.buyToken), address(order.sellToken), order.sellAmount); - swapValue = getToAssetForFromAsset(priceFeedOracle, address(order.buyToken), address(order.sellToken), totalOutAmount); - } + IPool pool = _pool(); + uint totalOutAmount = order.sellAmount + order.feeAmount; - validateBuyAmoutOnMaxSlippage(order.buyAmount, oracleBuyAmount, swapDetails.maxSlippageRatio); + // Prepare swap details + SwapOperation memory swapOp = prepareSwapDetails(pool, order); - refreshAssetLastSwapDate(pool, address(order.sellToken)); + // Perform validations + performPreSwapValidations(pool, swapOp, totalOutAmount); - // Transfer ERC20 asset from Pool - pool.transferAssetToSwapOperator(address(order.sellToken), totalOutAmount); + // Execute swap based on operation type + uint swapValueEth = executeAssetTransfer(pool, swapOp, totalOutAmount); - // Set the calculated oracle swapValue on the pool - pool.setSwapValue(swapValue); - } + // Set the swapValue on the pool + pool.setSwapValue(swapValueEth); - // Approve Cow's contract to spend sellToken - approveVaultRelayer(order.sellToken, totalOutAmount); + // Approve cowVaultRelayer contract to spend sellToken totalOutAmount + order.sellToken.safeApprove(cowVaultRelayer, totalOutAmount); - // Store the order UID + // Store the orderUID currentOrderUID = orderUID; // Sign the Cow order cowSettlement.setPreSignature(orderUID, true); - // Emit an event + // Emit OrderPlaced event emit OrderPlaced(order); } @@ -250,7 +389,7 @@ contract SwapOperator { // Cancel signature and unapprove tokens cowSettlement.setPreSignature(currentOrderUID, false); - approveVaultRelayer(order.sellToken, 0); + order.sellToken.safeApprove(cowVaultRelayer, 0); // Clear the current order delete currentOrderUID; @@ -290,49 +429,29 @@ contract SwapOperator { } } - /** - * @dev Function to determine if an order is for selling eth - * @param order The order - * @return true or false - */ - function isSellingEth(GPv2Order.Data calldata order) internal view returns (bool) { - return address(order.sellToken) == address(weth); - } - - /** - * @dev Function to determine if an order is for buying eth - * @param order The order - * @return true or false - */ - function isBuyingEth(GPv2Order.Data calldata order) internal view returns (bool) { - return address(order.buyToken) == address(weth); - } - /** * @dev General validations on individual order fields * @param order The order */ function validateBasicCowParams(GPv2Order.Data calldata order) internal view { - require(order.sellTokenBalance == GPv2Order.BALANCE_ERC20, "SwapOp: Only erc20 supported for sellTokenBalance"); - require(order.buyTokenBalance == GPv2Order.BALANCE_ERC20, "SwapOp: Only erc20 supported for buyTokenBalance"); - require(order.receiver == address(this), "SwapOp: Receiver must be this contract"); - require( - order.validTo >= block.timestamp + MIN_VALID_TO_PERIOD, - "SwapOp: validTo must be at least 10 minutes in the future" - ); - require( - order.validTo <= block.timestamp + MAX_VALID_TO_PERIOD, - "SwapOp: validTo must be at most 60 minutes in the future" - ); - } + uint minValidTo = block.timestamp + MIN_VALID_TO_PERIOD; + uint maxValidTo = block.timestamp + MAX_VALID_TO_PERIOD; - /** - * @dev Approve CoW's vault relayer to spend some given ERC20 token - * @param token The token - * @param amount Amount to approve - */ - function approveVaultRelayer(IERC20 token, uint amount) internal { - token.safeApprove(cowVaultRelayer, amount); + if (order.validTo < minValidTo) { + revert BelowMinValidTo(minValidTo); + } + if (order.validTo > maxValidTo) { + revert AboveMaxValidTo(maxValidTo); + } + if (order.receiver != address(this)) { + revert InvalidReceiver(); + } + if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20) { + revert UnsupportedTokenBalance('sell'); + } + if (order.buyTokenBalance != GPv2Order.BALANCE_ERC20) { + revert UnsupportedTokenBalance('buy'); + } } /** @@ -341,11 +460,10 @@ contract SwapOperator { * @param providedOrderUID The UID */ function validateUID(GPv2Order.Data calldata order, bytes memory providedOrderUID) internal view { - bytes memory calculatedUID = getUID(order); - require( - keccak256(calculatedUID) == keccak256(providedOrderUID), - "SwapOp: Provided UID doesnt match calculated UID" - ); + bytes memory calculatedOrderUID = getUID(order); + if (keccak256(calculatedOrderUID) != keccak256(providedOrderUID)) { + revert OrderUidMismatch(providedOrderUID, calculatedOrderUID); + } } /** @@ -360,35 +478,32 @@ contract SwapOperator { * @dev Validates that a given asset is not swapped too fast * @param swapDetails Swap details for the given asset */ + // TOOD: unit test for ETH SwapDetails(0,0,0,0) - should not error function validateSwapFrequency(SwapDetails memory swapDetails) internal view { - require( - block.timestamp >= swapDetails.lastSwapTime + MIN_TIME_BETWEEN_ORDERS, - "SwapOp: already swapped this asset recently" - ); - } - - /** - * @dev Set the last swap's time of a given asset to current time - * @param pool The pool instance - * @param asset The asset - */ - function refreshAssetLastSwapDate(IPool pool, address asset) internal { - pool.setSwapDetailsLastSwapTime(asset, uint32(block.timestamp)); + uint minValidSwapTime = swapDetails.lastSwapTime + MIN_TIME_BETWEEN_ORDERS; + if (block.timestamp < minValidSwapTime) { + revert InsufficientTimeBetweenSwaps(minValidSwapTime); + } } /** * @dev Validate that the fee for the order is not higher than the maximum allowed fee, in ether - * @param oracle The oracle instance - * @param asset The asset - * @param feeAmount The fee, in asset's units + * @param sellAsset The sell asset + * @param feeAmount The fee (will always be denominated in the sell asset units) */ + // TODO: unit test with WETH / ETH / asset address + // what if there is no WETH in oracle + // what if there is no asset in oracle? - error function validateMaxFee( - IPriceFeedOracle oracle, - address asset, + address sellAsset, uint feeAmount ) internal view { - uint feeInEther = oracle.getEthForAsset(asset, feeAmount); - require(feeInEther <= maxFee, "SwapOp: Fee amount is higher than configured max fee"); + uint feeInEther = sellAsset == address(weth) + ? feeAmount + : _pool().priceFeedOracle().getEthForAsset(sellAsset, feeAmount); + if (feeInEther > maxFee) { + revert AboveMaxFee(maxFee); + } } From 2ce3ca9443d198a14dd378fdfa4cb423949876de Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 26 Feb 2024 11:14:58 +0200 Subject: [PATCH 32/88] Fix closeOrder UID unit test --- test/unit/SwapOperator/closeOrder.js | 19 +++++++++++-------- test/unit/SwapOperator/helpers.js | 4 ++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/test/unit/SwapOperator/closeOrder.js b/test/unit/SwapOperator/closeOrder.js index 4802a096cf..f914a676cf 100644 --- a/test/unit/SwapOperator/closeOrder.js +++ b/test/unit/SwapOperator/closeOrder.js @@ -158,17 +158,20 @@ describe('closeOrder', function () { const { contracts: { swapOperator }, contractOrder, + order, + orderUID, + domain, } = await loadFixture(closeOrderSetup); // the contract's currentOrderUID is the one for the placed order in beforeEach step // we call with multiple invalid orders, with each individual field modified. it should fail - for (const [key, value] of Object.entries(contractOrder)) { - const wrongOrder = { - ...contractOrder, - [key]: makeWrongValue(value), - }; - await expect(swapOperator.closeOrder(wrongOrder)).to.revertedWith( - 'SwapOp: Provided UID doesnt match calculated UID', - ); + for (const [key, value] of Object.entries(order)) { + const wrongOrder = { ...order, [key]: makeWrongValue(value) }; + const wrongOrderUID = computeOrderUid(domain, wrongOrder, wrongOrder.receiver); + const wrongContractOrder = makeContractOrder(wrongOrder); + + await expect(swapOperator.closeOrder(wrongContractOrder)) + .to.revertedWithCustomError(swapOperator, 'OrderUidMismatch') + .withArgs(orderUID, wrongOrderUID); } // call with an order that matches currentOrderUID, should succeed diff --git a/test/unit/SwapOperator/helpers.js b/test/unit/SwapOperator/helpers.js index f42cb57059..7c173bf7be 100644 --- a/test/unit/SwapOperator/helpers.js +++ b/test/unit/SwapOperator/helpers.js @@ -55,6 +55,10 @@ const makeWrongValue = value => { return value + 1; } else if (typeof value === 'boolean') { return !value; + } else if (value === 'erc20') { + return 'internal'; + } else if (typeof value === 'string') { + return value + '!'; } else { throw new Error(`Unsupported value while fuzzing order: ${value}`); } From 843e5c703870da262c418c6f664e61a0ce56c92b Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 26 Feb 2024 11:44:09 +0200 Subject: [PATCH 33/88] Clean up + fix linting issues --- contracts/interfaces/ISwapOperator.sol | 15 +++++++++------ contracts/modules/capital/SwapOperator.sol | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 8e9eb44766..fe25afcda4 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -6,8 +6,11 @@ import "../external/cow/GPv2Order.sol"; import "../interfaces/IPool.sol"; interface ISwapOperator { - - enum SwapOperationType { WethToAsset, AssetToWeth, AssetToAsset } + enum SwapOperationType { + WethToAsset, + AssetToWeth, + AssetToAsset + } struct SwapOperation { GPv2Order.Data order; @@ -21,7 +24,7 @@ interface ISwapOperator { function getDigest(GPv2Order.Data calldata order) external view returns (bytes32); function getUID(GPv2Order.Data calldata order) external view returns (bytes memory); - + function orderInProgress() external view returns (bool); /* ==== MUTATIVE FUNCTIONS ==== */ @@ -36,7 +39,7 @@ interface ISwapOperator { event OrderClosed(GPv2Order.Data order, uint filledAmount); event Swapped(address indexed fromAsset, address indexed toAsset, uint amountIn, uint amountOut); - // Order + // Order error OrderInProgress(); error OrderUidMismatch(bytes providedOrderUID, bytes expectedOrderUID); error UnsupportedTokenBalance(string kind); @@ -46,10 +49,10 @@ interface ISwapOperator { // Valid To error BelowMinValidTo(uint minValidTo); error AboveMaxValidTo(uint maxValidTo); - + // Cool down error InsufficientTimeBetweenSwaps(uint minValidSwapTime); - + // Balance error EthReserveBelowMin(uint ethPostSwap, uint minEthReserve); error InvalidBalance(uint tokenBalance, uint limit, string limitType); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index b161199c14..c4b850344a 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -239,11 +239,11 @@ contract SwapOperator is ISwapOperator { function getSwapOperationType(GPv2Order.Data memory order) internal view returns (SwapOperationType) { if (address(order.sellToken) == address(weth)) { return SwapOperationType.WethToAsset; - } else if (address(order.buyToken) == address(weth)) { + } + if (address(order.buyToken) == address(weth)) { return SwapOperationType.AssetToWeth; - } else { - return SwapOperationType.AssetToAsset; } + return SwapOperationType.AssetToAsset; } /** From 3d63813a058f0bea6fa7a21a6f59ea47e78283de Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 27 Feb 2024 19:11:36 +0200 Subject: [PATCH 34/88] Replace setPreSignature false with invalidateOrder on closeOrder --- contracts/interfaces/ICowSettlement.sol | 2 ++ contracts/modules/capital/SwapOperator.sol | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/ICowSettlement.sol b/contracts/interfaces/ICowSettlement.sol index cbb69a7166..fae4e81fb8 100644 --- a/contracts/interfaces/ICowSettlement.sol +++ b/contracts/interfaces/ICowSettlement.sol @@ -27,6 +27,8 @@ interface ICowSettlement { } function setPreSignature(bytes calldata orderUid, bool signed) external; + + function invalidateOrder(bytes calldata orderUid) external; function filledAmount(bytes calldata orderUid) external view returns (uint256); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index c4b850344a..a2e53aee70 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -388,7 +388,7 @@ contract SwapOperator is ISwapOperator { uint filledAmount = cowSettlement.filledAmount(currentOrderUID); // Cancel signature and unapprove tokens - cowSettlement.setPreSignature(currentOrderUID, false); + cowSettlement.invalidateOrder(currentOrderUID); order.sellToken.safeApprove(cowVaultRelayer, 0); // Clear the current order From 8d13a224c5de5daa2fffbb488a4edd4d6cdb77ce Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 27 Feb 2024 19:13:02 +0200 Subject: [PATCH 35/88] Use triple slash for function annotations --- contracts/modules/capital/SwapOperator.sol | 174 ++++++++------------- 1 file changed, 68 insertions(+), 106 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index a2e53aee70..2f69c376e3 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -19,10 +19,8 @@ import "../../external/enzyme/IEnzymeV4Vault.sol"; import "../../external/enzyme/IEnzymeV4Comptroller.sol"; import "../../external/enzyme/IEnzymePolicyManager.sol"; -/** - @title A contract for swapping Pool's assets using CoW protocol - @dev This contract's address is set on the Pool's swapOperator variable via governance - */ +/// @title A contract for swapping Pool's assets using CoW protocol +/// @dev This contract's address is set on the Pool's swapOperator variable via governance contract SwapOperator is ISwapOperator { using SafeERC20 for IERC20; @@ -54,12 +52,10 @@ contract SwapOperator is ISwapOperator { _; } - /** - * @param _cowSettlement Address of CoW protocol's settlement contract - * @param _swapController Account allowed to place and close orders - * @param _master Address of Nexus' master contract - * @param _weth Address of wrapped eth token - */ + /// @param _cowSettlement Address of CoW protocol's settlement contract + /// @param _swapController Account allowed to place and close orders + /// @param _master Address of Nexus' master contract + /// @param _weth Address of wrapped eth token constructor( address _cowSettlement, address _swapController, @@ -82,21 +78,17 @@ contract SwapOperator is ISwapOperator { receive() external payable {} - /** - * @dev Compute the digest of an order using CoW protocol's logic - * @param order The order - * @return The digest - */ + /// @dev Compute the digest of an order using CoW protocol's logic + /// @param order The order + /// @return The digest function getDigest(GPv2Order.Data calldata order) public view returns (bytes32) { bytes32 hash = GPv2Order.hash(order, domainSeparator); return hash; } - /** - * @dev Compute the UID of an order using CoW protocol's logic - * @param order The order - * @return The UID (56 bytes) - */ + /// @dev Compute the UID of an order using CoW protocol's logic + /// @param order The order + /// @return The UID (56 bytes) function getUID(GPv2Order.Data calldata order) public view returns (bytes memory) { bytes memory uid = new bytes(56); bytes32 digest = getDigest(order); @@ -104,9 +96,7 @@ contract SwapOperator is ISwapOperator { return uid; } - /** - * @dev Validates that the quoted amount does not exceed minimum acceptable amount after accounting for max slippage - */ + /// @dev Validates that the quoted amount does not exceed minimum acceptable amount after accounting for max slippage function validateSlippageAndOracleAmount(address quotedAsset, uint quotedAmount, uint oracleAmount) internal view { SwapDetails memory swapDetails = _pool().getAssetSwapDetails(quotedAsset); @@ -118,10 +108,8 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAssetAmount` in `fromAsset` - * Supports conversions for ETH to Asset, Asset to ETH, and Asset to Asset - */ + /// @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAssetAmount` in `fromAsset` + /// Supports conversions for ETH to Asset, Asset to ETH, and Asset to Asset function getOracleAmount(address toAsset, address fromAsset, uint fromAssetAmount) internal view returns (uint) { IPriceFeedOracle priceFeedOracle = _pool().priceFeedOracle(); @@ -138,11 +126,9 @@ contract SwapOperator is ISwapOperator { return priceFeedOracle.getAssetForEth(toAsset, fromAssetInEth); } - /** - * @dev Validates the quoteAmount with the oracle price and max slippage tolerances - * If KIND_SELL validates quoted buyAmount - * If KIND_BUY validates quoted sellAmount - */ + /// @dev Validates the quoteAmount with the oracle price and max slippage tolerances + /// If KIND_SELL validates quoted buyAmount + /// If KIND_BUY validates quoted sellAmount function validateQuotedAmount(GPv2Order.Data memory order) internal view { if (order.kind == GPv2Order.KIND_SELL) { // KIND_SELL - buyToken is quoted / sellToken is inputted @@ -165,19 +151,15 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Validates if a token is enabled for swapping. - * WETH is excluded in validation since it does not have set swapDetails (i.e. SwapDetails(0,0,0,0)) - */ + /// @dev Validates if a token is enabled for swapping. + /// WETH is excluded in validation since it does not have set swapDetails (i.e. SwapDetails(0,0,0,0)) function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal view { if (token != address(weth) && swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { revert OrderTokenIsDisabled(token); } } - /** - * @dev Validates minimum pool ETH reserve is not breached after selling ETH - */ + /// @dev Validates minimum pool ETH reserve is not breached after selling ETH function validateEthBalance(IPool pool, uint totalOutAmount) internal view { uint ethPostSwap = address(pool).balance - totalOutAmount; if (ethPostSwap < minPoolEth) { @@ -185,12 +167,10 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Validates two conditions: - * 1. The current sellToken balance is greater than sellSwapDetails.maxAmount - * 2. The post-swap sellToken balance is greater than or equal to sellSwapDetails.minAmount - * Skips validation for WETH since it does not have set swapDetails - */ + /// @dev Validates two conditions: + /// 1. The current sellToken balance is greater than sellSwapDetails.maxAmount + /// 2. The post-swap sellToken balance is greater than or equal to sellSwapDetails.minAmount + /// Skips validation for WETH since it does not have set swapDetails function validateSellTokenBalance(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { uint sellTokenBalance = swapOp.order.sellToken.balanceOf(address(pool)); @@ -209,12 +189,10 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Validates two conditions: - * 1. The current buyToken balance is less than buySwapDetails.minAmount. - * 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount. - * Skip validation for WETH since it does not have set swapDetails - */ + /// @dev Validates two conditions: + /// 1. The current buyToken balance is less than buySwapDetails.minAmount. + /// 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount. + /// Skip validation for WETH since it does not have set swapDetails function validateBuyTokenBalance(IPool pool, SwapOperation memory swapOp) internal view { uint buyTokenBalance = swapOp.order.buyToken.balanceOf(address(pool)); @@ -233,9 +211,7 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Helper function to determine the SwapOperationType of the order - */ + /// @dev Helper function to determine the SwapOperationType of the order function getSwapOperationType(GPv2Order.Data memory order) internal view returns (SwapOperationType) { if (address(order.sellToken) == address(weth)) { return SwapOperationType.WethToAsset; @@ -246,9 +222,7 @@ contract SwapOperator is ISwapOperator { return SwapOperationType.AssetToAsset; } - /** - * @dev NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) - */ + /// @dev NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) function prepareSwapDetails(IPool pool, GPv2Order.Data calldata order) internal view returns (SwapOperation memory) { SwapDetails memory sellSwapDetails = pool.getAssetSwapDetails(address(order.sellToken)); SwapDetails memory buySwapDetails = pool.getAssetSwapDetails(address(order.buyToken)); @@ -261,9 +235,7 @@ contract SwapOperator is ISwapOperator { }); } - /** - * @dev Performs pre-swap validation checks for a given swap operation - */ + /// @dev Performs pre-swap validation checks for a given swap operation function performPreSwapValidations(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { address sellTokenAddress = address(swapOp.order.sellToken); address buyTokenAddress = address(swapOp.order.buyToken); @@ -290,10 +262,8 @@ contract SwapOperator is ISwapOperator { validateQuotedAmount(swapOp.order); } - /** - * @dev Executes asset transfers from Pool to SwapOperator for CoW Swap order executions - * Additionally if selling ETH, wraps received Pool ETH to WETH - */ + /// @dev Executes asset transfers from Pool to SwapOperator for CoW Swap order executions + /// Additionally if selling ETH, wraps received Pool ETH to WETH function executeAssetTransfer(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal returns (uint swapValueEth) { IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); address sellTokenAddress = address(swapOp.order.sellToken); @@ -327,11 +297,11 @@ contract SwapOperator is ISwapOperator { return swapValueEth; } - /** - * @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract - * The order is validated before the sellToken is transferred from the Pool to the SwapOperator for the CoW swap operation - * Only one order can be open at the same time - */ + /// @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract + /// Validates the order before the sellToken is transferred from the Pool to the SwapOperator for the CoW swap operation + /// Emits OrderPlaced event on success. Only one order can be open at the same time + /// @param order - The order to be placed + /// @param orderUID - the UID of the of the order to be placed function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) public onlyController { if (orderInProgress()) { revert OrderInProgress(); @@ -369,10 +339,9 @@ contract SwapOperator is ISwapOperator { emit OrderPlaced(order); } - /** - * @dev Close a previously placed order, returning assets to the pool (either fulfilled or not) - * @param order The order to close - */ + /// @dev Close a previously placed order, returning assets to the pool (either fulfilled or not) + /// Emits OrderClosed event on success + /// @param order The order to close function closeOrder(GPv2Order.Data calldata order) external { // Validate there is an order in place require(orderInProgress(), "SwapOp: No order in place"); @@ -405,10 +374,8 @@ contract SwapOperator is ISwapOperator { emit OrderClosed(order, filledAmount); } - /** - * @dev Return a given asset to the pool, either ETH or ERC20 - * @param asset The asset - */ + /// @dev Return a given asset to the pool, either ETH or ERC20 + /// @param asset The asset function returnAssetToPool(IERC20 asset) internal { uint balance = asset.balanceOf(address(this)); @@ -429,10 +396,8 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev General validations on individual order fields - * @param order The order - */ + /// @dev General validations on individual order fields + /// @param order The order function validateBasicCowParams(GPv2Order.Data calldata order) internal view { uint minValidTo = block.timestamp + MIN_VALID_TO_PERIOD; uint maxValidTo = block.timestamp + MAX_VALID_TO_PERIOD; @@ -454,11 +419,9 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Validate that a given UID is the correct one for a given order - * @param order The order - * @param providedOrderUID The UID - */ + /// @dev Validate that a given UID is the correct one for a given order + /// @param order The order + /// @param providedOrderUID The UID function validateUID(GPv2Order.Data calldata order, bytes memory providedOrderUID) internal view { bytes memory calculatedOrderUID = getUID(order); if (keccak256(calculatedOrderUID) != keccak256(providedOrderUID)) { @@ -466,19 +429,14 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Get the Pool's instance through master contract - * @return The pool instance - */ + /// @dev Get the Pool's instance through master contract + /// @return The pool instance function _pool() internal view returns (IPool) { return IPool(master.getLatestAddress("P1")); } - /** - * @dev Validates that a given asset is not swapped too fast - * @param swapDetails Swap details for the given asset - */ - // TOOD: unit test for ETH SwapDetails(0,0,0,0) - should not error + /// @dev Validates that a given asset is not swapped too fast + /// @param swapDetails Swap details for the given asset function validateSwapFrequency(SwapDetails memory swapDetails) internal view { uint minValidSwapTime = swapDetails.lastSwapTime + MIN_TIME_BETWEEN_ORDERS; if (block.timestamp < minValidSwapTime) { @@ -486,14 +444,9 @@ contract SwapOperator is ISwapOperator { } } - /** - * @dev Validate that the fee for the order is not higher than the maximum allowed fee, in ether - * @param sellAsset The sell asset - * @param feeAmount The fee (will always be denominated in the sell asset units) - */ - // TODO: unit test with WETH / ETH / asset address - // what if there is no WETH in oracle - // what if there is no asset in oracle? - error + /// @dev Validate that the fee for the order is not higher than the maximum allowed fee, in ether + /// @param sellAsset The sell asset + /// @param feeAmount The fee (will always be denominated in the sell asset units) function validateMaxFee( address sellAsset, uint feeAmount @@ -506,7 +459,9 @@ contract SwapOperator is ISwapOperator { } } - + /// @dev Exchanges ETH for Enzyme Vault shares with slippage control. Emits `Swapped` on success + /// @param amountIn Amount of ETH to be swapped for Enzyme Vault shares + /// @param amountOutMin Minimum Enzyme Vault shares out expected function swapETHForEnzymeVaultShare(uint amountIn, uint amountOutMin) external onlyController { // Validate there's no current cow swap order going on @@ -565,6 +520,9 @@ contract SwapOperator is ISwapOperator { emit Swapped(ETH, enzymeV4VaultProxyAddress, amountIn, amountOut); } + /// @dev Exchanges Enzyme Vault shares for ETH with slippage control. Emits `Swapped` on success + /// @param amountIn Amount of Enzyme Vault shares to be swapped for ETH + /// @param amountOutMin Minimum ETH out expected function swapEnzymeVaultShareForETH( uint amountIn, uint amountOutMin @@ -634,8 +592,7 @@ contract SwapOperator is ISwapOperator { function transferAssetTo (address asset, address to, uint amount) internal { - if (asset == ETH) { - (bool ok, /* data */) = to.call{ value: amount }(""); + if (asset == ETH) {) = to.call{ value: amount }(""); require(ok, "SwapOp: Eth transfer failed"); return; } @@ -644,6 +601,9 @@ contract SwapOperator is ISwapOperator { token.safeTransfer(to, amount); } + /// @dev Recovers assets in the SwapOperator to the pool or a specified receiver, ensuring no ongoing CoW swap orders + /// @param assetAddress Address of the asset to recover + /// @param receiver Address to receive the recovered assets, if asset is not supported by the pool function recoverAsset(address assetAddress, address receiver) public onlyController { // Validate there's no current cow swap order going on @@ -678,6 +638,8 @@ contract SwapOperator is ISwapOperator { asset.transfer(address(pool), balance); } + /// @dev Checks if there is an ongoing order. + /// @return bool True if an order is currently in progress, otherwise false. function orderInProgress() public view returns (bool) { return currentOrderUID.length > 0; } From 84a4fc881564636bdd43d3814af41990923dcf1d Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 29 Feb 2024 16:19:23 +0200 Subject: [PATCH 36/88] Update ISwapOperator with mutative functions --- contracts/interfaces/ISwapOperator.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index fe25afcda4..76c07427cf 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -31,6 +31,12 @@ interface ISwapOperator { function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) external; + function closeOrder(GPv2Order.Data calldata order) external; + + function swapEnzymeVaultShareForETH(uint amountIn, uint amountOutMin) external; + + function swapETHForEnzymeVaultShare(uint amountIn, uint amountOutMin) external; + function recoverAsset(address assetAddress, address receiver) external; /* ========== EVENTS AND ERRORS ========== */ @@ -57,7 +63,7 @@ interface ISwapOperator { error EthReserveBelowMin(uint ethPostSwap, uint minEthReserve); error InvalidBalance(uint tokenBalance, uint limit, string limitType); error InvalidPostSwapBalance(uint postSwapBalance, uint limit, string limitType); - error MaxSlippageExceeded(uint minAmount); + error AmountTooLow(uint quotedAmount, uint minAmount); // Fee error AboveMaxFee(uint maxFee); From bcd63a87bf2474a0c18f4f2d69199ac980e74657 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 29 Feb 2024 16:20:41 +0200 Subject: [PATCH 37/88] Refactor add validateOrderAmount / verifySlippage --- contracts/modules/capital/SwapOperator.sol | 81 ++++----- test/unit/SwapOperator/placeOrder.js | 186 +++++++++------------ 2 files changed, 118 insertions(+), 149 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 2f69c376e3..b3a0c0132b 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -96,18 +96,6 @@ contract SwapOperator is ISwapOperator { return uid; } - /// @dev Validates that the quoted amount does not exceed minimum acceptable amount after accounting for max slippage - function validateSlippageAndOracleAmount(address quotedAsset, uint quotedAmount, uint oracleAmount) internal view { - SwapDetails memory swapDetails = _pool().getAssetSwapDetails(quotedAsset); - - // Calculate slippage and minimum amount we should accept - uint maxSlippageAmount = (oracleAmount * swapDetails.maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; - uint minBuyAmountOnMaxSlippage = oracleAmount - maxSlippageAmount; - if (quotedAmount < minBuyAmountOnMaxSlippage) { - revert MaxSlippageExceeded(minBuyAmountOnMaxSlippage); - } - } - /// @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAssetAmount` in `fromAsset` /// Supports conversions for ETH to Asset, Asset to ETH, and Asset to Asset function getOracleAmount(address toAsset, address fromAsset, uint fromAssetAmount) internal view returns (uint) { @@ -126,28 +114,40 @@ contract SwapOperator is ISwapOperator { return priceFeedOracle.getAssetForEth(toAsset, fromAssetInEth); } - /// @dev Validates the quoteAmount with the oracle price and max slippage tolerances - /// If KIND_SELL validates quoted buyAmount - /// If KIND_BUY validates quoted sellAmount - function validateQuotedAmount(GPv2Order.Data memory order) internal view { - if (order.kind == GPv2Order.KIND_SELL) { - // KIND_SELL - buyToken is quoted / sellToken is inputted - uint quotedAmount = order.buyAmount; - address quotedAsset = address(order.buyToken); - uint inputAssetAmount = order.sellAmount; - address inputAsset = address(order.sellToken); - - uint oracleAmount = getOracleAmount(quotedAsset, inputAsset, inputAssetAmount); - validateSlippageAndOracleAmount(quotedAsset, quotedAmount, oracleAmount); - } else { // GPv2Order.KIND_BUY - // KIND_BUY - sellToken is quoted / buyToken is inputted - uint quotedAmount = order.sellAmount; - address quotedAsset = address(order.sellToken); - uint inputAssetAmount = order.buyAmount; - address inputAsset = address(order.buyToken); - - uint oracleAmount = getOracleAmount(quotedAsset, inputAsset, inputAssetAmount); - validateSlippageAndOracleAmount(quotedAsset, quotedAmount, oracleAmount); + /// @dev Validates the amount against the oracle price adjusted with max slippage tolerances + function verifySlippage(uint amount, uint oracleAmount, uint16 maxSlippageRatio) internal pure { + // Calculate slippage and minimum amount we should accept + uint maxSlippageAmount = (oracleAmount * maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; + uint minBuyAmountOnMaxSlippage = oracleAmount - maxSlippageAmount; + if (amount < minBuyAmountOnMaxSlippage) { + revert AmountTooLow(amount, minBuyAmountOnMaxSlippage); + } + } + + /// @dev Validates order amounts against oracle prices and slippage limits. + /// Uses the higher maxSlippageRatio from sell/buySwapDetails, then checks if the swap amount meets the minimum after slippage. + function validateOrderAmount(SwapOperation memory swapOp) internal view { + // Determine the higher maxSlippageRatio + if (swapOp.sellSwapDetails.maxSlippageRatio > swapOp.buySwapDetails.maxSlippageRatio) { + // verify sellAmount because sellToken maxSlippageRatio is higher + address fromAsset = address(swapOp.order.buyToken); + address toAsset = address(swapOp.order.sellToken); + uint fromAmount = swapOp.order.buyAmount; + uint amountToCheck = swapOp.order.sellAmount; + uint16 higherMaxSlippageRatio = swapOp.sellSwapDetails.maxSlippageRatio; + + uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); + verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); + } else { + // verify buyAmount because buyToken maxSlippageRatio is higher + address fromAsset = address(swapOp.order.sellToken); + address toAsset = address(swapOp.order.buyToken); + uint fromAmount = swapOp.order.sellAmount; + uint amountToCheck = swapOp.order.buyAmount; + uint16 higherMaxSlippageRatio = swapOp.buySwapDetails.maxSlippageRatio; + + uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); + verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); } } @@ -259,7 +259,7 @@ contract SwapOperator is ISwapOperator { // validate max fee and max slippage validateMaxFee(sellTokenAddress, swapOp.order.feeAmount); - validateQuotedAmount(swapOp.order); + validateOrderAmount(swapOp); } /// @dev Executes asset transfers from Pool to SwapOperator for CoW Swap order executions @@ -445,15 +445,15 @@ contract SwapOperator is ISwapOperator { } /// @dev Validate that the fee for the order is not higher than the maximum allowed fee, in ether - /// @param sellAsset The sell asset + /// @param sellToken The sell asset /// @param feeAmount The fee (will always be denominated in the sell asset units) function validateMaxFee( - address sellAsset, + address sellToken, uint feeAmount ) internal view { - uint feeInEther = sellAsset == address(weth) + uint feeInEther = sellToken == address(weth) ? feeAmount - : _pool().priceFeedOracle().getEthForAsset(sellAsset, feeAmount); + : _pool().priceFeedOracle().getEthForAsset(sellToken, feeAmount); if (feeInEther > maxFee) { revert AboveMaxFee(maxFee); } @@ -592,7 +592,8 @@ contract SwapOperator is ISwapOperator { function transferAssetTo (address asset, address to, uint amount) internal { - if (asset == ETH) {) = to.call{ value: amount }(""); + if (asset == ETH) { + (bool ok, /* data */) = to.call{ value: amount }(""); require(ok, "SwapOp: Eth transfer failed"); return; } diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index ae877d9412..1955de877c 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -658,195 +658,163 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when selling eth validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { + it('when selling eth validates buyAmount against oracle price if both assets has 0% slippage', async function () { const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); - const { swapOperator, priceFeedOracle, dai } = contracts; + const { swapOperator, priceFeedOracle, pool, dai, weth } = contracts; - // order kind SELL, buyAmount is quoted - const daiBuyAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); + const wethSellSwapDetails = await pool.getAssetSwapDetails(weth.address); + const daiBuySwapDetails = await pool.getAssetSwapDetails(dai.address); + expect(wethSellSwapDetails.maxSlippageRatio === daiBuySwapDetails.maxSlippageRatio).to.equal(true); - // Since quoted buyAmount is short by 1 DAI wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount.sub(1) }); + // Since buyAmount is short by 1 DAI wei, txn should revert + const buyOracleAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); + const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiBuyAmount); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(buyOracleAmount.sub(1), buyOracleAmount); // Oracle price buyAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount }); + const goodOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when selling eth validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { - const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); - const { swapOperator, priceFeedOracle, dai } = contracts; - - // order kind BUY, sellAmount is quoted - const ethSellAmount = await priceFeedOracle.getEthForAsset(dai.address, order.buyAmount); - - // Since quoted sellAmount is short by 1 DAI wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: ethSellAmount.sub(1) }); - const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(ethSellAmount); - - // Oracle price sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: ethSellAmount }); - await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); - }); - - it('when selling eth validates quotedAmount against oracle price & 1% slippage (orderKind SELL)', async function () { + it('when selling eth validates buyAmount against oracle price if buyToken has a higher slippage', async function () { const { contracts, governance, order, domain } = await loadFixture(placeSellWethOrderSetup); - const { swapOperator, pool, dai } = contracts; + const { swapOperator, pool, dai, weth } = contracts; - // order kind SELL, buyAmount is quoted const buyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); + const wethSellSwapDetails = await pool.getAssetSwapDetails(weth.address); + const daiBuySwapDetails = await pool.getAssetSwapDetails(dai.address); + expect(daiBuySwapDetails.maxSlippageRatio > wethSellSwapDetails.maxSlippageRatio).to.equal(true); + // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert - const badOrderOverrides = { kind: 'sell', buyAmount: buyAmountOnePercentSlippage.sub(1) }; + const badOrderOverrides = { buyAmount: buyAmountOnePercentSlippage.sub(1) }; const badOrder = createContractOrder(domain, order, badOrderOverrides); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') - .withArgs(buyAmountOnePercentSlippage); // 1% max slippage from oracle buy amount + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(buyAmountOnePercentSlippage.sub(1), buyAmountOnePercentSlippage); // 1% slippage from oracle buyAmount // Exactly 1% slippage buyAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: buyAmountOnePercentSlippage }); + const goodOrder = createContractOrder(domain, order, { buyAmount: buyAmountOnePercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - // ETH has no set swapDetails slippage, so no test for selling eth order kind BUY + // ETH has no set swapDetails slippage, so no test for sell eth which has higher slippage - it('when buying eth validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { + it('when buying eth validates buyAmount against oracle price if both assets has 0% slippage', async function () { const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); - const { dai, swapOperator, priceFeedOracle } = contracts; + const { swapOperator, priceFeedOracle, pool, dai, weth } = contracts; - // order kind SELL, buyAmount is quoted - const ethBuyAmount = await priceFeedOracle.getEthForAsset(dai.address, order.sellAmount); + const daiSellSwapDetails = await pool.getAssetSwapDetails(dai.address); + const wethBuySwapDetails = await pool.getAssetSwapDetails(weth.address); + expect(wethBuySwapDetails.maxSlippageRatio === daiSellSwapDetails.maxSlippageRatio).to.equal(true); - // Since quoted buyAmount is short by 1 wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: ethBuyAmount.sub(1) }); + // Since buyAmount is short by 1 wei, txn should revert + const buyOracleAmount = await priceFeedOracle.getEthForAsset(dai.address, order.sellAmount); + const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(ethBuyAmount); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(buyOracleAmount.sub(1), buyOracleAmount); // Oracle price buyAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'sell' }); + const goodOrder = createContractOrder(domain, order); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when buying eth validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { - const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); - const { dai, swapOperator, priceFeedOracle } = contracts; - - // order kind BUY, sellAmount is quoted - const daiSellAmount = await priceFeedOracle.getAssetForEth(dai.address, order.buyAmount); - - // Since quoted sellAmount is short by 1 wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: daiSellAmount.sub(1) }); - const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiSellAmount); - - // Oracle price sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'buy' }); - await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); - }); - - it('when buying eth validates quotedAmount against oracle price & 1% slippage (order kind BUY)', async function () { + it('when buying eth validates sellAmount against oracle price if sellToken has a higher slippage', async function () { const { contracts, governance, order, domain } = await loadFixture(placeBuyWethOrderSetup); - const { swapOperator, pool, dai } = contracts; + const { swapOperator, pool, dai, weth } = contracts; - // order kind BUY, sellAmount is quoted const daiSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); - // Since quoted sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert - const orderOverrides = { kind: 'buy', sellAmount: daiSellAmountOnePercentSlippage.sub(1) }; - const badOrder = createContractOrder(domain, order, orderOverrides); + const daiSellSwapDetails = await pool.getAssetSwapDetails(dai.address); + const wethBuySwapDetails = await pool.getAssetSwapDetails(weth.address); + expect(daiSellSwapDetails.maxSlippageRatio > wethBuySwapDetails.maxSlippageRatio).to.equal(true); + + // Since sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const badOrder = createContractOrder(domain, order, { sellAmount: daiSellAmountOnePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') - .withArgs(daiSellAmountOnePercentSlippage); + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(daiSellAmountOnePercentSlippage.sub(1), daiSellAmountOnePercentSlippage); // Exactly 1% slippage sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: daiSellAmountOnePercentSlippage }); + const goodOrder = createContractOrder(domain, order, { sellAmount: daiSellAmountOnePercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - // ETH has no set swapDetails slippage, so no test for buying eth order kind SELL - - it('non-ETH swap, validates quotedAmount against oracle price & 0% slippage (order kind SELL)', async function () { - const { contracts, order, domain } = await loadFixture(placeNonEthOrderSetup); - const { swapOperator, priceFeedOracle, dai } = contracts; - - // order kind SELL, buyAmount is quoted (use eth rate as proxy since 1 stETH : 1 ETH) - const daiBuyAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); - - // Since quoted buyAmount is short by 1 DAI wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount.sub(1) }); - const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded').withArgs(daiBuyAmount); - - // Oracle price buyAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'sell', buyAmount: daiBuyAmount }); - await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); - }); + // ETH has no set swapDetails slippage, so no test for buy eth which has higher slippage - it('non-ETH swaps, validates quotedAmount against oracle price & 0% slippage (order kind BUY)', async function () { + it('non-ETH swap, validates buyAmount against oracle price if both assets has 0% slippage', async function () { const { contracts, order, domain } = await loadFixture(placeNonEthOrderSetup); - const { swapOperator, priceFeedOracle, dai } = contracts; + const { swapOperator, priceFeedOracle, pool, dai, stEth } = contracts; - // order kind BUY, sellAmount is quoted (use eth rate as proxy since 1 stETH : 1 ETH) - const stEthSellAmount = await priceFeedOracle.getEthForAsset(dai.address, order.buyAmount); + const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); + const daiSwapDetails = await pool.getAssetSwapDetails(dai.address); + expect(stEthSellSwapDetails.maxSlippageRatio === daiSwapDetails.maxSlippageRatio).to.equal(true); - // Since sellAmount is short by 1 wei, txn should revert - const badOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: stEthSellAmount.sub(1) }); + // Since buyAmount is short by 1 DAI wei, txn should revert + const buyOracleAmount = await priceFeedOracle.getAssetForEth(dai.address, order.sellAmount); + const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') - .withArgs(stEthSellAmount); + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(buyOracleAmount.sub(1), buyOracleAmount); - // Oracle price sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { kind: 'buy', sellAmount: stEthSellAmount }); + // Oracle price buyAmount should not revert + const goodOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('non-ETH swap, validates quotedAmount against oracle price & 1% slippage (order kind SELL)', async function () { + it('non-ETH swap, validates buyAmount with oracle price & slippage if buyToken has > slippage', async function () { const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); - const { swapOperator, pool, dai } = contracts; + const { swapOperator, pool, dai, stEth } = contracts; - // order kind SELL, buyAmount is quoted const daiBuyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); + const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); + const daiBuySwapDetails = await pool.getAssetSwapDetails(dai.address); + expect(daiBuySwapDetails.maxSlippageRatio > stEthSellSwapDetails.maxSlippageRatio).to.equal(true); + // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert const badOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') - .withArgs(daiBuyAmountOnePercentSlippage); // 1% max slippage from oracle buy amount + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(daiBuyAmountOnePercentSlippage.sub(1), daiBuyAmountOnePercentSlippage); // Exactly 1% slippage buyAmount should not revert const goodOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('non-ETH swap, validates quotedAmount against oracle price & 1% slippage (order kind BUY)', async function () { + it('non-ETH swap, validates sellAmount with oracle price & slippage if sellToken has > slippage', async function () { const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); - const { swapOperator, pool, stEth } = contracts; + const { swapOperator, pool, dai, stEth } = contracts; - // order kind BUY, sellAmount is quoted const stEthSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 100); + const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); + const daiBuySwapDetails = await pool.getAssetSwapDetails(dai.address); + expect(stEthSellSwapDetails.maxSlippageRatio > daiBuySwapDetails.maxSlippageRatio).to.equal(true); + // Since sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert - const badOrderOverride = { kind: 'buy', sellAmount: stEthSellAmountOnePercentSlippage.sub(1) }; - const badOrder = createContractOrder(domain, order, badOrderOverride); + const badOrder = createContractOrder(domain, order, { sellAmount: stEthSellAmountOnePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'MaxSlippageExceeded') - .withArgs(stEthSellAmountOnePercentSlippage); // 1% max slippage from oracle buy amount + .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .withArgs(stEthSellAmountOnePercentSlippage.sub(1), stEthSellAmountOnePercentSlippage); // Exactly 1% slippage sellAmount should not revert - const goodOrderOverride = { kind: 'buy', sellAmount: stEthSellAmountOnePercentSlippage }; - const goodOrder = createContractOrder(domain, order, goodOrderOverride); + const goodOrder = createContractOrder(domain, order, { sellAmount: stEthSellAmountOnePercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); From 1143293761e41d990902f74daeab232b79a7b5ad Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 29 Feb 2024 16:22:43 +0200 Subject: [PATCH 38/88] Fix closeOrder tests * the replacement of presignature(false) with invalidateOrder(UID) broke some tests --- .../mocks/SwapOperator/SOMockSettlement.sol | 4 +++ test/unit/SwapOperator/closeOrder.js | 28 ++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/contracts/mocks/SwapOperator/SOMockSettlement.sol b/contracts/mocks/SwapOperator/SOMockSettlement.sol index 956792afa5..09f512b909 100644 --- a/contracts/mocks/SwapOperator/SOMockSettlement.sol +++ b/contracts/mocks/SwapOperator/SOMockSettlement.sol @@ -39,4 +39,8 @@ contract SOMockSettlement { vaultRelayer.transfer(order.sellToken, order.receiver, address(vaultRelayer), sellAmount + feeAmount); vaultRelayer.transfer(order.buyToken, address(vaultRelayer), order.receiver, buyAmount); } + + function invalidateOrder(bytes calldata orderUid) external { + filledAmount[orderUid] = type(uint256).max; + } } diff --git a/test/unit/SwapOperator/closeOrder.js b/test/unit/SwapOperator/closeOrder.js index f914a676cf..9450d588bf 100644 --- a/test/unit/SwapOperator/closeOrder.js +++ b/test/unit/SwapOperator/closeOrder.js @@ -23,6 +23,7 @@ const setup = require('./setup'); const { utils: { parseEther, hexZeroPad }, + constants: { MaxUint256 }, } = ethers; async function closeOrderSetup() { @@ -189,32 +190,33 @@ describe('closeOrder', function () { await expect(swapOperator.closeOrder(contractOrder)).to.be.revertedWith('SwapOp: No order in place'); }); - it('presignature is false and allowance is 0 when order was not filled at all', async function () { + it('invalidates order and sets allowance back to 0 when order was not filled at all', async function () { const { contracts: { swapOperator, cowSettlement, weth, cowVaultRelayer }, contractOrder, orderUID, order, } = await loadFixture(closeOrderSetup); - expect(await cowSettlement.presignatures(orderUID)).to.equal(true); + expect(await cowSettlement.filledAmount(orderUID)).to.equal(0); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.add(order.feeAmount), ); await swapOperator.closeOrder(contractOrder); - expect(await cowSettlement.presignatures(orderUID)).to.equal(false); + // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); - it('cancels presignature and allowance when the order is partially filled', async function () { + it('invalidates order and sets allowance back to 0 when the order is partially filled', async function () { const { contracts: { swapOperator, cowSettlement, weth, dai, cowVaultRelayer }, contractOrder, orderUID, order, } = await loadFixture(closeOrderSetup); - // intially there is some sellToken, no buyToken + // initially there is some sellToken, no buyToken expect(await dai.balanceOf(swapOperator.address)).to.eq(0); expect(await weth.balanceOf(swapOperator.address)).to.gt(0); @@ -231,27 +233,27 @@ describe('closeOrder', function () { expect(await dai.balanceOf(swapOperator.address)).to.gt(0); expect(await weth.balanceOf(swapOperator.address)).to.gt(0); - // presignature still valid, allowance was decreased - expect(await cowSettlement.presignatures(orderUID)).to.equal(true); + // fill amount still partially filled, allowance was decreased + expect(await cowSettlement.filledAmount(orderUID)).to.equal(order.sellAmount.div(2)); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.div(2).add(order.feeAmount.div(2)), ); await swapOperator.closeOrder(contractOrder); - // after closing, presignature = false and allowance = 0 - expect(await cowSettlement.presignatures(orderUID)).to.equal(false); + // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); - it('cancels presignature and allowance when the order is fully filled', async function () { + it('invalidates order and sets allowance back to 0 when the order is fully filled', async function () { const { contracts: { swapOperator, weth, dai, cowSettlement, cowVaultRelayer }, contractOrder, orderUID, order, } = await loadFixture(closeOrderSetup); - expect(await cowSettlement.presignatures(orderUID)).to.equal(true); + expect(await cowSettlement.filledAmount(orderUID)).to.equal(0); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.add(order.feeAmount), ); @@ -267,8 +269,8 @@ describe('closeOrder', function () { await swapOperator.closeOrder(contractOrder); - // After closing, presignature should be false - expect(await cowSettlement.presignatures(orderUID)).to.equal(false); + // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); From 1eeae5ac13737d7d953b3d4ca810bec118d6ae91 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 1 Mar 2024 13:20:05 +0200 Subject: [PATCH 39/88] Update SwapOperator custom errors + fix tests --- contracts/interfaces/ISwapOperator.sol | 15 ++++++++------- contracts/modules/capital/SwapOperator.sol | 10 +++++----- test/unit/SwapOperator/placeOrder.js | 13 +++++++++---- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 76c07427cf..6370f369f2 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -6,6 +6,7 @@ import "../external/cow/GPv2Order.sol"; import "../interfaces/IPool.sol"; interface ISwapOperator { + enum SwapOperationType { WethToAsset, AssetToWeth, @@ -46,25 +47,25 @@ interface ISwapOperator { event Swapped(address indexed fromAsset, address indexed toAsset, uint amountIn, uint amountOut); // Order - error OrderInProgress(); + error OrderInProgress(bytes currentOrderUID); error OrderUidMismatch(bytes providedOrderUID, bytes expectedOrderUID); error UnsupportedTokenBalance(string kind); - error InvalidReceiver(); + error InvalidReceiver(address validReceiver); error OrderTokenIsDisabled(address token); + error AmountTooLow(uint amount, uint minAmount); // Valid To error BelowMinValidTo(uint minValidTo); error AboveMaxValidTo(uint maxValidTo); - // Cool down - error InsufficientTimeBetweenSwaps(uint minValidSwapTime); - // Balance error EthReserveBelowMin(uint ethPostSwap, uint minEthReserve); error InvalidBalance(uint tokenBalance, uint limit, string limitType); error InvalidPostSwapBalance(uint postSwapBalance, uint limit, string limitType); - error AmountTooLow(uint quotedAmount, uint minAmount); + + // Cool down + error InsufficientTimeBetweenSwaps(uint minValidSwapTime); // Fee - error AboveMaxFee(uint maxFee); + error AboveMaxFee(uint feeInEth, uint maxFee); } diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index b3a0c0132b..d35928f597 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -304,7 +304,7 @@ contract SwapOperator is ISwapOperator { /// @param orderUID - the UID of the of the order to be placed function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) public onlyController { if (orderInProgress()) { - revert OrderInProgress(); + revert OrderInProgress(currentOrderUID); } // Order UID and basic CoW params validations @@ -409,7 +409,7 @@ contract SwapOperator is ISwapOperator { revert AboveMaxValidTo(maxValidTo); } if (order.receiver != address(this)) { - revert InvalidReceiver(); + revert InvalidReceiver(address(this)); } if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20) { revert UnsupportedTokenBalance('sell'); @@ -455,7 +455,7 @@ contract SwapOperator is ISwapOperator { ? feeAmount : _pool().priceFeedOracle().getEthForAsset(sellToken, feeAmount); if (feeInEther > maxFee) { - revert AboveMaxFee(maxFee); + revert AboveMaxFee(feeInEther, maxFee); } } @@ -639,8 +639,8 @@ contract SwapOperator is ISwapOperator { asset.transfer(address(pool), balance); } - /// @dev Checks if there is an ongoing order. - /// @return bool True if an order is currently in progress, otherwise false. + /// @dev Checks if there is an ongoing order + /// @return bool True if an order is currently in progress, otherwise false function orderInProgress() public view returns (bool) { return currentOrderUID.length > 0; } diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index 1955de877c..c296c235cd 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -179,7 +179,7 @@ describe('placeOrder', function () { // calling with valid data should fail second time, because first order is still there const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OrderInProgress'); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OrderInProgress').withArgs(orderUID); }); it('validates only erc20 is supported for sellTokenBalance', async function () { @@ -206,7 +206,9 @@ describe('placeOrder', function () { const { contractOrder, orderUID } = createContractOrder(domain, order, { receiver: governance.address }); const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'InvalidReceiver'); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'InvalidReceiver') + .withArgs(swapOperator.address); }); it('validates that order.validTo is at least 10 minutes in the future', async function () { @@ -634,7 +636,9 @@ describe('placeOrder', function () { // Place order with fee 1 wei higher than maximum, should fail const badOrder = createContractOrder(domain, order, { feeAmount: maxFee.add(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee').withArgs(maxFee); + await expect(placeOrder) + .to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee') + .withArgs(badOrder.contractOrder.feeAmount, maxFee); // Place order with exactly maxFee, should succeed const goodOrder = createContractOrder(domain, order, { feeAmount: maxFee }); @@ -650,8 +654,9 @@ describe('placeOrder', function () { // Place order with fee 1 wei higher than maximum, should fail const badOrder = createContractOrder(domain, order, { feeAmount: maxFee.add(1).mul(ethToDaiRate) }); + const feeInEth = await priceFeedOracle.getEthForAsset(order.sellToken, badOrder.contractOrder.feeAmount); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee').withArgs(maxFee); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'AboveMaxFee').withArgs(feeInEth, maxFee); // Place order with exactly maxFee, should succeed const goodOrder = createContractOrder(domain, order, { feeAmount: maxFee.mul(ethToDaiRate) }); From 2cf05f0338e86f03c457951d4eca93d7aa11171c Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 1 Mar 2024 15:30:09 +0200 Subject: [PATCH 40/88] Remove own PR template in favour of PR community health file --- .github/pull_request_template.md | 38 -------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 730c8d3f67..0000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,38 +0,0 @@ -## Context - -Include relevant motivation and context. A link to the issue is enough if it contains all the relevant -information. - - -## Changes proposed in this pull request - -Include a summary of the change. - - -## Test plan - -Please describe the tests cases that you ran to verify your changes. Add further instructions on -how to run them if needed (i.e. migration / deployment scripts, env vars, etc). - - -## Checklist - -- [ ] Rebased the base branch -- [ ] Attached corresponding Github issue -- [ ] Prefixed the name with the type of change (i.e. feat, chore, test) -- [ ] Performed a self-review of my own code -- [ ] Followed the style guidelines of this project -- [ ] Made corresponding changes to the documentation -- [ ] Didn't generate new warnings -- [ ] Didn't generate failures on existing tests -- [ ] Added tests that prove my fix is effective or that my feature works - - -## Review - -When reviewing a PR, please indicate intention in comments using the following emojis: -* :cake: = Nice to have but not essential. -* :bulb: = Suggestion or a comment based on personal opinion -* :hammer: = I believe this should be changed. -* :thinking: = I don’t understand something, do you mind giving me more context? -* :rocket: = Feedback From 1bc2f0060ddeac39f9b90310807a7827ede0ddd3 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 14:03:14 +0200 Subject: [PATCH 41/88] Update function annotations and comments --- contracts/modules/capital/SwapOperator.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index d35928f597..818bddbf6e 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -300,6 +300,7 @@ contract SwapOperator is ISwapOperator { /// @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract /// Validates the order before the sellToken is transferred from the Pool to the SwapOperator for the CoW swap operation /// Emits OrderPlaced event on success. Only one order can be open at the same time + /// NOTE: ETH orders are expected to have a WETH address because ETH will be eventually converted to WETH to do the swap /// @param order - The order to be placed /// @param orderUID - the UID of the of the order to be placed function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) public onlyController { @@ -356,7 +357,7 @@ contract SwapOperator is ISwapOperator { // Check how much of the order was filled, and if it was fully filled uint filledAmount = cowSettlement.filledAmount(currentOrderUID); - // Cancel signature and unapprove tokens + // Cancel order and unapprove tokens cowSettlement.invalidateOrder(currentOrderUID); order.sellToken.safeApprove(cowVaultRelayer, 0); From 71ec781d4c2c0a5243794263bcb39ba7e25994f4 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 16:47:59 +0200 Subject: [PATCH 42/88] Drop SwaOperation struct and rename SwapOperationType enum values --- contracts/interfaces/ISwapOperator.sol | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 6370f369f2..d7002a5e58 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -8,18 +8,11 @@ import "../interfaces/IPool.sol"; interface ISwapOperator { enum SwapOperationType { - WethToAsset, - AssetToWeth, + EthToAsset, + AssetToEth, AssetToAsset } - struct SwapOperation { - GPv2Order.Data order; - SwapDetails sellSwapDetails; - SwapDetails buySwapDetails; - SwapOperationType swapType; - } - /* ========== VIEWS ========== */ function getDigest(GPv2Order.Data calldata order) external view returns (bytes32); From 3ce21ab0cc2ebd303d8caa981a39cbcf680a30ed Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 16:49:03 +0200 Subject: [PATCH 43/88] Drop prepareSwapDetails method and fix methods --- contracts/modules/capital/SwapOperator.sol | 183 +++++++++++---------- 1 file changed, 97 insertions(+), 86 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 818bddbf6e..7c03e9fe79 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -126,25 +126,29 @@ contract SwapOperator is ISwapOperator { /// @dev Validates order amounts against oracle prices and slippage limits. /// Uses the higher maxSlippageRatio from sell/buySwapDetails, then checks if the swap amount meets the minimum after slippage. - function validateOrderAmount(SwapOperation memory swapOp) internal view { + function validateOrderAmount( + GPv2Order.Data calldata order, + SwapDetails memory sellSwapDetails, + SwapDetails memory buySwapDetails + ) internal view { // Determine the higher maxSlippageRatio - if (swapOp.sellSwapDetails.maxSlippageRatio > swapOp.buySwapDetails.maxSlippageRatio) { + if (sellSwapDetails.maxSlippageRatio > buySwapDetails.maxSlippageRatio) { // verify sellAmount because sellToken maxSlippageRatio is higher - address fromAsset = address(swapOp.order.buyToken); - address toAsset = address(swapOp.order.sellToken); - uint fromAmount = swapOp.order.buyAmount; - uint amountToCheck = swapOp.order.sellAmount; - uint16 higherMaxSlippageRatio = swapOp.sellSwapDetails.maxSlippageRatio; + address fromAsset = address(order.buyToken); + address toAsset = address(order.sellToken); + uint fromAmount = order.buyAmount; + uint amountToCheck = order.sellAmount; + uint16 higherMaxSlippageRatio = sellSwapDetails.maxSlippageRatio; uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); } else { // verify buyAmount because buyToken maxSlippageRatio is higher - address fromAsset = address(swapOp.order.sellToken); - address toAsset = address(swapOp.order.buyToken); - uint fromAmount = swapOp.order.sellAmount; - uint amountToCheck = swapOp.order.buyAmount; - uint16 higherMaxSlippageRatio = swapOp.buySwapDetails.maxSlippageRatio; + address fromAsset = address(order.sellToken); + address toAsset = address(order.buyToken); + uint fromAmount = order.sellAmount; + uint amountToCheck = order.buyAmount; + uint16 higherMaxSlippageRatio = buySwapDetails.maxSlippageRatio; uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); @@ -171,21 +175,26 @@ contract SwapOperator is ISwapOperator { /// 1. The current sellToken balance is greater than sellSwapDetails.maxAmount /// 2. The post-swap sellToken balance is greater than or equal to sellSwapDetails.minAmount /// Skips validation for WETH since it does not have set swapDetails - function validateSellTokenBalance(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { - uint sellTokenBalance = swapOp.order.sellToken.balanceOf(address(pool)); + function validateSellTokenBalance( + IPool pool, + GPv2Order.Data calldata order, + SwapDetails memory sellSwapDetails, + uint totalOutAmount + ) internal view { + uint sellTokenBalance = order.sellToken.balanceOf(address(pool)); // skip validation for WETH since it does not have set swapDetails - if (address(swapOp.order.sellToken) == address(weth)) { + if (address(order.sellToken) == address(weth)) { return; } - if (sellTokenBalance <= swapOp.sellSwapDetails.maxAmount) { - revert InvalidBalance(sellTokenBalance, swapOp.sellSwapDetails.maxAmount, 'max'); + if (sellTokenBalance <= sellSwapDetails.maxAmount) { + revert InvalidBalance(sellTokenBalance, sellSwapDetails.maxAmount, 'max'); } // NOTE: the totalOutAmount (i.e. sellAmount + fee) is used to get postSellTokenSwapBalance uint postSellTokenSwapBalance = sellTokenBalance - totalOutAmount; - if (postSellTokenSwapBalance < swapOp.sellSwapDetails.minAmount) { - revert InvalidPostSwapBalance(postSellTokenSwapBalance, swapOp.sellSwapDetails.minAmount, 'min'); + if (postSellTokenSwapBalance < sellSwapDetails.minAmount) { + revert InvalidPostSwapBalance(postSellTokenSwapBalance, sellSwapDetails.minAmount, 'min'); } } @@ -193,105 +202,109 @@ contract SwapOperator is ISwapOperator { /// 1. The current buyToken balance is less than buySwapDetails.minAmount. /// 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount. /// Skip validation for WETH since it does not have set swapDetails - function validateBuyTokenBalance(IPool pool, SwapOperation memory swapOp) internal view { - uint buyTokenBalance = swapOp.order.buyToken.balanceOf(address(pool)); + function validateBuyTokenBalance( + IPool pool, + GPv2Order.Data calldata order, + SwapDetails memory buySwapDetails + ) internal view { + uint buyTokenBalance = order.buyToken.balanceOf(address(pool)); // skip validation for WETH since it does not have set swapDetails - if (address(swapOp.order.buyToken) == address(weth)) { + if (address(order.buyToken) == address(weth)) { return; } - if (buyTokenBalance >= swapOp.buySwapDetails.minAmount) { - revert InvalidBalance(buyTokenBalance, swapOp.buySwapDetails.minAmount, 'min'); + if (buyTokenBalance >= buySwapDetails.minAmount) { + revert InvalidBalance(buyTokenBalance, buySwapDetails.minAmount, 'min'); } // NOTE: use order.buyAmount to get postBuyTokenSwapBalance - uint postBuyTokenSwapBalance = buyTokenBalance + swapOp.order.buyAmount; - if (postBuyTokenSwapBalance > swapOp.buySwapDetails.maxAmount) { - revert InvalidPostSwapBalance(postBuyTokenSwapBalance, swapOp.buySwapDetails.maxAmount, 'max'); + uint postBuyTokenSwapBalance = buyTokenBalance + order.buyAmount; + if (postBuyTokenSwapBalance > buySwapDetails.maxAmount) { + revert InvalidPostSwapBalance(postBuyTokenSwapBalance, buySwapDetails.maxAmount, 'max'); } } /// @dev Helper function to determine the SwapOperationType of the order + /// NOTE: ETH orders has WETH address because ETH will be eventually converted to WETH to do the swap function getSwapOperationType(GPv2Order.Data memory order) internal view returns (SwapOperationType) { if (address(order.sellToken) == address(weth)) { - return SwapOperationType.WethToAsset; + return SwapOperationType.EthToAsset; } if (address(order.buyToken) == address(weth)) { - return SwapOperationType.AssetToWeth; + return SwapOperationType.AssetToEth; } return SwapOperationType.AssetToAsset; } - /// @dev NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) - function prepareSwapDetails(IPool pool, GPv2Order.Data calldata order) internal view returns (SwapOperation memory) { + /// @dev Performs pre-swap validation checks for a given swap operation + function performPreSwapValidations( + IPool pool, + GPv2Order.Data calldata order, + uint totalOutAmount, + SwapOperationType swapOperationType + ) internal view { + // NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) SwapDetails memory sellSwapDetails = pool.getAssetSwapDetails(address(order.sellToken)); SwapDetails memory buySwapDetails = pool.getAssetSwapDetails(address(order.buyToken)); - return SwapOperation({ - order: order, - sellSwapDetails: sellSwapDetails, - buySwapDetails: buySwapDetails, - swapType: getSwapOperationType(order) - }); - } - - /// @dev Performs pre-swap validation checks for a given swap operation - function performPreSwapValidations(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal view { - address sellTokenAddress = address(swapOp.order.sellToken); - address buyTokenAddress = address(swapOp.order.buyToken); - // validate both sell and buy tokens are enabled - validateTokenIsEnabled(sellTokenAddress, swapOp.sellSwapDetails); - validateTokenIsEnabled(buyTokenAddress, swapOp.buySwapDetails); + validateTokenIsEnabled(address(order.sellToken), sellSwapDetails); + validateTokenIsEnabled(address(order.buyToken), buySwapDetails); - // validate ETH balance is within ETH reserves after the swap - if (swapOp.swapType == SwapOperationType.WethToAsset) { + // sell ETH - validate ETH balance is within ETH reserves after the swap + if (swapOperationType == SwapOperationType.EthToAsset) { validateEthBalance(pool, totalOutAmount); } // validate sell/buy token balances against swapDetails min/max - validateSellTokenBalance(pool, swapOp, totalOutAmount); - validateBuyTokenBalance(pool, swapOp); + validateSellTokenBalance(pool, order, sellSwapDetails, totalOutAmount); + validateBuyTokenBalance(pool, order, buySwapDetails); // validate swap frequency to enforce cool down periods - validateSwapFrequency(swapOp.sellSwapDetails); - validateSwapFrequency(swapOp.buySwapDetails); + validateSwapFrequency(sellSwapDetails); + validateSwapFrequency(buySwapDetails); // validate max fee and max slippage - validateMaxFee(sellTokenAddress, swapOp.order.feeAmount); - validateOrderAmount(swapOp); + validateMaxFee(address(order.sellToken), order.feeAmount); + validateOrderAmount(order, sellSwapDetails, buySwapDetails); } /// @dev Executes asset transfers from Pool to SwapOperator for CoW Swap order executions /// Additionally if selling ETH, wraps received Pool ETH to WETH - function executeAssetTransfer(IPool pool, SwapOperation memory swapOp, uint totalOutAmount) internal returns (uint swapValueEth) { + function executeAssetTransfer( + IPool pool, + GPv2Order.Data calldata order, + uint totalOutAmount, + SwapOperationType swapOperationType + ) internal returns (uint swapValueEth) { IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); - address sellTokenAddress = address(swapOp.order.sellToken); - address buyTokenAddress = address(swapOp.order.buyToken); - - if (swapOp.swapType == SwapOperationType.WethToAsset) { - // set lastSwapTime of buyToken only (sellToken WETH has no set swapDetails) - pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); - // transfer ETH from pool and wrap it (use ETH address here because swapOp.sellToken is WETH address) - pool.transferAssetToSwapOperator(ETH, totalOutAmount); - weth.deposit{value: totalOutAmount}(); - // no need to convert since totalOutAmount is already in ETH (i.e. WETH) - swapValueEth = totalOutAmount; - } else if (swapOp.swapType == SwapOperationType.AssetToWeth) { - // set lastSwapTime of sellToken only (buyToken WETH has no set swapDetails) - pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); - // transfer ERC20 asset from Pool - pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); - // convert totalOutAmount (sellAmount + fee) to ETH - swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); - } else { // SwapOperationType.AssetToAsset - // set lastSwapTime of sell / buy tokens - pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); - pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); - // transfer ERC20 asset from Pool - pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); - // convert totalOutAmount (sellAmount + fee) to ETH - swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); + address sellTokenAddress = address(order.sellToken); + address buyTokenAddress = address(order.buyToken); + + if (swapOperationType == SwapOperationType.EthToAsset) { + // set lastSwapTime of buyToken only (sellToken WETH has no set swapDetails) + pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); + // transfer ETH from pool and wrap it (use ETH address here because swapOp.sellToken is WETH address) + pool.transferAssetToSwapOperator(ETH, totalOutAmount); + weth.deposit{value: totalOutAmount}(); + // no need to convert since totalOutAmount is already in ETH (i.e. WETH) + swapValueEth = totalOutAmount; + } else if (swapOperationType == SwapOperationType.AssetToEth) { + // set lastSwapTime of sellToken only (buyToken WETH has no set swapDetails) + pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); + // transfer ERC20 asset from Pool + pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); + // convert totalOutAmount (sellAmount + fee) to ETH + swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); + } else { + // SwapOperationType.AssetToAsset + // set lastSwapTime of sell / buy tokens + pool.setSwapDetailsLastSwapTime(sellTokenAddress, uint32(block.timestamp)); + pool.setSwapDetailsLastSwapTime(buyTokenAddress, uint32(block.timestamp)); + // transfer ERC20 asset from Pool + pool.transferAssetToSwapOperator(sellTokenAddress, totalOutAmount); + // convert totalOutAmount (sellAmount + fee) to ETH + swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); } return swapValueEth; @@ -314,15 +327,13 @@ contract SwapOperator is ISwapOperator { IPool pool = _pool(); uint totalOutAmount = order.sellAmount + order.feeAmount; - - // Prepare swap details - SwapOperation memory swapOp = prepareSwapDetails(pool, order); + SwapOperationType swapOperationType = getSwapOperationType(order); // Perform validations - performPreSwapValidations(pool, swapOp, totalOutAmount); + performPreSwapValidations(pool, order, totalOutAmount, swapOperationType); // Execute swap based on operation type - uint swapValueEth = executeAssetTransfer(pool, swapOp, totalOutAmount); + uint swapValueEth = executeAssetTransfer(pool, order, totalOutAmount, swapOperationType); // Set the swapValue on the pool pool.setSwapValue(swapValueEth); From c8641a6950d95f4428472476de5a637085ddbc25 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 22:10:08 +0200 Subject: [PATCH 44/88] Reorganise getOracleAmount parameters order --- contracts/modules/capital/SwapOperator.sol | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 7c03e9fe79..c2b07b04e0 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -96,32 +96,22 @@ contract SwapOperator is ISwapOperator { return uid; } - /// @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAssetAmount` in `fromAsset` + /// @dev Using oracle prices, returns the equivalent amount in `toAsset` for a given `fromAmount` in `fromAsset` /// Supports conversions for ETH to Asset, Asset to ETH, and Asset to Asset - function getOracleAmount(address toAsset, address fromAsset, uint fromAssetAmount) internal view returns (uint) { + function getOracleAmount(address fromAsset, address toAsset, uint fromAmount) internal view returns (uint) { IPriceFeedOracle priceFeedOracle = _pool().priceFeedOracle(); if (fromAsset == address(weth)) { // ETH -> toAsset - return priceFeedOracle.getAssetForEth(toAsset, fromAssetAmount); + return priceFeedOracle.getAssetForEth(toAsset, fromAmount); } if (toAsset == address(weth)) { // fromAsset -> ETH - return priceFeedOracle.getEthForAsset(fromAsset, fromAssetAmount); + return priceFeedOracle.getEthForAsset(fromAsset, fromAmount); } // fromAsset -> toAsset via ETH - uint fromAssetInEth = priceFeedOracle.getEthForAsset(fromAsset, fromAssetAmount); - return priceFeedOracle.getAssetForEth(toAsset, fromAssetInEth); - } - - /// @dev Validates the amount against the oracle price adjusted with max slippage tolerances - function verifySlippage(uint amount, uint oracleAmount, uint16 maxSlippageRatio) internal pure { - // Calculate slippage and minimum amount we should accept - uint maxSlippageAmount = (oracleAmount * maxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; - uint minBuyAmountOnMaxSlippage = oracleAmount - maxSlippageAmount; - if (amount < minBuyAmountOnMaxSlippage) { - revert AmountTooLow(amount, minBuyAmountOnMaxSlippage); - } + uint fromAmountInEth = priceFeedOracle.getEthForAsset(fromAsset, fromAmount); + return priceFeedOracle.getAssetForEth(toAsset, fromAmountInEth); } /// @dev Validates order amounts against oracle prices and slippage limits. From 22499f3e4042b5e5547a9e3db010143e94f12db1 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 22:11:21 +0200 Subject: [PATCH 45/88] Always check buyAmount against higer max slippage ratio --- contracts/modules/capital/SwapOperator.sol | 35 ++++++++-------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index c2b07b04e0..2b21de897a 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -115,33 +115,24 @@ contract SwapOperator is ISwapOperator { } /// @dev Validates order amounts against oracle prices and slippage limits. - /// Uses the higher maxSlippageRatio from sell/buySwapDetails, then checks if the swap amount meets the minimum after slippage. + /// Uses the higher maxSlippageRatio of either sell or buy swap details, then checks if the swap amount meets the minimum after slippage. function validateOrderAmount( GPv2Order.Data calldata order, SwapDetails memory sellSwapDetails, SwapDetails memory buySwapDetails ) internal view { - // Determine the higher maxSlippageRatio - if (sellSwapDetails.maxSlippageRatio > buySwapDetails.maxSlippageRatio) { - // verify sellAmount because sellToken maxSlippageRatio is higher - address fromAsset = address(order.buyToken); - address toAsset = address(order.sellToken); - uint fromAmount = order.buyAmount; - uint amountToCheck = order.sellAmount; - uint16 higherMaxSlippageRatio = sellSwapDetails.maxSlippageRatio; - - uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); - verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); - } else { - // verify buyAmount because buyToken maxSlippageRatio is higher - address fromAsset = address(order.sellToken); - address toAsset = address(order.buyToken); - uint fromAmount = order.sellAmount; - uint amountToCheck = order.buyAmount; - uint16 higherMaxSlippageRatio = buySwapDetails.maxSlippageRatio; - - uint oracleAmount = getOracleAmount(toAsset, fromAsset, fromAmount); - verifySlippage(amountToCheck, oracleAmount, higherMaxSlippageRatio); + uint oracleBuyAmount = getOracleAmount(address(order.sellToken), address(order.buyToken), order.sellAmount); + + // Use the higher slippage ratio of either sell/buySwapDetails + uint16 higherMaxSlippageRatio = sellSwapDetails.maxSlippageRatio > buySwapDetails.maxSlippageRatio + ? sellSwapDetails.maxSlippageRatio + : buySwapDetails.maxSlippageRatio; + + uint maxSlippageAmount = (oracleBuyAmount * higherMaxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; + uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; + + if (order.buyAmount < minBuyAmountOnMaxSlippage) { + revert AmountTooLow(order.buyAmount, minBuyAmountOnMaxSlippage); } } From 9b0700f5bd57caade2dfddd7d58a10447492d109 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 22:12:25 +0200 Subject: [PATCH 46/88] Drop validateEthBalance and make it inline --- contracts/modules/capital/SwapOperator.sol | 25 +++++++++------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 2b21de897a..f830710804 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -144,14 +144,6 @@ contract SwapOperator is ISwapOperator { } } - /// @dev Validates minimum pool ETH reserve is not breached after selling ETH - function validateEthBalance(IPool pool, uint totalOutAmount) internal view { - uint ethPostSwap = address(pool).balance - totalOutAmount; - if (ethPostSwap < minPoolEth) { - revert EthReserveBelowMin(ethPostSwap, minPoolEth); - } - } - /// @dev Validates two conditions: /// 1. The current sellToken balance is greater than sellSwapDetails.maxAmount /// 2. The post-swap sellToken balance is greater than or equal to sellSwapDetails.minAmount @@ -221,8 +213,8 @@ contract SwapOperator is ISwapOperator { function performPreSwapValidations( IPool pool, GPv2Order.Data calldata order, - uint totalOutAmount, - SwapOperationType swapOperationType + SwapOperationType swapOperationType, + uint totalOutAmount ) internal view { // NOTE: for assets that does not have any set swapDetails such as WETH it will have SwapDetails(0,0,0,0) SwapDetails memory sellSwapDetails = pool.getAssetSwapDetails(address(order.sellToken)); @@ -234,7 +226,10 @@ contract SwapOperator is ISwapOperator { // sell ETH - validate ETH balance is within ETH reserves after the swap if (swapOperationType == SwapOperationType.EthToAsset) { - validateEthBalance(pool, totalOutAmount); + uint ethPostSwap = address(pool).balance - totalOutAmount; + if (ethPostSwap < minPoolEth) { + revert EthReserveBelowMin(ethPostSwap, minPoolEth); + } } // validate sell/buy token balances against swapDetails min/max @@ -255,8 +250,8 @@ contract SwapOperator is ISwapOperator { function executeAssetTransfer( IPool pool, GPv2Order.Data calldata order, - uint totalOutAmount, - SwapOperationType swapOperationType + SwapOperationType swapOperationType, + uint totalOutAmount ) internal returns (uint swapValueEth) { IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); address sellTokenAddress = address(order.sellToken); @@ -311,10 +306,10 @@ contract SwapOperator is ISwapOperator { SwapOperationType swapOperationType = getSwapOperationType(order); // Perform validations - performPreSwapValidations(pool, order, totalOutAmount, swapOperationType); + performPreSwapValidations(pool, order, swapOperationType, totalOutAmount); // Execute swap based on operation type - uint swapValueEth = executeAssetTransfer(pool, order, totalOutAmount, swapOperationType); + uint swapValueEth = executeAssetTransfer(pool, order, swapOperationType, totalOutAmount); // Set the swapValue on the pool pool.setSwapValue(swapValueEth); From ad318b72ffc063cdc5d7f2e8598d51b33e9630dc Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 11 Mar 2024 22:24:29 +0200 Subject: [PATCH 47/88] Update slippage tests --- test/unit/SwapOperator/placeOrder.js | 166 ++++++++++++++++----------- test/unit/SwapOperator/setup.js | 2 - 2 files changed, 97 insertions(+), 71 deletions(-) diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index c296c235cd..8fee8de867 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -85,14 +85,40 @@ async function placeSellWethOrderSetup(overrides = {}) { return placeOrderSetup(order, fixture); } +/** + * DAI -> WETH swap + */ +async function placeSellDaiOrderSetup(overrides = {}) { + const fixture = await loadFixture(setup); + const { dai, weth, pool, swapOperator } = fixture.contracts; + + await dai.setBalance(pool.address, parseEther('25000')); + + // Set reasonable amounts for DAI so selling does not bring balance below min + const order = { + sellToken: dai.address, + buyToken: weth.address, + receiver: swapOperator.address, + sellAmount: parseEther('10000'), + feeAmount: parseEther('1'), + buyAmount: parseEther('2'), + validTo: (await lastBlockTimestamp()) + 650, + ...orderParams, + ...overrides, + }; + + return placeOrderSetup(order, fixture); +} + /** * stETH -> DAI swap */ -async function placeNonEthOrderSetup() { +async function placeNonEthOrderSellStethSetup() { const fixture = await loadFixture(setup); - const { dai, stEth, swapOperator, priceFeedOracle } = fixture.contracts; + const { dai, stEth, swapOperator, priceFeedOracle, pool } = fixture.contracts; const sellAmount = parseEther('2'); + await stEth.mint(pool.address, parseEther('50')); // Build order struct, domain separator and calculate UID const order = { @@ -109,25 +135,24 @@ async function placeNonEthOrderSetup() { } /** - * DAI -> WETH swap + * DAI -> stETH swap */ -async function placeBuyWethOrderSetup(overrides = {}) { +async function placeNonEthOrderSellDaiSetup() { const fixture = await loadFixture(setup); - const { dai, weth, pool, swapOperator } = fixture.contracts; + const { dai, stEth, swapOperator, priceFeedOracle, pool } = fixture.contracts; - await dai.setBalance(pool.address, parseEther('25000')); + const sellAmount = parseEther('5000'); + await dai.mint(pool.address, parseEther('30000')); - // Set reasonable amounts for DAI so selling does not bring balance below min + // Build order struct, domain separator and calculate UID const order = { sellToken: dai.address, - buyToken: weth.address, + buyToken: stEth.address, receiver: swapOperator.address, - sellAmount: parseEther('10000'), - feeAmount: parseEther('1'), - buyAmount: parseEther('2'), + sellAmount, + buyAmount: await priceFeedOracle.getEthForAsset(dai.address, sellAmount), validTo: (await lastBlockTimestamp()) + 650, ...orderParams, - ...overrides, }; return placeOrderSetup(order, fixture); @@ -265,7 +290,7 @@ describe('placeOrder', function () { }); it('performs token enabled validation when sellToken is not WETH', async function () { - const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSetup); + const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, stEth, dai, pool } = contracts; // Since stEth was already registered on setup, set its details to 0 @@ -280,7 +305,7 @@ describe('placeOrder', function () { }); it('only allows to sell when sellToken balance is above asset maxAmount - ASSET -> WETH', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, dai, pool } = contracts; // Try to run when balance is at maxAmount, @@ -296,7 +321,7 @@ describe('placeOrder', function () { }); it('only allows to sell when sellToken balance is above asset maxAmount - ASSET -> ASSET', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, stEth, pool } = contracts; // Try to run when balance is at maxAmount, @@ -312,7 +337,7 @@ describe('placeOrder', function () { }); it('only allows to buy when buyToken balance is below minAmount - ASSET -> ASSET', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, dai, pool } = contracts; // set buyToken balance to be minAmount, txn should fail @@ -332,7 +357,7 @@ describe('placeOrder', function () { const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); - const sellDaiForEthSetup = () => placeBuyWethOrderSetup({ sellAmount, feeAmount, buyAmount }); + const sellDaiForEthSetup = () => placeSellDaiOrderSetup({ sellAmount, feeAmount, buyAmount }); const { contracts, contractOrder, orderUID } = await loadFixture(sellDaiForEthSetup); const { swapOperator, dai, pool } = contracts; @@ -358,7 +383,7 @@ describe('placeOrder', function () { const sellAmount = parseEther('24999'); const feeAmount = parseEther('1'); const buyAmount = parseEther('4.9998'); - const sellDaiForEthSetup = () => placeBuyWethOrderSetup({ sellAmount, feeAmount, buyAmount }); + const sellDaiForEthSetup = () => placeSellDaiOrderSetup({ sellAmount, feeAmount, buyAmount }); const { contracts, contractOrder, orderUID } = await loadFixture(sellDaiForEthSetup); const { swapOperator, dai, pool } = contracts; @@ -392,7 +417,7 @@ describe('placeOrder', function () { }); it('does not perform WETH token enabled validation when buying WETH', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, weth, pool } = contracts; // Ensure eth (weth) is disabled by checking min and max amount @@ -405,7 +430,7 @@ describe('placeOrder', function () { }); it('performs token enabled validation when not buying WETH', async function () { - const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSetup); + const { contracts, contractOrder, orderUID, governance } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, stEth, dai, pool } = contracts; // Since DAI was already registered on setup, set its details to 0 @@ -512,7 +537,7 @@ describe('placeOrder', function () { orderUID, governance, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeBuyWethOrderSetup); + } = await loadFixture(placeSellDaiOrderSetup); // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); @@ -553,7 +578,7 @@ describe('placeOrder', function () { orderUID, domain, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeNonEthOrderSetup); + } = await loadFixture(placeNonEthOrderSellStethSetup); // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); @@ -595,7 +620,7 @@ describe('placeOrder', function () { orderUID, domain, MIN_TIME_BETWEEN_ORDERS, - } = await loadFixture(placeNonEthOrderSetup); + } = await loadFixture(placeNonEthOrderSellStethSetup); // Place and close an order await swapOperator.placeOrder(contractOrder, orderUID); @@ -628,7 +653,7 @@ describe('placeOrder', function () { await swapOperator.placeOrder(secondOrder.contractOrder, secondOrder.orderUID); }); - it('when selling ether, checks that feeAmount is not higher than maxFee', async function () { + it('when selling ETH, checks that feeAmount is not higher than maxFee', async function () { const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); const { swapOperator } = contracts; const maxFee = await swapOperator.maxFee(); @@ -646,7 +671,7 @@ describe('placeOrder', function () { }); it('when selling other asset, uses oracle to check fee in ether is not higher than maxFee', async function () { - const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); + const { contracts, order, domain } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, priceFeedOracle, dai } = contracts; const maxFee = await swapOperator.maxFee(); const daiToEthRate = await priceFeedOracle.getAssetToEthRate(dai.address); @@ -684,11 +709,11 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when selling eth validates buyAmount against oracle price if buyToken has a higher slippage', async function () { + it('when selling ETH validates buyAmount against oracle + the higher slippage ratio', async function () { const { contracts, governance, order, domain } = await loadFixture(placeSellWethOrderSetup); const { swapOperator, pool, dai, weth } = contracts; - const buyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); + // set 1% slippage ratio for DAI await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); const wethSellSwapDetails = await pool.getAssetSwapDetails(weth.address); @@ -696,6 +721,7 @@ describe('placeOrder', function () { expect(daiBuySwapDetails.maxSlippageRatio > wethSellSwapDetails.maxSlippageRatio).to.equal(true); // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const buyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); const badOrderOverrides = { buyAmount: buyAmountOnePercentSlippage.sub(1) }; const badOrder = createContractOrder(domain, order, badOrderOverrides); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); @@ -708,10 +734,8 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - // ETH has no set swapDetails slippage, so no test for sell eth which has higher slippage - - it('when buying eth validates buyAmount against oracle price if both assets has 0% slippage', async function () { - const { contracts, order, domain } = await loadFixture(placeBuyWethOrderSetup); + it('when buying ETH validates buyAmount against oracle price if both assets has 0% slippage', async function () { + const { contracts, order, domain } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, priceFeedOracle, pool, dai, weth } = contracts; const daiSellSwapDetails = await pool.getAssetSwapDetails(dai.address); @@ -731,33 +755,32 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('when buying eth validates sellAmount against oracle price if sellToken has a higher slippage', async function () { - const { contracts, governance, order, domain } = await loadFixture(placeBuyWethOrderSetup); + it('when buying ETH validates buyAmount against oracle + the higher slippage ratio', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, pool, dai, weth } = contracts; - const daiSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); - await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); + // set 2% slippage ratio for DAI + await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 200); const daiSellSwapDetails = await pool.getAssetSwapDetails(dai.address); const wethBuySwapDetails = await pool.getAssetSwapDetails(weth.address); expect(daiSellSwapDetails.maxSlippageRatio > wethBuySwapDetails.maxSlippageRatio).to.equal(true); - // Since sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert - const badOrder = createContractOrder(domain, order, { sellAmount: daiSellAmountOnePercentSlippage.sub(1) }); + // Since buyAmount is > 2% slippage (i.e. 98% -1 wei), txn should revert + const ethBuyAmountTwoPercentSlippage = order.buyAmount.mul(98).div(100); + const badOrder = createContractOrder(domain, order, { buyAmount: ethBuyAmountTwoPercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') - .withArgs(daiSellAmountOnePercentSlippage.sub(1), daiSellAmountOnePercentSlippage); + .withArgs(ethBuyAmountTwoPercentSlippage.sub(1), ethBuyAmountTwoPercentSlippage); - // Exactly 1% slippage sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { sellAmount: daiSellAmountOnePercentSlippage }); + // Exactly 2% slippage sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { buyAmount: ethBuyAmountTwoPercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - // ETH has no set swapDetails slippage, so no test for buy eth which has higher slippage - it('non-ETH swap, validates buyAmount against oracle price if both assets has 0% slippage', async function () { - const { contracts, order, domain } = await loadFixture(placeNonEthOrderSetup); + const { contracts, order, domain } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, priceFeedOracle, pool, dai, stEth } = contracts; const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); @@ -777,11 +800,11 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('non-ETH swap, validates buyAmount with oracle price & slippage if buyToken has > slippage', async function () { - const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); + it('non-ETH swap, validates buyAmount with oracle price + higher slippage ratio (stEth -> dai)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, pool, dai, stEth } = contracts; - const daiBuyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); + // set 1% slippage ratio for DAI await pool.connect(governance).setSwapDetails(dai.address, daiMinAmount, daiMaxAmount, 100); const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); @@ -789,6 +812,7 @@ describe('placeOrder', function () { expect(daiBuySwapDetails.maxSlippageRatio > stEthSellSwapDetails.maxSlippageRatio).to.equal(true); // Since buyAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert + const daiBuyAmountOnePercentSlippage = order.buyAmount.mul(99).div(100); const badOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) @@ -800,29 +824,32 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); - it('non-ETH swap, validates sellAmount with oracle price & slippage if sellToken has > slippage', async function () { - const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSetup); + it('non-ETH swap, validates buyAmount with oracle price + higher slippage ratio (dai -> stEth)', async function () { + const { contracts, governance, order, domain } = await loadFixture(placeNonEthOrderSellDaiSetup); const { swapOperator, pool, dai, stEth } = contracts; - const stEthSellAmountOnePercentSlippage = order.sellAmount.mul(99).div(100); - await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 100); + // set 3% slippage ratio for stETH + await pool.connect(governance).setSwapDetails(stEth.address, stEthMinAmount, stEthMaxAmount, 300); const stEthSellSwapDetails = await pool.getAssetSwapDetails(stEth.address); const daiBuySwapDetails = await pool.getAssetSwapDetails(dai.address); expect(stEthSellSwapDetails.maxSlippageRatio > daiBuySwapDetails.maxSlippageRatio).to.equal(true); - // Since sellAmount is > 1% slippage (i.e. 99% -1 wei), txn should revert - const badOrder = createContractOrder(domain, order, { sellAmount: stEthSellAmountOnePercentSlippage.sub(1) }); + // Since sellAmount is > 3% slippage (i.e. 97% -1 wei), txn should revert + const stEthBuyAmountThreePercentSlippage = order.buyAmount.mul(97).div(100); + const badOrder = createContractOrder(domain, order, { buyAmount: stEthBuyAmountThreePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') - .withArgs(stEthSellAmountOnePercentSlippage.sub(1), stEthSellAmountOnePercentSlippage); + .withArgs(stEthBuyAmountThreePercentSlippage.sub(1), stEthBuyAmountThreePercentSlippage); - // Exactly 1% slippage sellAmount should not revert - const goodOrder = createContractOrder(domain, order, { sellAmount: stEthSellAmountOnePercentSlippage }); + // Exactly 3% slippage sellAmount should not revert + const goodOrder = createContractOrder(domain, order, { buyAmount: stEthBuyAmountThreePercentSlippage }); await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); + // TODO: same + it('pulling funds from pool: transfers ETH from pool and wrap it into WETH when sellToken is ETH', async function () { const { contracts, order, contractOrder, orderUID } = await loadFixture(placeSellWethOrderSetup); const { swapOperator, weth, pool } = contracts; @@ -840,19 +867,19 @@ describe('placeOrder', function () { }); it('pulling funds from pool: transfer erc20 asset from pool to eth if sellToken is not WETH', async function () { - const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSetup); - const { swapOperator, stEth, pool } = contracts; + const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSellDaiSetup); + const { swapOperator, dai, pool } = contracts; - const stEthPoolBefore = await stEth.balanceOf(pool.address); - const stEthSwapOpBefore = await stEth.balanceOf(swapOperator.address); + const daiPoolBefore = await dai.balanceOf(pool.address); + const daiSwapOpBefore = await dai.balanceOf(swapOperator.address); await swapOperator.placeOrder(contractOrder, orderUID); - const stEthPoolAfter = await stEth.balanceOf(pool.address); - const stEthSwapOpAfter = await stEth.balanceOf(swapOperator.address); + const daiPoolAfter = await dai.balanceOf(pool.address); + const daiSwapOpAfter = await dai.balanceOf(swapOperator.address); - expect(stEthPoolBefore.sub(stEthPoolAfter)).to.eq(order.sellAmount.add(order.feeAmount)); - expect(stEthSwapOpAfter.sub(stEthSwapOpBefore)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(daiPoolBefore.sub(daiPoolAfter)).to.eq(order.sellAmount.add(order.feeAmount)); + expect(daiSwapOpAfter.sub(daiSwapOpBefore)).to.eq(order.sellAmount.add(order.feeAmount)); }); it('sets lastSwapDate on buyToken when selling ETH', async function () { @@ -869,7 +896,7 @@ describe('placeOrder', function () { }); it('sets lastSwapDate on sellToken when buying ETH', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeBuyWethOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, dai, pool } = contracts; const before = await pool.getAssetSwapDetails(dai.address); @@ -882,7 +909,7 @@ describe('placeOrder', function () { }); it('sets lastSwapDate on both sellToken / buyToken when swapping asset to asset', async function () { - const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSetup); + const { contracts, contractOrder, orderUID } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, stEth, dai, pool } = contracts; const stEthBefore = await pool.getAssetSwapDetails(stEth.address); @@ -914,7 +941,7 @@ describe('placeOrder', function () { }); it('should set totalOutAmount in ETH as pool.swapValue when selling non-ETH assets', async function () { - const orderSetupsToTest = [placeBuyWethOrderSetup, placeNonEthOrderSetup]; + const orderSetupsToTest = [placeSellDaiOrderSetup, placeNonEthOrderSellStethSetup, placeNonEthOrderSellDaiSetup]; for (const orderSetup of orderSetupsToTest) { const { contracts, order, contractOrder, orderUID } = await loadFixture(orderSetup); const { swapOperator, pool, priceFeedOracle } = contracts; @@ -931,7 +958,7 @@ describe('placeOrder', function () { }); it('should set totalOutAmount in ETH as pool.swapValue on non-ETH asset swaps', async function () { - const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSetup); + const { contracts, order, orderUID, contractOrder } = await loadFixture(placeNonEthOrderSellStethSetup); const { swapOperator, pool, priceFeedOracle, stEth } = contracts; const { sellAmount, feeAmount } = order; @@ -947,8 +974,9 @@ describe('placeOrder', function () { it('approves CoW vault relayer to spend exactly sellAmount + fee', async function () { const orderSetupsToTest = [ { sellTokenName: 'weth', orderSetup: placeSellWethOrderSetup }, - { sellTokenName: 'dai', orderSetup: placeBuyWethOrderSetup }, - { sellTokenName: 'stEth', orderSetup: placeNonEthOrderSetup }, + { sellTokenName: 'dai', orderSetup: placeSellDaiOrderSetup }, + { sellTokenName: 'stEth', orderSetup: placeNonEthOrderSellStethSetup }, + { sellTokenName: 'dai', orderSetup: placeNonEthOrderSellDaiSetup }, ]; for (const { sellTokenName, orderSetup } of orderSetupsToTest) { const { contracts, order, contractOrder, orderUID } = await loadFixture(orderSetup); diff --git a/test/unit/SwapOperator/setup.js b/test/unit/SwapOperator/setup.js index 58412fad46..784c5b5c67 100644 --- a/test/unit/SwapOperator/setup.js +++ b/test/unit/SwapOperator/setup.js @@ -121,8 +121,6 @@ async function setup() { await pool.connect(governance).addAsset(usdc.address, true, 0, parseEther('1000'), 0); - await stEth.mint(pool.address, parseEther('50')); - // Deploy SwapOperator const swapOperator = await SwapOperator.deploy( cowSettlement.address, From 3c167c50b16477d2eefbf9c506fdb894ae0321c0 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 12 Mar 2024 09:56:48 +0200 Subject: [PATCH 48/88] Update function annotations and comments --- contracts/modules/capital/SwapOperator.sol | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index f830710804..3181193db1 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -114,8 +114,8 @@ contract SwapOperator is ISwapOperator { return priceFeedOracle.getAssetForEth(toAsset, fromAmountInEth); } - /// @dev Validates order amounts against oracle prices and slippage limits. - /// Uses the higher maxSlippageRatio of either sell or buy swap details, then checks if the swap amount meets the minimum after slippage. + /// @dev Validates order.buyAmount against oracle prices and slippage limits + /// Uses the higher maxSlippageRatio of either sell or buy swap details, then checks if the swap amount meets the minimum after slippage function validateOrderAmount( GPv2Order.Data calldata order, SwapDetails memory sellSwapDetails, @@ -136,7 +136,7 @@ contract SwapOperator is ISwapOperator { } } - /// @dev Validates if a token is enabled for swapping. + /// @dev Validates if a token is enabled for swapping /// WETH is excluded in validation since it does not have set swapDetails (i.e. SwapDetails(0,0,0,0)) function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal view { if (token != address(weth) && swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { @@ -172,8 +172,8 @@ contract SwapOperator is ISwapOperator { } /// @dev Validates two conditions: - /// 1. The current buyToken balance is less than buySwapDetails.minAmount. - /// 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount. + /// 1. The current buyToken balance is less than buySwapDetails.minAmount + /// 2. The post-swap buyToken balance is less than or equal to buySwapDetails.maxAmount /// Skip validation for WETH since it does not have set swapDetails function validateBuyTokenBalance( IPool pool, @@ -209,7 +209,7 @@ contract SwapOperator is ISwapOperator { return SwapOperationType.AssetToAsset; } - /// @dev Performs pre-swap validation checks for a given swap operation + /// @dev Performs pre-swap validation checks for the given order function performPreSwapValidations( IPool pool, GPv2Order.Data calldata order, @@ -224,7 +224,7 @@ contract SwapOperator is ISwapOperator { validateTokenIsEnabled(address(order.sellToken), sellSwapDetails); validateTokenIsEnabled(address(order.buyToken), buySwapDetails); - // sell ETH - validate ETH balance is within ETH reserves after the swap + // validate ETH balance is within ETH reserves after the swap if (swapOperationType == SwapOperationType.EthToAsset) { uint ethPostSwap = address(pool).balance - totalOutAmount; if (ethPostSwap < minPoolEth) { From c28adbcf8316b294cdadb9b6425f3f230952c667 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 12 Mar 2024 09:57:16 +0200 Subject: [PATCH 49/88] Add InvalidTokenAddress validation + unit tests --- contracts/interfaces/ISwapOperator.sol | 1 + contracts/modules/capital/SwapOperator.sol | 8 ++++++++ test/unit/SwapOperator/placeOrder.js | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index d7002a5e58..ec48668a53 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -46,6 +46,7 @@ interface ISwapOperator { error InvalidReceiver(address validReceiver); error OrderTokenIsDisabled(address token); error AmountTooLow(uint amount, uint minAmount); + error InvalidTokenAddress(string token); // Valid To error BelowMinValidTo(uint minValidTo); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 3181193db1..2cbc86609c 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -399,6 +399,14 @@ contract SwapOperator is ISwapOperator { if (order.receiver != address(this)) { revert InvalidReceiver(address(this)); } + if (address(order.sellToken) == ETH) { + // must to be WETH address for ETH swaps + revert InvalidTokenAddress('sellToken'); + } + if (address(order.buyToken) == ETH) { + // must to be WETH address for ETH swaps + revert InvalidTokenAddress('buyToken'); + } if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20) { revert UnsupportedTokenBalance('sell'); } diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index 8fee8de867..4daaee8583 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -16,6 +16,7 @@ const setup = require('./setup'); const { setEtherBalance, setNextBlockTime } = require('../../utils/evm'); const { parseEther, hexZeroPad, hexlify, randomBytes } = ethers.utils; +const ETH_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; function createContractOrder(domain, order, overrides = {}) { order = { ...order, ...overrides }; @@ -256,6 +257,24 @@ describe('placeOrder', function () { await swapOperator.placeOrder(goodOrder.contractOrder, goodOrder.orderUID); }); + it('validates that sellToken is not ETH address on ETH swaps', async function () { + const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); + const { swapOperator } = contracts; + + const badOrder = createContractOrder(domain, order, { sellToken: ETH_ADDRESS }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'InvalidTokenAddress').withArgs('sellToken'); + }); + + it('validates that buyToken is not ETH address on ETH swaps', async function () { + const { contracts, order, domain } = await loadFixture(placeSellDaiOrderSetup); + const { swapOperator } = contracts; + + const badOrder = createContractOrder(domain, order, { buyToken: ETH_ADDRESS }); + const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'InvalidTokenAddress').withArgs('buyToken'); + }); + it('validates that order.validTo is at most 60 minutes in the future', async function () { const MAX_VALID_TO_PERIOD_SECONDS = 60 * 60; // 60 minutes const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); From 311dd7ca2e9f95d3ffba52abbc96c1c77fd0658f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 12 Mar 2024 13:37:00 +0200 Subject: [PATCH 50/88] Move sell ETH post-swap balance validation inside validateSellTokenBalance --- contracts/modules/capital/SwapOperator.sol | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 2cbc86609c..c45af0be1a 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -152,12 +152,18 @@ contract SwapOperator is ISwapOperator { IPool pool, GPv2Order.Data calldata order, SwapDetails memory sellSwapDetails, + SwapOperationType swapOperationType, uint totalOutAmount ) internal view { uint sellTokenBalance = order.sellToken.balanceOf(address(pool)); - // skip validation for WETH since it does not have set swapDetails - if (address(order.sellToken) == address(weth)) { + // validate ETH balance is within ETH reserves after the swap + if (swapOperationType == SwapOperationType.EthToAsset) { + uint ethPostSwap = address(pool).balance - totalOutAmount; + if (ethPostSwap < minPoolEth) { + revert EthReserveBelowMin(ethPostSwap, minPoolEth); + } + // skip sellSwapDetails validation for ETH/WETH since it does not have set swapDetails return; } @@ -224,16 +230,8 @@ contract SwapOperator is ISwapOperator { validateTokenIsEnabled(address(order.sellToken), sellSwapDetails); validateTokenIsEnabled(address(order.buyToken), buySwapDetails); - // validate ETH balance is within ETH reserves after the swap - if (swapOperationType == SwapOperationType.EthToAsset) { - uint ethPostSwap = address(pool).balance - totalOutAmount; - if (ethPostSwap < minPoolEth) { - revert EthReserveBelowMin(ethPostSwap, minPoolEth); - } - } - // validate sell/buy token balances against swapDetails min/max - validateSellTokenBalance(pool, order, sellSwapDetails, totalOutAmount); + validateSellTokenBalance(pool, order, sellSwapDetails, swapOperationType, totalOutAmount); validateBuyTokenBalance(pool, order, buySwapDetails); // validate swap frequency to enforce cool down periods From 24315f0112c6a2975babde55cbb618748519e87e Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 18 Mar 2024 12:53:51 +0200 Subject: [PATCH 51/88] Call _pool() once and pass down to child functions --- contracts/modules/capital/SwapOperator.sol | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index c45af0be1a..6c8279935d 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -239,7 +239,7 @@ contract SwapOperator is ISwapOperator { validateSwapFrequency(buySwapDetails); // validate max fee and max slippage - validateMaxFee(address(order.sellToken), order.feeAmount); + validateMaxFee(pool, address(order.sellToken), order.feeAmount); validateOrderAmount(order, sellSwapDetails, buySwapDetails); } @@ -349,12 +349,14 @@ contract SwapOperator is ISwapOperator { // Clear the current order delete currentOrderUID; + IPool pool = _pool(); + // Withdraw both buyToken and sellToken - returnAssetToPool(order.buyToken); - returnAssetToPool(order.sellToken); + returnAssetToPool(pool, order.buyToken); + returnAssetToPool(pool, order.sellToken); // Set swapValue on pool to 0 - _pool().setSwapValue(0); + pool.setSwapValue(0); // Emit event emit OrderClosed(order, filledAmount); @@ -362,7 +364,7 @@ contract SwapOperator is ISwapOperator { /// @dev Return a given asset to the pool, either ETH or ERC20 /// @param asset The asset - function returnAssetToPool(IERC20 asset) internal { + function returnAssetToPool(IPool pool, IERC20 asset) internal { uint balance = asset.balanceOf(address(this)); if (balance == 0) { @@ -374,11 +376,11 @@ contract SwapOperator is ISwapOperator { weth.withdraw(balance); // Transfer ETH to pool - (bool sent, ) = payable(address(_pool())).call{value: balance}(""); + (bool sent, ) = payable(address(pool)).call{value: balance}(""); require(sent, "SwapOp: Failed to send Ether to pool"); } else { // Transfer ERC20 to pool - asset.safeTransfer(address(_pool()), balance); + asset.safeTransfer(address(pool), balance); } } @@ -442,12 +444,13 @@ contract SwapOperator is ISwapOperator { /// @param sellToken The sell asset /// @param feeAmount The fee (will always be denominated in the sell asset units) function validateMaxFee( + IPool pool, address sellToken, uint feeAmount ) internal view { uint feeInEther = sellToken == address(weth) ? feeAmount - : _pool().priceFeedOracle().getEthForAsset(sellToken, feeAmount); + : pool.priceFeedOracle().getEthForAsset(sellToken, feeAmount); if (feeInEther > maxFee) { revert AboveMaxFee(feeInEther, maxFee); } From 36a6635ca5635f6f0da4de474633791250b4559d Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 18 Mar 2024 12:59:58 +0200 Subject: [PATCH 52/88] Remove unneccesary return statement + fix comment --- contracts/modules/capital/SwapOperator.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 6c8279935d..e0464cf5c1 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -280,8 +280,6 @@ contract SwapOperator is ISwapOperator { // convert totalOutAmount (sellAmount + fee) to ETH swapValueEth = priceFeedOracle.getEthForAsset(sellTokenAddress, totalOutAmount); } - - return swapValueEth; } /// @dev Approve a given order to be executed, by presigning it on CoW protocol's settlement contract @@ -339,7 +337,7 @@ contract SwapOperator is ISwapOperator { validateUID(order, currentOrderUID); - // Check how much of the order was filled, and if it was fully filled + // Check how much of the order was filled uint filledAmount = cowSettlement.filledAmount(currentOrderUID); // Cancel order and unapprove tokens From 32fe8cb946ffed99970391f15f9a44d937de00e9 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 18 Mar 2024 13:00:30 +0200 Subject: [PATCH 53/88] Rename maxFee to MAX_FEE to follow constants naming convention --- contracts/modules/capital/SwapOperator.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index e0464cf5c1..965a9dbd4c 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -45,7 +45,7 @@ contract SwapOperator is ISwapOperator { uint public constant MIN_VALID_TO_PERIOD = 600; // 10 minutes uint public constant MAX_VALID_TO_PERIOD = 3600; // 60 minutes uint public constant MIN_TIME_BETWEEN_ORDERS = 900; // 15 minutes - uint public constant maxFee = 0.3 ether; + uint public constant MAX_FEE = 0.3 ether; modifier onlyController() { require(msg.sender == swapController, "SwapOp: only controller can execute"); @@ -449,8 +449,8 @@ contract SwapOperator is ISwapOperator { uint feeInEther = sellToken == address(weth) ? feeAmount : pool.priceFeedOracle().getEthForAsset(sellToken, feeAmount); - if (feeInEther > maxFee) { - revert AboveMaxFee(feeInEther, maxFee); + if (feeInEther > MAX_FEE) { + revert AboveMaxFee(feeInEther, MAX_FEE); } } From 9bc5467a71ff49afb8b66b618161783761db18c1 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Mon, 18 Mar 2024 19:45:16 +0200 Subject: [PATCH 54/88] Add invalidate signature on closeOrder --- contracts/modules/capital/SwapOperator.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 965a9dbd4c..dc019dac8c 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -340,7 +340,8 @@ contract SwapOperator is ISwapOperator { // Check how much of the order was filled uint filledAmount = cowSettlement.filledAmount(currentOrderUID); - // Cancel order and unapprove tokens + // Invalidate signature, cancel order and unapprove tokens + cowSettlement.setPreSignature(currentOrderUID, false); cowSettlement.invalidateOrder(currentOrderUID); order.sellToken.safeApprove(cowVaultRelayer, 0); From 45ba4c81dbf8f4fc693f6568fec0f0b132bfd5c4 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 20 Mar 2024 15:04:06 +0200 Subject: [PATCH 55/88] SwapOperator - replace all requires with custom error --- contracts/interfaces/ISwapOperator.sol | 15 ++- contracts/modules/capital/SwapOperator.sol | 129 +++++++++++++-------- 2 files changed, 94 insertions(+), 50 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index ec48668a53..e998db1752 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -39,24 +39,31 @@ interface ISwapOperator { event OrderClosed(GPv2Order.Data order, uint filledAmount); event Swapped(address indexed fromAsset, address indexed toAsset, uint amountIn, uint amountOut); - // Order + // Swap Order error OrderInProgress(bytes currentOrderUID); + error NoOrderInPlace(); error OrderUidMismatch(bytes providedOrderUID, bytes expectedOrderUID); error UnsupportedTokenBalance(string kind); error InvalidReceiver(address validReceiver); - error OrderTokenIsDisabled(address token); - error AmountTooLow(uint amount, uint minAmount); + error TokenIsDisabled(address token); + error AmountOutTooLow(uint amountOut, uint minAmount); error InvalidTokenAddress(string token); + error InvalidDenominationAsset(address invalidAsset, address validAsset); // Valid To error BelowMinValidTo(uint minValidTo); error AboveMaxValidTo(uint maxValidTo); // Balance - error EthReserveBelowMin(uint ethPostSwap, uint minEthReserve); error InvalidBalance(uint tokenBalance, uint limit, string limitType); error InvalidPostSwapBalance(uint postSwapBalance, uint limit, string limitType); + // Access Controls + error OnlyController(); + + // Transfer + error TransferFailed(address to, uint value, address token); + // Cool down error InsufficientTimeBetweenSwaps(uint minValidSwapTime); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index dc019dac8c..72bdba1600 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -48,7 +48,9 @@ contract SwapOperator is ISwapOperator { uint public constant MAX_FEE = 0.3 ether; modifier onlyController() { - require(msg.sender == swapController, "SwapOp: only controller can execute"); + if (msg.sender != swapController) { + revert OnlyController(); + } _; } @@ -114,6 +116,13 @@ contract SwapOperator is ISwapOperator { return priceFeedOracle.getAssetForEth(toAsset, fromAmountInEth); } + /// @dev Reverts if amountOut is less than amountOutMin + function validateAmountOut(uint amountOut, uint amountOutMin) internal pure { + if (amountOut < amountOutMin) { + revert AmountOutTooLow(amountOut, amountOutMin); + } + } + /// @dev Validates order.buyAmount against oracle prices and slippage limits /// Uses the higher maxSlippageRatio of either sell or buy swap details, then checks if the swap amount meets the minimum after slippage function validateOrderAmount( @@ -131,16 +140,21 @@ contract SwapOperator is ISwapOperator { uint maxSlippageAmount = (oracleBuyAmount * higherMaxSlippageRatio) / MAX_SLIPPAGE_DENOMINATOR; uint minBuyAmountOnMaxSlippage = oracleBuyAmount - maxSlippageAmount; - if (order.buyAmount < minBuyAmountOnMaxSlippage) { - revert AmountTooLow(order.buyAmount, minBuyAmountOnMaxSlippage); - } + validateAmountOut(order.buyAmount, minBuyAmountOnMaxSlippage); } - /// @dev Validates if a token is enabled for swapping + /// @dev Reverts if both swapDetails min/maxAmount are set to 0 + function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal pure { + if (swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { + revert TokenIsDisabled(token); + } + } + + /// @dev Reverts if both swapDetails min/maxAmount (excluding WETH) are set to 0 /// WETH is excluded in validation since it does not have set swapDetails (i.e. SwapDetails(0,0,0,0)) - function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal view { - if (token != address(weth) && swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { - revert OrderTokenIsDisabled(token); + function validateTokenIsEnabledSkipWeth(address token, SwapDetails memory swapDetails) internal view { + if (token != address(weth)) { + validateTokenIsEnabled(token, swapDetails); } } @@ -227,8 +241,8 @@ contract SwapOperator is ISwapOperator { SwapDetails memory buySwapDetails = pool.getAssetSwapDetails(address(order.buyToken)); // validate both sell and buy tokens are enabled - validateTokenIsEnabled(address(order.sellToken), sellSwapDetails); - validateTokenIsEnabled(address(order.buyToken), buySwapDetails); + validateTokenIsEnabledSkipWeth(address(order.sellToken), sellSwapDetails); + validateTokenIsEnabledSkipWeth(address(order.buyToken), buySwapDetails); // validate sell/buy token balances against swapDetails min/max validateSellTokenBalance(pool, order, sellSwapDetails, swapOperationType, totalOutAmount); @@ -328,11 +342,13 @@ contract SwapOperator is ISwapOperator { /// @param order The order to close function closeOrder(GPv2Order.Data calldata order) external { // Validate there is an order in place - require(orderInProgress(), "SwapOp: No order in place"); + if (!orderInProgress()) { + revert NoOrderInPlace(); + } // Before validTo, only controller can call this. After it, everyone can call - if (block.timestamp <= order.validTo) { - require(msg.sender == swapController, "SwapOp: only controller can execute"); + if (block.timestamp <= order.validTo && msg.sender != swapController) { + revert OnlyController(); } validateUID(order, currentOrderUID); @@ -376,7 +392,9 @@ contract SwapOperator is ISwapOperator { // Transfer ETH to pool (bool sent, ) = payable(address(pool)).call{value: balance}(""); - require(sent, "SwapOp: Failed to send Ether to pool"); + if (!sent) { + revert TransferFailed(address(pool), balance, ETH); + } } else { // Transfer ERC20 to pool asset.safeTransfer(address(pool), balance); @@ -461,7 +479,9 @@ contract SwapOperator is ISwapOperator { function swapETHForEnzymeVaultShare(uint amountIn, uint amountOutMin) external onlyController { // Validate there's no current cow swap order going on - require(!orderInProgress(), "SwapOp: an order is already in place"); + if (orderInProgress()) { + revert OrderInProgress(currentOrderUID); + } IPool pool = _pool(); IEnzymeV4Comptroller comptrollerProxy = IEnzymeV4Comptroller(IEnzymeV4Vault(enzymeV4VaultProxyAddress).getAccessor()); @@ -470,13 +490,8 @@ contract SwapOperator is ISwapOperator { SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(toToken)); - require(!(swapDetails.minAmount == 0 && swapDetails.maxAmount == 0), "SwapOp: asset is not enabled"); - - { - // scope for swap frequency check - uint timeSinceLastTrade = block.timestamp - uint(swapDetails.lastSwapTime); - require(timeSinceLastTrade > MIN_TIME_BETWEEN_ORDERS, "SwapOp: too fast"); - } + validateTokenIsEnabled(address(toToken), swapDetails); + validateSwapFrequency(swapDetails); { // check slippage @@ -486,13 +501,16 @@ contract SwapOperator is ISwapOperator { uint maxSlippageAmount = avgAmountOut * swapDetails.maxSlippageRatio / MAX_SLIPPAGE_DENOMINATOR; uint minOutOnMaxSlippage = avgAmountOut - maxSlippageAmount; - require(amountOutMin >= minOutOnMaxSlippage, "SwapOp: amountOutMin < minOutOnMaxSlippage"); + validateAmountOut(amountOutMin, minOutOnMaxSlippage); } uint balanceBefore = toToken.balanceOf(address(pool)); pool.transferAssetToSwapOperator(ETH, amountIn); - require(comptrollerProxy.getDenominationAsset() == address(weth), "SwapOp: invalid denomination asset"); + address denominationAsset = comptrollerProxy.getDenominationAsset(); + if (denominationAsset != address(weth)) { + revert InvalidDenominationAsset(denominationAsset, address(weth)); + } weth.deposit{ value: amountIn }(); weth.approve(address(comptrollerProxy), amountIn); @@ -502,13 +520,17 @@ contract SwapOperator is ISwapOperator { uint amountOut = toToken.balanceOf(address(this)); - require(amountOut >= amountOutMin, "SwapOp: amountOut < amountOutMin"); - require(balanceBefore < swapDetails.minAmount, "SwapOp: balanceBefore >= min"); - require(balanceBefore + amountOutMin <= swapDetails.maxAmount, "SwapOp: balanceAfter > max"); + validateAmountOut(amountOut, amountOutMin); + if (balanceBefore >= swapDetails.minAmount) { + revert InvalidBalance(balanceBefore, swapDetails.minAmount, 'min'); + } + if (balanceBefore + amountOutMin > swapDetails.maxAmount) { + revert InvalidPostSwapBalance(balanceBefore + amountOutMin, swapDetails.maxAmount, 'max'); + } - { - uint ethBalanceAfter = address(pool).balance; - require(ethBalanceAfter >= minPoolEth, "SwapOp: insufficient ether left"); + uint ethBalanceAfter = address(pool).balance; + if (ethBalanceAfter < minPoolEth) { + revert InvalidPostSwapBalance(ethBalanceAfter, minPoolEth, 'min'); } transferAssetTo(enzymeV4VaultProxyAddress, address(pool), amountOut); @@ -525,7 +547,9 @@ contract SwapOperator is ISwapOperator { ) external onlyController { // Validate there's no current cow swap order going on - require(!orderInProgress(), "SwapOp: an order is already in place"); + if (orderInProgress()) { + revert OrderInProgress(currentOrderUID); + } IPool pool = _pool(); IERC20Detailed fromToken = IERC20Detailed(enzymeV4VaultProxyAddress); @@ -535,11 +559,8 @@ contract SwapOperator is ISwapOperator { SwapDetails memory swapDetails = pool.getAssetSwapDetails(address(fromToken)); - require(!(swapDetails.minAmount == 0 && swapDetails.maxAmount == 0), "SwapOp: asset is not enabled"); - - // swap frequency check - uint timeSinceLastTrade = block.timestamp - uint(swapDetails.lastSwapTime); - require(timeSinceLastTrade > MIN_TIME_BETWEEN_ORDERS, "SwapOp: too fast"); + validateTokenIsEnabled(address(fromToken), swapDetails); + validateSwapFrequency(swapDetails); uint netShareValue; { @@ -547,7 +568,9 @@ contract SwapOperator is ISwapOperator { (denominationAsset, netShareValue) = enzymeFundValueCalculatorRouter.calcNetShareValue(enzymeV4VaultProxyAddress); - require(denominationAsset == address(weth), "SwapOp: invalid denomination asset"); + if (denominationAsset != address(weth)) { + revert InvalidDenominationAsset(denominationAsset, address(weth)); + } } // avgAmountOut in ETH @@ -556,9 +579,13 @@ contract SwapOperator is ISwapOperator { uint minOutOnMaxSlippage = avgAmountOut - maxSlippageAmount; // slippage check - require(amountOutMin >= minOutOnMaxSlippage, "SwapOp: amountOutMin < minOutOnMaxSlippage"); - require(balanceBefore > swapDetails.maxAmount, "SwapOp: balanceBefore <= max"); - require(balanceBefore - amountIn >= swapDetails.minAmount, "SwapOp: tokenBalanceAfter < min"); + validateAmountOut(amountOutMin, minOutOnMaxSlippage); + if (balanceBefore <= swapDetails.maxAmount) { + revert InvalidBalance(balanceBefore, swapDetails.maxAmount, 'max'); + } + if (balanceBefore - amountIn < swapDetails.minAmount) { + revert InvalidPostSwapBalance(balanceBefore - amountIn, swapDetails.minAmount, 'min'); + } } pool.transferAssetToSwapOperator(address(fromToken), amountIn); @@ -579,7 +606,7 @@ contract SwapOperator is ISwapOperator { pool.setSwapDetailsLastSwapTime(address(fromToken), uint32(block.timestamp)); - require(amountOut >= amountOutMin, "SwapOp: amountOut < amountOutMin"); + validateAmountOut(amountOut, amountOutMin); transferAssetTo(ETH, address(pool), amountOut); @@ -590,7 +617,9 @@ contract SwapOperator is ISwapOperator { if (asset == ETH) { (bool ok, /* data */) = to.call{ value: amount }(""); - require(ok, "SwapOp: Eth transfer failed"); + if (!ok) { + revert TransferFailed(to, amount, ETH); + } return; } @@ -604,17 +633,23 @@ contract SwapOperator is ISwapOperator { function recoverAsset(address assetAddress, address receiver) public onlyController { // Validate there's no current cow swap order going on - require(!orderInProgress(), "SwapOp: an order is already in place"); + if (orderInProgress()) { + revert OrderInProgress(currentOrderUID); + } IPool pool = _pool(); if (assetAddress == ETH) { uint ethBalance = address(this).balance; - require(ethBalance > 0, "SwapOp: ETH balance to recover is 0"); + if (ethBalance == 0) { + revert InvalidBalance(ethBalance, 0, 'min'); + } // We assume ETH is always supported so we directly transfer it back to the Pool (bool sent, ) = payable(address(pool)).call{value: ethBalance}(""); - require(sent, "SwapOp: Failed to send Ether to pool"); + if (!sent) { + revert TransferFailed(address(pool), ethBalance, ETH); + } return; } @@ -622,7 +657,9 @@ contract SwapOperator is ISwapOperator { IERC20 asset = IERC20(assetAddress); uint balance = asset.balanceOf(address(this)); - require(balance > 0, "SwapOp: Balance = 0"); + if (balance == 0) { + revert InvalidBalance(balance, 0, 'min'); + } SwapDetails memory swapDetails = pool.getAssetSwapDetails(assetAddress); From 8274c01ccfbc98902122478dbc5d6e02d07f14bc Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 20 Mar 2024 15:33:38 +0200 Subject: [PATCH 56/88] Fix InvalidBalance limit type param --- contracts/modules/capital/SwapOperator.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 72bdba1600..665392094f 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -175,14 +175,14 @@ contract SwapOperator is ISwapOperator { if (swapOperationType == SwapOperationType.EthToAsset) { uint ethPostSwap = address(pool).balance - totalOutAmount; if (ethPostSwap < minPoolEth) { - revert EthReserveBelowMin(ethPostSwap, minPoolEth); + revert InvalidPostSwapBalance(ethPostSwap, minPoolEth, 'min'); } // skip sellSwapDetails validation for ETH/WETH since it does not have set swapDetails return; } if (sellTokenBalance <= sellSwapDetails.maxAmount) { - revert InvalidBalance(sellTokenBalance, sellSwapDetails.maxAmount, 'max'); + revert InvalidBalance(sellTokenBalance, sellSwapDetails.maxAmount, 'min'); } // NOTE: the totalOutAmount (i.e. sellAmount + fee) is used to get postSellTokenSwapBalance uint postSellTokenSwapBalance = sellTokenBalance - totalOutAmount; @@ -208,7 +208,7 @@ contract SwapOperator is ISwapOperator { } if (buyTokenBalance >= buySwapDetails.minAmount) { - revert InvalidBalance(buyTokenBalance, buySwapDetails.minAmount, 'min'); + revert InvalidBalance(buyTokenBalance, buySwapDetails.minAmount, 'max'); } // NOTE: use order.buyAmount to get postBuyTokenSwapBalance uint postBuyTokenSwapBalance = buyTokenBalance + order.buyAmount; @@ -522,7 +522,7 @@ contract SwapOperator is ISwapOperator { validateAmountOut(amountOut, amountOutMin); if (balanceBefore >= swapDetails.minAmount) { - revert InvalidBalance(balanceBefore, swapDetails.minAmount, 'min'); + revert InvalidBalance(balanceBefore, swapDetails.minAmount, 'max'); } if (balanceBefore + amountOutMin > swapDetails.maxAmount) { revert InvalidPostSwapBalance(balanceBefore + amountOutMin, swapDetails.maxAmount, 'max'); @@ -581,7 +581,7 @@ contract SwapOperator is ISwapOperator { // slippage check validateAmountOut(amountOutMin, minOutOnMaxSlippage); if (balanceBefore <= swapDetails.maxAmount) { - revert InvalidBalance(balanceBefore, swapDetails.maxAmount, 'max'); + revert InvalidBalance(balanceBefore, swapDetails.maxAmount, 'min'); } if (balanceBefore - amountIn < swapDetails.minAmount) { revert InvalidPostSwapBalance(balanceBefore - amountIn, swapDetails.minAmount, 'min'); From c1f0cbbc9e5d1c91e60d13cbf580bac8a846dab5 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 20 Mar 2024 17:51:59 +0200 Subject: [PATCH 57/88] Rename TokenIsDisabled to TokenDisabled --- contracts/interfaces/ISwapOperator.sol | 2 +- contracts/modules/capital/SwapOperator.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index e998db1752..1cf3df2794 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -45,7 +45,7 @@ interface ISwapOperator { error OrderUidMismatch(bytes providedOrderUID, bytes expectedOrderUID); error UnsupportedTokenBalance(string kind); error InvalidReceiver(address validReceiver); - error TokenIsDisabled(address token); + error TokenDisabled(address token); error AmountOutTooLow(uint amountOut, uint minAmount); error InvalidTokenAddress(string token); error InvalidDenominationAsset(address invalidAsset, address validAsset); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 665392094f..bf93b14ead 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -146,7 +146,7 @@ contract SwapOperator is ISwapOperator { /// @dev Reverts if both swapDetails min/maxAmount are set to 0 function validateTokenIsEnabled(address token, SwapDetails memory swapDetails) internal pure { if (swapDetails.minAmount == 0 && swapDetails.maxAmount == 0) { - revert TokenIsDisabled(token); + revert TokenDisabled(token); } } From f45a3550517940f092fbca0846310cb0c2b4cc24 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 20 Mar 2024 17:52:43 +0200 Subject: [PATCH 58/88] Fix unit tests --- test/unit/SwapOperator/closeOrder.js | 8 ++-- test/unit/SwapOperator/placeOrder.js | 41 +++++++++---------- .../swapETHForEnzymeVaultShare.js | 37 ++++++++--------- .../swapEnzymeVaultShareForETH.js | 35 ++++++++-------- 4 files changed, 55 insertions(+), 66 deletions(-) diff --git a/test/unit/SwapOperator/closeOrder.js b/test/unit/SwapOperator/closeOrder.js index 9450d588bf..c4e5a7394e 100644 --- a/test/unit/SwapOperator/closeOrder.js +++ b/test/unit/SwapOperator/closeOrder.js @@ -124,10 +124,8 @@ describe('closeOrder', function () { // Executing as non-controller should fail await setNextBlockTime(deadline); - await expect(swapOperator.connect(governance).closeOrder(contractOrder)).to.be.revertedWith( - 'SwapOp: only controller can execute', - ); - + const placeOrder = swapOperator.connect(governance).closeOrder(contractOrder); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OnlyController'); // Executing as controller should succeed await revertToSnapshot(snapshot); await setNextBlockTime(deadline); @@ -187,7 +185,7 @@ describe('closeOrder', function () { // cancel the current order, leaving no order in place await expect(swapOperator.closeOrder(contractOrder)).to.not.be.reverted; - await expect(swapOperator.closeOrder(contractOrder)).to.be.revertedWith('SwapOp: No order in place'); + await expect(swapOperator.closeOrder(contractOrder)).to.be.revertedWithCustomError(swapOperator, 'NoOrderInPlace'); }); it('invalidates order and sets allowance back to 0 when order was not filled at all', async function () { diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index 4daaee8583..a2ceae1449 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -165,9 +165,8 @@ describe('placeOrder', function () { const { swapOperator } = contracts; // call with non-controller, should fail - await expect(swapOperator.connect(governance).placeOrder(contractOrder, orderUID)).to.revertedWith( - 'SwapOp: only controller can execute', - ); + const placeOrder = swapOperator.connect(governance).placeOrder(contractOrder, orderUID); + await expect(placeOrder).to.revertedWithCustomError(swapOperator, 'OnlyController'); // call with controller, should succeed await swapOperator.connect(controller).placeOrder(contractOrder, orderUID); @@ -318,9 +317,7 @@ describe('placeOrder', function () { // Order selling stEth should fail const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); - await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'OrderTokenIsDisabled') - .withArgs(stEth.address); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'TokenDisabled').withArgs(stEth.address); }); it('only allows to sell when sellToken balance is above asset maxAmount - ASSET -> WETH', async function () { @@ -332,7 +329,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMaxAmount, daiMaxAmount, 'max'); + .withArgs(daiMaxAmount, daiMaxAmount, 'min'); // When balance > maxAmount, should succeed await dai.setBalance(pool.address, daiMaxAmount.add(1)); @@ -348,7 +345,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(stEthMaxAmount, stEthMaxAmount, 'max'); + .withArgs(stEthMaxAmount, stEthMaxAmount, 'min'); // When balance > maxAmount, should succeed await stEth.setBalance(pool.address, stEthMaxAmount.add(1)); @@ -365,7 +362,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMinAmount, daiMinAmount, 'min'); + .withArgs(daiMinAmount, daiMinAmount, 'max'); // set buyToken balance to be < minAmount, txn should succeed await dai.setBalance(pool.address, daiMinAmount.sub(1)); @@ -427,8 +424,8 @@ describe('placeOrder', function () { const minPoolEth = parseEther('1'); await expect(placeOrder) - .to.revertedWithCustomError(swapOperator, 'EthReserveBelowMin') - .withArgs(ethPostSwap, minPoolEth); + .to.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(ethPostSwap, minPoolEth, 'min'); // Set pool balance to 2 eth, should succeed await setEtherBalance(pool.address, parseEther('2')); @@ -459,7 +456,7 @@ describe('placeOrder', function () { // Order buying DAI should fail const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OrderTokenIsDisabled').withArgs(dai.address); + await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'TokenDisabled').withArgs(dai.address); }); it('only allows to buy when buyToken balance is below minAmount (WETH -> ASSET)', async function () { @@ -472,7 +469,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMinAmount, daiMinAmount, 'min'); + .withArgs(daiMinAmount, daiMinAmount, 'max'); // set buyToken balance to be < minAmount, txn should succeed await dai.setBalance(pool.address, daiMinAmount.sub(1)); @@ -675,7 +672,7 @@ describe('placeOrder', function () { it('when selling ETH, checks that feeAmount is not higher than maxFee', async function () { const { contracts, order, domain } = await loadFixture(placeSellWethOrderSetup); const { swapOperator } = contracts; - const maxFee = await swapOperator.maxFee(); + const maxFee = await swapOperator.MAX_FEE(); // Place order with fee 1 wei higher than maximum, should fail const badOrder = createContractOrder(domain, order, { feeAmount: maxFee.add(1) }); @@ -692,7 +689,7 @@ describe('placeOrder', function () { it('when selling other asset, uses oracle to check fee in ether is not higher than maxFee', async function () { const { contracts, order, domain } = await loadFixture(placeSellDaiOrderSetup); const { swapOperator, priceFeedOracle, dai } = contracts; - const maxFee = await swapOperator.maxFee(); + const maxFee = await swapOperator.MAX_FEE(); const daiToEthRate = await priceFeedOracle.getAssetToEthRate(dai.address); const ethToDaiRate = parseEther('1').div(daiToEthRate); // 1 ETH -> N DAI @@ -720,7 +717,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(buyOracleAmount.sub(1), buyOracleAmount); // Oracle price buyAmount should not revert @@ -745,7 +742,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, badOrderOverrides); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(buyAmountOnePercentSlippage.sub(1), buyAmountOnePercentSlippage); // 1% slippage from oracle buyAmount // Exactly 1% slippage buyAmount should not revert @@ -766,7 +763,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(buyOracleAmount.sub(1), buyOracleAmount); // Oracle price buyAmount should not revert @@ -790,7 +787,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: ethBuyAmountTwoPercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(ethBuyAmountTwoPercentSlippage.sub(1), ethBuyAmountTwoPercentSlippage); // Exactly 2% slippage sellAmount should not revert @@ -811,7 +808,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: buyOracleAmount.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(buyOracleAmount.sub(1), buyOracleAmount); // Oracle price buyAmount should not revert @@ -835,7 +832,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: daiBuyAmountOnePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(daiBuyAmountOnePercentSlippage.sub(1), daiBuyAmountOnePercentSlippage); // Exactly 1% slippage buyAmount should not revert @@ -859,7 +856,7 @@ describe('placeOrder', function () { const badOrder = createContractOrder(domain, order, { buyAmount: stEthBuyAmountThreePercentSlippage.sub(1) }); const placeOrder = swapOperator.placeOrder(badOrder.contractOrder, badOrder.orderUID); await expect(placeOrder) - .to.be.revertedWithCustomError(swapOperator, 'AmountTooLow') + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') .withArgs(stEthBuyAmountThreePercentSlippage.sub(1), stEthBuyAmountThreePercentSlippage); // Exactly 3% slippage sellAmount should not revert diff --git a/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js b/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js index 520e8b7c7f..72e185a397 100644 --- a/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js +++ b/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js @@ -22,12 +22,10 @@ describe('swapETHForEnzymeVaultShare', function () { it('should revert when called by an address that is not swap controller', async function () { const fixture = await loadFixture(setup); const { swapOperator } = fixture.contracts; + const [nobody] = fixture.accounts.nonMembers; - const nobody = fixture.accounts.nonMembers[0]; - - await expect(swapOperator.connect(nobody).swapETHForEnzymeVaultShare('0', '0')).to.be.revertedWith( - 'SwapOp: only controller can execute', - ); + const swap = swapOperator.connect(nobody).swapETHForEnzymeVaultShare('0', '0'); + await expect(swap).to.revertedWithCustomError(swapOperator, 'OnlyController'); }); it('should revert when asset is not enabled', async function () { @@ -42,9 +40,8 @@ describe('swapETHForEnzymeVaultShare', function () { '100', // 1% max slippage ); - await expect(swapOperator.swapETHForEnzymeVaultShare(parseEther('1'), '0')).to.be.revertedWith( - 'SwapOp: asset is not enabled', - ); + const swap = swapOperator.swapETHForEnzymeVaultShare(parseEther('1'), '0'); + await expect(swap).to.be.revertedWithCustomError(swapOperator, 'TokenDisabled').withArgs(enzymeV4Vault.address); }); it('should revert if ether left in pool is less than minPoolEth', async function () { @@ -64,9 +61,9 @@ describe('swapETHForEnzymeVaultShare', function () { const maxPoolTradableEther = currentEther.sub(minPoolEth); // should fail with max + 1 - await expect(swapOperator.swapETHForEnzymeVaultShare(currentEther, currentEther)).to.be.revertedWith( - 'SwapOp: insufficient ether left', - ); + await expect(swapOperator.swapETHForEnzymeVaultShare(currentEther, currentEther)) + .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(0, minPoolEth, 'min'); // should work with max await swapOperator.swapETHForEnzymeVaultShare(maxPoolTradableEther, maxPoolTradableEther); @@ -101,9 +98,9 @@ describe('swapETHForEnzymeVaultShare', function () { // enzyme lowers the rate. await enzymeV4Comptroller.setETHToVaultSharesRate('500'); - await expect(swapOperator.swapETHForEnzymeVaultShare(amountIn, amountIn)).to.be.revertedWith( - 'SwapOp: amountOut < amountOutMin', - ); + await expect(swapOperator.swapETHForEnzymeVaultShare(amountIn, amountIn)) + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') + .withArgs(parseEther('50'), amountIn); }); it('should revert if balanceAfter > max', async function () { @@ -127,9 +124,9 @@ describe('swapETHForEnzymeVaultShare', function () { }); const etherIn = max.add(10001); - await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)).to.be.revertedWith( - 'SwapOp: balanceAfter > max', - ); + await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)) + .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(etherIn, max, 'max'); }); it('should swap asset for eth and emit a Swapped event with correct values', async function () { @@ -295,8 +292,8 @@ describe('swapETHForEnzymeVaultShare', function () { } const etherIn = minAssetAmount.div(2); - await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)).to.be.revertedWith( - 'SwapOp: balanceBefore >= min', - ); + await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(BigNumber.from('116666666666666666666'), minAssetAmount, 'max'); }); }); diff --git a/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js b/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js index 85d779cdd6..8f9524c920 100644 --- a/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js +++ b/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js @@ -29,12 +29,10 @@ describe('swapEnzymeVaultShareForETH', function () { it('should revert when called by an address that is not swap controller', async function () { const fixture = await loadFixture(setup); const { swapOperator } = fixture.contracts; - const nobody = fixture.accounts.nonMembers[0]; - await expect(swapOperator.connect(nobody).swapEnzymeVaultShareForETH('0', '0')).to.be.revertedWith( - 'SwapOp: only controller can execute', - ); + const swap = swapOperator.connect(nobody).swapEnzymeVaultShareForETH('0', '0'); + await expect(swap).to.be.revertedWithCustomError(swapOperator, 'OnlyController'); }); it('should revert when asset is not enabled', async function () { @@ -49,9 +47,8 @@ describe('swapEnzymeVaultShareForETH', function () { '100', // 1% max slippage ); - await expect(swapOperator.swapEnzymeVaultShareForETH(parseEther('1'), '0')).to.be.revertedWith( - 'SwapOp: asset is not enabled', - ); + const swap = swapOperator.swapEnzymeVaultShareForETH(parseEther('1'), '0'); + await expect(swap).to.be.revertedWithCustomError(swapOperator, 'TokenDisabled').withArgs(enzymeV4Vault.address); }); it('should revert if Enzyme does not sent enough shares back', async function () { @@ -74,9 +71,9 @@ describe('swapEnzymeVaultShareForETH', function () { // enzyme lowers the rate. await enzymeV4Comptroller.setETHToVaultSharesRate('20000'); - await expect(swapOperator.swapEnzymeVaultShareForETH(amountIn, amountIn)).to.be.revertedWith( - 'SwapOp: amountOut < amountOutMin', - ); + await expect(swapOperator.swapEnzymeVaultShareForETH(amountIn, amountIn)) + .to.be.revertedWithCustomError(swapOperator, 'AmountOutTooLow') + .withArgs(parseEther('500'), amountIn); }); it('should revert if tokenBalanceAfter < min', async function () { @@ -96,9 +93,9 @@ describe('swapEnzymeVaultShareForETH', function () { await enzymeV4Vault.mint(pool.address, amountInPool); const amountIn = parseEther('1950'); - await expect(swapOperator.swapEnzymeVaultShareForETH(amountIn, amountIn)).to.be.revertedWith( - 'SwapOp: tokenBalanceAfter < min', - ); + await expect(swapOperator.swapEnzymeVaultShareForETH(amountIn, amountIn)) + .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') + .withArgs(parseEther('50'), parseEther('100'), 'min'); }); it('should swap asset for eth and emit a Swapped event with correct values', async function () { @@ -122,20 +119,20 @@ describe('swapEnzymeVaultShareForETH', function () { // amounts in/out of the trade const sharesIn = parseEther('1500'); const minTokenOut = sharesIn.sub(1); - const swapTx = await swapOperator.swapEnzymeVaultShareForETH(sharesIn, sharesIn); + const swap = await swapOperator.swapEnzymeVaultShareForETH(sharesIn, sharesIn); const etherAfter = await ethers.provider.getBalance(pool.address); const tokensAfter = await enzymeV4Vault.balanceOf(pool.address); const etherReceived = etherAfter.sub(etherBefore); const tokensSent = tokensBefore.sub(tokensAfter); - await expect(swapTx).to.emit(swapOperator, 'Swapped').withArgs(enzymeV4Vault.address, ETH, sharesIn, tokensSent); + await expect(swap).to.emit(swapOperator, 'Swapped').withArgs(enzymeV4Vault.address, ETH, sharesIn, tokensSent); expect(etherReceived).to.be.equal(sharesIn); expect(tokensSent).to.be.greaterThanOrEqual(minTokenOut); }); - it('reverts if another balanceBefore <= max', async function () { + it('reverts if the balanceBefore <= max', async function () { const fixture = await loadFixture(setup); const { pool, swapOperator, enzymeV4Vault } = fixture.contracts; @@ -154,8 +151,8 @@ describe('swapEnzymeVaultShareForETH', function () { // amounts in/out of the trade const sharesIn = parseEther('400'); - await expect(swapOperator.swapEnzymeVaultShareForETH(sharesIn, sharesIn)).to.be.revertedWith( - 'SwapOp: balanceBefore <= max', - ); + await expect(swapOperator.swapEnzymeVaultShareForETH(sharesIn, sharesIn)) + .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') + .withArgs(parseEther('500'), parseEther('1000'), 'min'); }); }); From 71ad77fc605888506894c77ee138bd6744624235 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 21 Mar 2024 16:04:16 +0200 Subject: [PATCH 59/88] Drop limitType parameter on error * InvalidBalance * InvalidPostSwapBalance --- contracts/interfaces/ISwapOperator.sol | 4 ++-- contracts/modules/capital/SwapOperator.sol | 24 +++++++++---------- test/unit/SwapOperator/placeOrder.js | 14 +++++------ .../swapETHForEnzymeVaultShare.js | 6 ++--- .../swapEnzymeVaultShareForETH.js | 4 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 1cf3df2794..1be6de61c8 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -55,8 +55,8 @@ interface ISwapOperator { error AboveMaxValidTo(uint maxValidTo); // Balance - error InvalidBalance(uint tokenBalance, uint limit, string limitType); - error InvalidPostSwapBalance(uint postSwapBalance, uint limit, string limitType); + error InvalidBalance(uint tokenBalance, uint limit); + error InvalidPostSwapBalance(uint postSwapBalance, uint limit); // Access Controls error OnlyController(); diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index bf93b14ead..b305f7eb51 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -175,19 +175,19 @@ contract SwapOperator is ISwapOperator { if (swapOperationType == SwapOperationType.EthToAsset) { uint ethPostSwap = address(pool).balance - totalOutAmount; if (ethPostSwap < minPoolEth) { - revert InvalidPostSwapBalance(ethPostSwap, minPoolEth, 'min'); + revert InvalidPostSwapBalance(ethPostSwap, minPoolEth); } // skip sellSwapDetails validation for ETH/WETH since it does not have set swapDetails return; } if (sellTokenBalance <= sellSwapDetails.maxAmount) { - revert InvalidBalance(sellTokenBalance, sellSwapDetails.maxAmount, 'min'); + revert InvalidBalance(sellTokenBalance, sellSwapDetails.maxAmount); } // NOTE: the totalOutAmount (i.e. sellAmount + fee) is used to get postSellTokenSwapBalance uint postSellTokenSwapBalance = sellTokenBalance - totalOutAmount; if (postSellTokenSwapBalance < sellSwapDetails.minAmount) { - revert InvalidPostSwapBalance(postSellTokenSwapBalance, sellSwapDetails.minAmount, 'min'); + revert InvalidPostSwapBalance(postSellTokenSwapBalance, sellSwapDetails.minAmount); } } @@ -208,12 +208,12 @@ contract SwapOperator is ISwapOperator { } if (buyTokenBalance >= buySwapDetails.minAmount) { - revert InvalidBalance(buyTokenBalance, buySwapDetails.minAmount, 'max'); + revert InvalidBalance(buyTokenBalance, buySwapDetails.minAmount); } // NOTE: use order.buyAmount to get postBuyTokenSwapBalance uint postBuyTokenSwapBalance = buyTokenBalance + order.buyAmount; if (postBuyTokenSwapBalance > buySwapDetails.maxAmount) { - revert InvalidPostSwapBalance(postBuyTokenSwapBalance, buySwapDetails.maxAmount, 'max'); + revert InvalidPostSwapBalance(postBuyTokenSwapBalance, buySwapDetails.maxAmount); } } @@ -522,15 +522,15 @@ contract SwapOperator is ISwapOperator { validateAmountOut(amountOut, amountOutMin); if (balanceBefore >= swapDetails.minAmount) { - revert InvalidBalance(balanceBefore, swapDetails.minAmount, 'max'); + revert InvalidBalance(balanceBefore, swapDetails.minAmount); } if (balanceBefore + amountOutMin > swapDetails.maxAmount) { - revert InvalidPostSwapBalance(balanceBefore + amountOutMin, swapDetails.maxAmount, 'max'); + revert InvalidPostSwapBalance(balanceBefore + amountOutMin, swapDetails.maxAmount); } uint ethBalanceAfter = address(pool).balance; if (ethBalanceAfter < minPoolEth) { - revert InvalidPostSwapBalance(ethBalanceAfter, minPoolEth, 'min'); + revert InvalidPostSwapBalance(ethBalanceAfter, minPoolEth); } transferAssetTo(enzymeV4VaultProxyAddress, address(pool), amountOut); @@ -581,10 +581,10 @@ contract SwapOperator is ISwapOperator { // slippage check validateAmountOut(amountOutMin, minOutOnMaxSlippage); if (balanceBefore <= swapDetails.maxAmount) { - revert InvalidBalance(balanceBefore, swapDetails.maxAmount, 'min'); + revert InvalidBalance(balanceBefore, swapDetails.maxAmount); } if (balanceBefore - amountIn < swapDetails.minAmount) { - revert InvalidPostSwapBalance(balanceBefore - amountIn, swapDetails.minAmount, 'min'); + revert InvalidPostSwapBalance(balanceBefore - amountIn, swapDetails.minAmount); } } @@ -642,7 +642,7 @@ contract SwapOperator is ISwapOperator { if (assetAddress == ETH) { uint ethBalance = address(this).balance; if (ethBalance == 0) { - revert InvalidBalance(ethBalance, 0, 'min'); + revert InvalidBalance(ethBalance, 0); } // We assume ETH is always supported so we directly transfer it back to the Pool @@ -658,7 +658,7 @@ contract SwapOperator is ISwapOperator { uint balance = asset.balanceOf(address(this)); if (balance == 0) { - revert InvalidBalance(balance, 0, 'min'); + revert InvalidBalance(balance, 0); } SwapDetails memory swapDetails = pool.getAssetSwapDetails(assetAddress); diff --git a/test/unit/SwapOperator/placeOrder.js b/test/unit/SwapOperator/placeOrder.js index a2ceae1449..76e78b79b9 100644 --- a/test/unit/SwapOperator/placeOrder.js +++ b/test/unit/SwapOperator/placeOrder.js @@ -329,7 +329,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMaxAmount, daiMaxAmount, 'min'); + .withArgs(daiMaxAmount, daiMaxAmount); // When balance > maxAmount, should succeed await dai.setBalance(pool.address, daiMaxAmount.add(1)); @@ -345,7 +345,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(stEthMaxAmount, stEthMaxAmount, 'min'); + .withArgs(stEthMaxAmount, stEthMaxAmount); // When balance > maxAmount, should succeed await stEth.setBalance(pool.address, stEthMaxAmount.add(1)); @@ -362,7 +362,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMinAmount, daiMinAmount, 'max'); + .withArgs(daiMinAmount, daiMinAmount); // set buyToken balance to be < minAmount, txn should succeed await dai.setBalance(pool.address, daiMinAmount.sub(1)); @@ -386,7 +386,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(invalidBalance.sub(totalOutAmount), daiMinAmount, 'min'); + .withArgs(invalidBalance.sub(totalOutAmount), daiMinAmount); // Set balance so it can exactly cover totalOutAmount await dai.setBalance(pool.address, daiMinAmount.add(totalOutAmount)); @@ -425,7 +425,7 @@ describe('placeOrder', function () { await expect(placeOrder) .to.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(ethPostSwap, minPoolEth, 'min'); + .withArgs(ethPostSwap, minPoolEth); // Set pool balance to 2 eth, should succeed await setEtherBalance(pool.address, parseEther('2')); @@ -469,7 +469,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(contractOrder, orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(daiMinAmount, daiMinAmount, 'max'); + .withArgs(daiMinAmount, daiMinAmount); // set buyToken balance to be < minAmount, txn should succeed await dai.setBalance(pool.address, daiMinAmount.sub(1)); @@ -488,7 +488,7 @@ describe('placeOrder', function () { const placeOrder = swapOperator.placeOrder(exceedsMaxOrder.contractOrder, exceedsMaxOrder.orderUID); await expect(placeOrder) .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(order.buyAmount, daiMaxAmount, 'max'); + .withArgs(order.buyAmount, daiMaxAmount); // place an order that will bring balance exactly to maxAmount, should succeed const withinMaxOrder = createContractOrder(domain, order, { buyAmount: daiMaxAmount }); diff --git a/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js b/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js index 72e185a397..d148a318fd 100644 --- a/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js +++ b/test/unit/SwapOperator/swapETHForEnzymeVaultShare.js @@ -63,7 +63,7 @@ describe('swapETHForEnzymeVaultShare', function () { // should fail with max + 1 await expect(swapOperator.swapETHForEnzymeVaultShare(currentEther, currentEther)) .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(0, minPoolEth, 'min'); + .withArgs(0, minPoolEth); // should work with max await swapOperator.swapETHForEnzymeVaultShare(maxPoolTradableEther, maxPoolTradableEther); @@ -126,7 +126,7 @@ describe('swapETHForEnzymeVaultShare', function () { const etherIn = max.add(10001); await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)) .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(etherIn, max, 'max'); + .withArgs(etherIn, max); }); it('should swap asset for eth and emit a Swapped event with correct values', async function () { @@ -294,6 +294,6 @@ describe('swapETHForEnzymeVaultShare', function () { const etherIn = minAssetAmount.div(2); await expect(swapOperator.swapETHForEnzymeVaultShare(etherIn, etherIn)) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(BigNumber.from('116666666666666666666'), minAssetAmount, 'max'); + .withArgs(BigNumber.from('116666666666666666666'), minAssetAmount); }); }); diff --git a/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js b/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js index 8f9524c920..1a6f84ba2a 100644 --- a/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js +++ b/test/unit/SwapOperator/swapEnzymeVaultShareForETH.js @@ -95,7 +95,7 @@ describe('swapEnzymeVaultShareForETH', function () { const amountIn = parseEther('1950'); await expect(swapOperator.swapEnzymeVaultShareForETH(amountIn, amountIn)) .to.be.revertedWithCustomError(swapOperator, 'InvalidPostSwapBalance') - .withArgs(parseEther('50'), parseEther('100'), 'min'); + .withArgs(parseEther('50'), parseEther('100')); }); it('should swap asset for eth and emit a Swapped event with correct values', async function () { @@ -153,6 +153,6 @@ describe('swapEnzymeVaultShareForETH', function () { await expect(swapOperator.swapEnzymeVaultShareForETH(sharesIn, sharesIn)) .to.be.revertedWithCustomError(swapOperator, 'InvalidBalance') - .withArgs(parseEther('500'), parseEther('1000'), 'min'); + .withArgs(parseEther('500'), parseEther('1000')); }); }); From 93ad0d9cb840662dd4a9e994f77a194b7bf4e862 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 22 Mar 2024 10:28:51 +0200 Subject: [PATCH 60/88] Add removal of signature on cancelOrder unit tests --- test/unit/SwapOperator/closeOrder.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/test/unit/SwapOperator/closeOrder.js b/test/unit/SwapOperator/closeOrder.js index c4e5a7394e..488b4a9e06 100644 --- a/test/unit/SwapOperator/closeOrder.js +++ b/test/unit/SwapOperator/closeOrder.js @@ -124,8 +124,8 @@ describe('closeOrder', function () { // Executing as non-controller should fail await setNextBlockTime(deadline); - const placeOrder = swapOperator.connect(governance).closeOrder(contractOrder); - await expect(placeOrder).to.be.revertedWithCustomError(swapOperator, 'OnlyController'); + const closeOrder = swapOperator.connect(governance).closeOrder(contractOrder); + await expect(closeOrder).to.be.revertedWithCustomError(swapOperator, 'OnlyController'); // Executing as controller should succeed await revertToSnapshot(snapshot); await setNextBlockTime(deadline); @@ -188,13 +188,14 @@ describe('closeOrder', function () { await expect(swapOperator.closeOrder(contractOrder)).to.be.revertedWithCustomError(swapOperator, 'NoOrderInPlace'); }); - it('invalidates order and sets allowance back to 0 when order was not filled at all', async function () { + it('cancels order and removes signature and allowance when order was not filled at all', async function () { const { contracts: { swapOperator, cowSettlement, weth, cowVaultRelayer }, contractOrder, orderUID, order, } = await loadFixture(closeOrderSetup); + expect(await cowSettlement.presignatures(orderUID)).to.equal(true); expect(await cowSettlement.filledAmount(orderUID)).to.equal(0); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.add(order.feeAmount), @@ -202,12 +203,14 @@ describe('closeOrder', function () { await swapOperator.closeOrder(contractOrder); - // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + // order is cancelled when filledAmount is set to MaxUint256 expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); + // removes signature and allowance + expect(await cowSettlement.presignatures(orderUID)).to.equal(false); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); - it('invalidates order and sets allowance back to 0 when the order is partially filled', async function () { + it('cancels order and removes signature and allowance when the order is partially filled', async function () { const { contracts: { swapOperator, cowSettlement, weth, dai, cowVaultRelayer }, contractOrder, @@ -231,7 +234,8 @@ describe('closeOrder', function () { expect(await dai.balanceOf(swapOperator.address)).to.gt(0); expect(await weth.balanceOf(swapOperator.address)).to.gt(0); - // fill amount still partially filled, allowance was decreased + // presignature still exists, order not cancelled and allowance was decreased + expect(await cowSettlement.presignatures(orderUID)).to.equal(true); expect(await cowSettlement.filledAmount(orderUID)).to.equal(order.sellAmount.div(2)); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.div(2).add(order.feeAmount.div(2)), @@ -239,18 +243,21 @@ describe('closeOrder', function () { await swapOperator.closeOrder(contractOrder); - // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + // order is cancelled when filledAmount is set to MaxUint256 / 0 allowance expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); + // removes signature and allowance + expect(await cowSettlement.presignatures(orderUID)).to.equal(false); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); - it('invalidates order and sets allowance back to 0 when the order is fully filled', async function () { + it('cancels order and removes signature and allowance when the order is fully filled', async function () { const { contracts: { swapOperator, weth, dai, cowSettlement, cowVaultRelayer }, contractOrder, orderUID, order, } = await loadFixture(closeOrderSetup); + expect(await cowSettlement.presignatures(orderUID)).to.equal(true); expect(await cowSettlement.filledAmount(orderUID)).to.equal(0); expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq( order.sellAmount.add(order.feeAmount), @@ -267,8 +274,9 @@ describe('closeOrder', function () { await swapOperator.closeOrder(contractOrder); - // order is invalidated when filledAmount is set to MaxUint256 / 0 allowance + // order is cancelled when filledAmount is set to MaxUint256 / 0 allowance expect(await cowSettlement.filledAmount(orderUID)).to.equal(MaxUint256); + // removes signature and allowance expect(await weth.allowance(swapOperator.address, cowVaultRelayer.address)).to.eq(0); }); From 81487e9f553848a8d70a8f448dc710bbc227d906 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 26 Mar 2024 17:15:21 +0200 Subject: [PATCH 61/88] Pass in priceFeedOracle to validateMaxFee and executeAssetTransfer --- contracts/modules/capital/SwapOperator.sol | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index b305f7eb51..d278b7484f 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -232,6 +232,7 @@ contract SwapOperator is ISwapOperator { /// @dev Performs pre-swap validation checks for the given order function performPreSwapValidations( IPool pool, + IPriceFeedOracle priceFeedOracle, GPv2Order.Data calldata order, SwapOperationType swapOperationType, uint totalOutAmount @@ -253,7 +254,7 @@ contract SwapOperator is ISwapOperator { validateSwapFrequency(buySwapDetails); // validate max fee and max slippage - validateMaxFee(pool, address(order.sellToken), order.feeAmount); + validateMaxFee(priceFeedOracle, address(order.sellToken), order.feeAmount); validateOrderAmount(order, sellSwapDetails, buySwapDetails); } @@ -261,11 +262,11 @@ contract SwapOperator is ISwapOperator { /// Additionally if selling ETH, wraps received Pool ETH to WETH function executeAssetTransfer( IPool pool, + IPriceFeedOracle priceFeedOracle, GPv2Order.Data calldata order, SwapOperationType swapOperationType, uint totalOutAmount ) internal returns (uint swapValueEth) { - IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); address sellTokenAddress = address(order.sellToken); address buyTokenAddress = address(order.buyToken); @@ -312,14 +313,15 @@ contract SwapOperator is ISwapOperator { validateBasicCowParams(order); IPool pool = _pool(); + IPriceFeedOracle priceFeedOracle = pool.priceFeedOracle(); uint totalOutAmount = order.sellAmount + order.feeAmount; SwapOperationType swapOperationType = getSwapOperationType(order); // Perform validations - performPreSwapValidations(pool, order, swapOperationType, totalOutAmount); + performPreSwapValidations(pool, priceFeedOracle, order, swapOperationType, totalOutAmount); // Execute swap based on operation type - uint swapValueEth = executeAssetTransfer(pool, order, swapOperationType, totalOutAmount); + uint swapValueEth = executeAssetTransfer(pool, priceFeedOracle, order, swapOperationType, totalOutAmount); // Set the swapValue on the pool pool.setSwapValue(swapValueEth); @@ -461,13 +463,13 @@ contract SwapOperator is ISwapOperator { /// @param sellToken The sell asset /// @param feeAmount The fee (will always be denominated in the sell asset units) function validateMaxFee( - IPool pool, + IPriceFeedOracle priceFeedOracle, address sellToken, uint feeAmount ) internal view { uint feeInEther = sellToken == address(weth) ? feeAmount - : pool.priceFeedOracle().getEthForAsset(sellToken, feeAmount); + : priceFeedOracle.getEthForAsset(sellToken, feeAmount); if (feeInEther > MAX_FEE) { revert AboveMaxFee(feeInEther, MAX_FEE); } From 69bc86e26ecbac1770cfb228cd29eb4b600b9b13 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 28 Mar 2024 18:07:48 +0200 Subject: [PATCH 62/88] Add ICoverBroker --- contracts/interfaces/ICoverBroker.sol | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 contracts/interfaces/ICoverBroker.sol diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol new file mode 100644 index 0000000000..3d710e7aa0 --- /dev/null +++ b/contracts/interfaces/ICoverBroker.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.18; + +import "../interfaces/ICover.sol"; + +interface ICoverBroker { + /* ==== FUNCTIONS ==== */ + + function buyCover( + BuyCoverParams memory params, + PoolAllocationRequest[] calldata poolAllocationRequests + ) external payable returns (uint coverId); + + function switchMembership(address newAddress) external; + + function transferFunds(address assetAddress) external; + + /* ==== ERRORS ==== */ + + error OnlyOwner(); + error TransferFailed(address to, uint value, address token); + error ZeroBalance(address token); +} From a55846636414c4c1f2f9dd9689930f1b584a593a Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Thu, 28 Mar 2024 18:10:28 +0200 Subject: [PATCH 63/88] Add CoverBroker contract --- contracts/external/cover/CoverBroker.sol | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 contracts/external/cover/CoverBroker.sol diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol new file mode 100644 index 0000000000..b947e73127 --- /dev/null +++ b/contracts/external/cover/CoverBroker.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.18; + +import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts-v4/security/ReentrancyGuard.sol"; + +import "../../interfaces/ICover.sol"; +import "../../interfaces/ICoverBroker.sol"; +import "../../interfaces/IMemberRoles.sol"; + +/// @dev Allows cover distribution by buying cover in behalf of the caller +contract CoverBroker is ICoverBroker, ReentrancyGuard { + + // Immutables + address owner; + ICover cover; + IMemberRoles memberRoles; + + // Constants + address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + modifier onlyOwner() { + if (msg.sender != owner) { + revert OnlyOwner(); + } + _; + } + + constructor(address _owner, address _cover, address _memberRoles) { + owner = _owner; + cover = ICover(_cover); + memberRoles = IMemberRoles(_memberRoles); + } + + /// @dev buys cover in behalf of the caller + function buyCover( + BuyCoverParams memory params, + PoolAllocationRequest[] calldata poolAllocationRequests + ) external payable nonReentrant returns (uint coverId) { + uint ethBalanceBefore = address(this).balance; + + // set the cover owner to the caller + params.owner = msg.sender; + + // call cover.buyCover with msg.value and params + coverId = cover.buyCover{value: msg.value}(params, poolAllocationRequests); + + if (address(this).balance > ethBalanceBefore) { + // send back any ETH refund to user + unchecked { + uint ethRefund = address(this).balance - ethBalanceBefore; + (bool sent, ) = payable(msg.sender).call{value: ethRefund}(""); + if (!sent) { + revert TransferFailed(msg.sender, ethRefund, ETH); + } + } + } + } + + /// @dev switches the membership to the given address + function switchMembership(address newAddress) external onlyOwner { + memberRoles.switchMembership(newAddress); + } + + /// @dev transfers available funds to owner + function transferFunds(address assetAddress) external onlyOwner { + if (assetAddress == ETH) { + uint ethBalance = address(this).balance; + if (ethBalance == 0) { + revert ZeroBalance(ETH); + } + + (bool sent, ) = payable(owner).call{value: ethBalance}(""); + if (!sent) { + revert TransferFailed(owner, ethBalance, ETH); + } + + return; + } + + IERC20 asset = IERC20(assetAddress); + uint erc20Balance = asset.balanceOf(address(this)); + if (erc20Balance == 0) { + revert ZeroBalance(assetAddress); + } + + asset.transfer(owner, erc20Balance); + } +} From bdc72ea5016fe338c81749ebae8ecce650c6120f Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Sun, 31 Mar 2024 23:14:22 +0300 Subject: [PATCH 64/88] CoverBroker to inherit Ownable.sol --- contracts/external/cover/CoverBroker.sol | 15 +++------------ contracts/interfaces/ICoverBroker.sol | 1 - 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index b947e73127..50a8a403dd 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -3,32 +3,23 @@ pragma solidity ^0.8.18; import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts-v4/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts-v4/access/Ownable.sol"; import "../../interfaces/ICover.sol"; import "../../interfaces/ICoverBroker.sol"; import "../../interfaces/IMemberRoles.sol"; /// @dev Allows cover distribution by buying cover in behalf of the caller -contract CoverBroker is ICoverBroker, ReentrancyGuard { +contract CoverBroker is ICoverBroker, Ownable { // Immutables - address owner; ICover cover; IMemberRoles memberRoles; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - modifier onlyOwner() { - if (msg.sender != owner) { - revert OnlyOwner(); - } - _; - } - - constructor(address _owner, address _cover, address _memberRoles) { - owner = _owner; + constructor(address _cover, address _memberRoles) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); } diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol index 3d710e7aa0..5ec5946630 100644 --- a/contracts/interfaces/ICoverBroker.sol +++ b/contracts/interfaces/ICoverBroker.sol @@ -18,7 +18,6 @@ interface ICoverBroker { /* ==== ERRORS ==== */ - error OnlyOwner(); error TransferFailed(address to, uint value, address token); error ZeroBalance(address token); } From e539e1fccc48f7f14972ce7e40973a4b173b10ad Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 2 Apr 2024 18:18:53 +0300 Subject: [PATCH 65/88] Add CoverBroker.buyCover ERC20 payment support + add new method * maxApproveCoverContract --- contracts/external/cover/CoverBroker.sol | 73 ++++++++++++++++++------ contracts/interfaces/ICoverBroker.sol | 7 ++- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 50a8a403dd..df5b368076 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -3,68 +3,103 @@ pragma solidity ^0.8.18; import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts-v4/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts-v4/access/Ownable.sol"; import "../../interfaces/ICover.sol"; import "../../interfaces/ICoverBroker.sol"; import "../../interfaces/IMemberRoles.sol"; +import "../../interfaces/IPool.sol"; /// @dev Allows cover distribution by buying cover in behalf of the caller contract CoverBroker is ICoverBroker, Ownable { + using SafeERC20 for IERC20; // Immutables ICover cover; IMemberRoles memberRoles; + IPool pool; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint private constant ETH_ASSET_ID = 0; - constructor(address _cover, address _memberRoles) { + constructor(address _cover, address _memberRoles, address _pool) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); + pool = IPool(_pool); } - /// @dev buys cover in behalf of the caller + /// @dev Buys cover in behalf of the caller + /// @notice for ERC20 payments, the cover contract must be first approved for ERC20 spending using maxApproveCoverContract function buyCover( - BuyCoverParams memory params, + BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests - ) external payable nonReentrant returns (uint coverId) { - uint ethBalanceBefore = address(this).balance; + ) external payable returns (uint coverId) { - // set the cover owner to the caller - params.owner = msg.sender; + // ETH payment - // call cover.buyCover with msg.value and params - coverId = cover.buyCover{value: msg.value}(params, poolAllocationRequests); + if (params.paymentAsset == ETH_ASSET_ID) { + uint ethBalanceBefore = address(this).balance - msg.value; + coverId = cover.buyCover{value: msg.value}(params, poolAllocationRequests); + uint ethBalanceAfter = address(this).balance; - if (address(this).balance > ethBalanceBefore) { - // send back any ETH refund to user - unchecked { - uint ethRefund = address(this).balance - ethBalanceBefore; + // send any ETH refund back to msg.sender + if (ethBalanceAfter > ethBalanceBefore) { + uint ethRefund = ethBalanceAfter - ethBalanceBefore; (bool sent, ) = payable(msg.sender).call{value: ethRefund}(""); if (!sent) { revert TransferFailed(msg.sender, ethRefund, ETH); } } + + return coverId; + } + + // ERC20 payment + + if (msg.value > 0) { + // msg.value must be 0 if ERC20 payment + revert InvalidPayment(); + } + + address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; + IERC20 token = IERC20(paymentAsset); + uint erc20BalanceBefore = token.balanceOf(address(this)); + + token.safeTransferFrom(msg.sender, address(this), params.maxPremiumInAsset); + coverId = cover.buyCover(params, poolAllocationRequests); + + // send any ERC20 refund back to msg.sender + uint erc20BalanceAfter = token.balanceOf(address(this)); + if (erc20BalanceAfter > erc20BalanceBefore) { + uint erc20Refund = erc20BalanceAfter - erc20BalanceBefore; + token.safeTransfer(msg.sender, erc20Refund); } } - /// @dev switches the membership to the given address + /// @dev Approves cover contract to spend max value of the given ERC20 token in behalf of CoverBroker + function maxApproveCoverContract(IERC20 token) external onlyOwner { + token.safeApprove(address(cover), type(uint256).max); + } + + /// @dev Switches the membership to the given address function switchMembership(address newAddress) external onlyOwner { memberRoles.switchMembership(newAddress); } - /// @dev transfers available funds to owner + /// @dev Transfers available funds of the specified asset to owner function transferFunds(address assetAddress) external onlyOwner { + if (assetAddress == ETH) { uint ethBalance = address(this).balance; if (ethBalance == 0) { revert ZeroBalance(ETH); } - (bool sent, ) = payable(owner).call{value: ethBalance}(""); + (bool sent, ) = payable(msg.sender).call{value: ethBalance}(""); if (!sent) { - revert TransferFailed(owner, ethBalance, ETH); + revert TransferFailed(msg.sender, ethBalance, ETH); } return; @@ -76,6 +111,8 @@ contract CoverBroker is ICoverBroker, Ownable { revert ZeroBalance(assetAddress); } - asset.transfer(owner, erc20Balance); + asset.transfer(msg.sender, erc20Balance); } + + receive() external payable {} } diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol index 5ec5946630..887b93bb57 100644 --- a/contracts/interfaces/ICoverBroker.sol +++ b/contracts/interfaces/ICoverBroker.sol @@ -2,16 +2,20 @@ pragma solidity ^0.8.18; +import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; + import "../interfaces/ICover.sol"; interface ICoverBroker { /* ==== FUNCTIONS ==== */ function buyCover( - BuyCoverParams memory params, + BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests ) external payable returns (uint coverId); + function maxApproveCoverContract(IERC20 token) external; + function switchMembership(address newAddress) external; function transferFunds(address assetAddress) external; @@ -20,4 +24,5 @@ interface ICoverBroker { error TransferFailed(address to, uint value, address token); error ZeroBalance(address token); + error InvalidPayment(); } From e7067d4cdde44db6a3e0de32c1e04f611fda991c Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 10:53:48 +0300 Subject: [PATCH 66/88] Add NXM approval for MemberRoles on switchMembership --- contracts/external/cover/CoverBroker.sol | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index df5b368076..4b93a3177d 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -10,6 +10,8 @@ import "../../interfaces/ICover.sol"; import "../../interfaces/ICoverBroker.sol"; import "../../interfaces/IMemberRoles.sol"; import "../../interfaces/IPool.sol"; +import "../../interfaces/INXMToken.sol"; + /// @dev Allows cover distribution by buying cover in behalf of the caller contract CoverBroker is ICoverBroker, Ownable { @@ -19,15 +21,17 @@ contract CoverBroker is ICoverBroker, Ownable { ICover cover; IMemberRoles memberRoles; IPool pool; + INXMToken nxmToken; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint private constant ETH_ASSET_ID = 0; - constructor(address _cover, address _memberRoles, address _pool) { + constructor(address _cover, address _memberRoles, address _pool, address _nxmToken) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); pool = IPool(_pool); + nxmToken = INXMToken(_nxmToken); } /// @dev Buys cover in behalf of the caller @@ -64,27 +68,28 @@ contract CoverBroker is ICoverBroker, Ownable { } address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; - IERC20 token = IERC20(paymentAsset); - uint erc20BalanceBefore = token.balanceOf(address(this)); + IERC20 erc20 = IERC20(paymentAsset); + uint erc20BalanceBefore = erc20.balanceOf(address(this)); - token.safeTransferFrom(msg.sender, address(this), params.maxPremiumInAsset); + erc20.safeTransferFrom(msg.sender, address(this), params.maxPremiumInAsset); coverId = cover.buyCover(params, poolAllocationRequests); // send any ERC20 refund back to msg.sender - uint erc20BalanceAfter = token.balanceOf(address(this)); + uint erc20BalanceAfter = erc20.balanceOf(address(this)); if (erc20BalanceAfter > erc20BalanceBefore) { uint erc20Refund = erc20BalanceAfter - erc20BalanceBefore; - token.safeTransfer(msg.sender, erc20Refund); + erc20.safeTransfer(msg.sender, erc20Refund); } } /// @dev Approves cover contract to spend max value of the given ERC20 token in behalf of CoverBroker - function maxApproveCoverContract(IERC20 token) external onlyOwner { - token.safeApprove(address(cover), type(uint256).max); + function maxApproveCoverContract(IERC20 erc20) external onlyOwner { + erc20.safeApprove(address(cover), type(uint256).max); } /// @dev Switches the membership to the given address function switchMembership(address newAddress) external onlyOwner { + nxmToken.approve(address(memberRoles), type(uint256).max); memberRoles.switchMembership(newAddress); } From 6a755c2223f75816003c0c7555b4a1eec760c971 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 13:14:41 +0300 Subject: [PATCH 67/88] Add buyCover NXM payment support --- contracts/external/cover/CoverBroker.sol | 32 ++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 4b93a3177d..cba9e30eed 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -18,14 +18,15 @@ contract CoverBroker is ICoverBroker, Ownable { using SafeERC20 for IERC20; // Immutables - ICover cover; - IMemberRoles memberRoles; - IPool pool; - INXMToken nxmToken; + ICover public immutable cover; + IMemberRoles public immutable memberRoles; + IPool public immutable pool; + INXMToken public immutable nxmToken; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint private constant ETH_ASSET_ID = 0; + uint private constant NXM_ASSET_ID = type(uint8).max; constructor(address _cover, address _memberRoles, address _pool, address _nxmToken) { cover = ICover(_cover); @@ -60,13 +61,30 @@ contract CoverBroker is ICoverBroker, Ownable { return coverId; } - // ERC20 payment - if (msg.value > 0) { - // msg.value must be 0 if ERC20 payment + // msg.value must be 0 if not ETH payment revert InvalidPayment(); } + // NXM payment + + if (params.paymentAsset == NXM_ASSET_ID) { + uint nxmBalanceBefore = nxmToken.balanceOf(address(this)); + + nxmToken.transferFrom(msg.sender, address(this), params.maxPremiumInAsset); + coverId = cover.buyCover(params, poolAllocationRequests); + + // send any NXM refund back to msg.sender + uint nxmBalanceAfter = nxmToken.balanceOf(address(this)); + if (nxmBalanceAfter > nxmBalanceBefore) { + uint erc20Refund = nxmBalanceAfter - nxmBalanceBefore; + nxmToken.transfer(msg.sender, erc20Refund); + } + return coverId; + } + + // ERC20 payment + address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; IERC20 erc20 = IERC20(paymentAsset); uint erc20BalanceBefore = erc20.balanceOf(address(this)); From fba9911c5ae8840fe40c2590f3266259ae4993c8 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 14:17:03 +0300 Subject: [PATCH 68/88] Add CoverBroker _handle payment internal functions --- contracts/external/cover/CoverBroker.sol | 147 +++++++++++++++-------- 1 file changed, 98 insertions(+), 49 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index cba9e30eed..3181baca1a 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -13,7 +13,10 @@ import "../../interfaces/IPool.sol"; import "../../interfaces/INXMToken.sol"; -/// @dev Allows cover distribution by buying cover in behalf of the caller +/// @title Cover Broker Contract +/// @notice Enables non-members of the mutual to purchase cover policies. +/// Supports ETH, NXM, and ERC20 asset payments which are supported by the pool. +/// @dev See supported ERC20 payment methods via pool.getAssets. contract CoverBroker is ICoverBroker, Ownable { using SafeERC20 for IERC20; @@ -35,83 +38,52 @@ contract CoverBroker is ICoverBroker, Ownable { nxmToken = INXMToken(_nxmToken); } - /// @dev Buys cover in behalf of the caller - /// @notice for ERC20 payments, the cover contract must be first approved for ERC20 spending using maxApproveCoverContract + /// @notice Buys cover on behalf of the caller. Supports ETH, NXM and ERC20 asset payments which are supported by the pool. + /// @dev For NXM and ERC20 payments, ensure the Cover contract is approved to spend the tokens first (maxApproveCoverContract). + /// See supported ERC20 payment methods via pool.getAssets. + /// @param params The parameters required to buy cover. + /// @param poolAllocationRequests The allocation requests for the pool's liquidity. + /// @return coverId The ID of the purchased cover. function buyCover( BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests ) external payable returns (uint coverId) { // ETH payment - if (params.paymentAsset == ETH_ASSET_ID) { - uint ethBalanceBefore = address(this).balance - msg.value; - coverId = cover.buyCover{value: msg.value}(params, poolAllocationRequests); - uint ethBalanceAfter = address(this).balance; - - // send any ETH refund back to msg.sender - if (ethBalanceAfter > ethBalanceBefore) { - uint ethRefund = ethBalanceAfter - ethBalanceBefore; - (bool sent, ) = payable(msg.sender).call{value: ethRefund}(""); - if (!sent) { - revert TransferFailed(msg.sender, ethRefund, ETH); - } - } - - return coverId; + return _handleEthPayment(params, poolAllocationRequests); } + // msg.value must be 0 if not an ETH payment if (msg.value > 0) { - // msg.value must be 0 if not ETH payment revert InvalidPayment(); } // NXM payment - if (params.paymentAsset == NXM_ASSET_ID) { - uint nxmBalanceBefore = nxmToken.balanceOf(address(this)); - - nxmToken.transferFrom(msg.sender, address(this), params.maxPremiumInAsset); - coverId = cover.buyCover(params, poolAllocationRequests); - - // send any NXM refund back to msg.sender - uint nxmBalanceAfter = nxmToken.balanceOf(address(this)); - if (nxmBalanceAfter > nxmBalanceBefore) { - uint erc20Refund = nxmBalanceAfter - nxmBalanceBefore; - nxmToken.transfer(msg.sender, erc20Refund); - } - return coverId; + return _handleNxmPayment(params, poolAllocationRequests); } // ERC20 payment - - address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; - IERC20 erc20 = IERC20(paymentAsset); - uint erc20BalanceBefore = erc20.balanceOf(address(this)); - - erc20.safeTransferFrom(msg.sender, address(this), params.maxPremiumInAsset); - coverId = cover.buyCover(params, poolAllocationRequests); - - // send any ERC20 refund back to msg.sender - uint erc20BalanceAfter = erc20.balanceOf(address(this)); - if (erc20BalanceAfter > erc20BalanceBefore) { - uint erc20Refund = erc20BalanceAfter - erc20BalanceBefore; - erc20.safeTransfer(msg.sender, erc20Refund); - } + return _handleErc20Payment(params, poolAllocationRequests); } - /// @dev Approves cover contract to spend max value of the given ERC20 token in behalf of CoverBroker + /// @notice Allows the CoverBroker contract to spend the maximum possible amount of a specified ERC20 token on behalf of the CoverBroker. + /// @param erc20 The ERC20 token for which to approve spending. function maxApproveCoverContract(IERC20 erc20) external onlyOwner { erc20.safeApprove(address(cover), type(uint256).max); } - /// @dev Switches the membership to the given address + /// @notice Switches CoverBroker's membership to a new address. + /// @dev MemberRoles contract needs to be approved to transfer NXM tokens to new membership address/ + /// @param newAddress The address to which the membership will be switched. function switchMembership(address newAddress) external onlyOwner { nxmToken.approve(address(memberRoles), type(uint256).max); memberRoles.switchMembership(newAddress); } - /// @dev Transfers available funds of the specified asset to owner + /// @notice Transfers all available funds of a specified asset (ETH or ERC20) to the contract owner. + /// @param assetAddress The address of the asset to transfer funds from. function transferFunds(address assetAddress) external onlyOwner { if (assetAddress == ETH) { @@ -137,5 +109,82 @@ contract CoverBroker is ICoverBroker, Ownable { asset.transfer(msg.sender, erc20Balance); } + /// @notice Handles ETH payments for buying cover. + /// @dev Calculates ETH refunds if applicable and sends back to msg.sender. + /// @param params The parameters required to buy cover. + /// @param poolAllocationRequests The allocation requests for the pool's liquidity. + /// @return coverId The ID of the purchased cover. + function _handleEthPayment( + BuyCoverParams calldata params, + PoolAllocationRequest[] calldata poolAllocationRequests + ) internal returns (uint coverId) { + + uint ethBalanceBefore = address(this).balance - msg.value; + coverId = cover.buyCover{value: msg.value}(params, poolAllocationRequests); + uint ethBalanceAfter = address(this).balance; + + // transfer any ETH refund back to msg.sender + if (ethBalanceAfter > ethBalanceBefore) { + uint ethRefund = ethBalanceAfter - ethBalanceBefore; + (bool sent, ) = payable(msg.sender).call{value: ethRefund}(""); + if (!sent) { + revert TransferFailed(msg.sender, ethRefund, ETH); + } + } + } + + /// @notice Handles NXM payments for buying cover. + /// @dev Transfers NXM from the caller to the contract, then buys cover on behalf of the caller. + /// Calculates NXM refunds if any and sends back to msg.sender. + /// @param params The parameters required to buy cover. + /// @param poolAllocationRequests The allocation requests for the pool's liquidity. + /// @return coverId The ID of the purchased cover. + function _handleNxmPayment( + BuyCoverParams calldata params, + PoolAllocationRequest[] calldata poolAllocationRequests + ) internal returns (uint coverId) { + + uint nxmBalanceBefore = nxmToken.balanceOf(address(this)); + + nxmToken.transferFrom(msg.sender, address(this), params.maxPremiumInAsset); + coverId = cover.buyCover(params, poolAllocationRequests); + + uint nxmBalanceAfter = nxmToken.balanceOf(address(this)); + + // transfer any NXM refund back to msg.sender + if (nxmBalanceAfter > nxmBalanceBefore) { + uint erc20Refund = nxmBalanceAfter - nxmBalanceBefore; + nxmToken.transfer(msg.sender, erc20Refund); + } + } + + /// @notice Handles ERC20 payments for buying cover. + /// @dev Transfers ERC20 tokens from the caller to the contract, then buys cover on behalf of the caller. + /// Calculates ERC20 refunds if any and sends back to msg.sender. + /// @param params The parameters required to buy cover. + /// @param poolAllocationRequests The allocation requests for the pool's liquidity. + /// @return coverId The ID of the purchased cover. + function _handleErc20Payment( + BuyCoverParams calldata params, + PoolAllocationRequest[] calldata poolAllocationRequests + ) internal returns (uint coverId) { + + address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; + IERC20 erc20 = IERC20(paymentAsset); + + uint erc20BalanceBefore = erc20.balanceOf(address(this)); + + erc20.safeTransferFrom(msg.sender, address(this), params.maxPremiumInAsset); + coverId = cover.buyCover(params, poolAllocationRequests); + + uint erc20BalanceAfter = erc20.balanceOf(address(this)); + + // send any ERC20 refund back to msg.sender + if (erc20BalanceAfter > erc20BalanceBefore) { + uint erc20Refund = erc20BalanceAfter - erc20BalanceBefore; + erc20.safeTransfer(msg.sender, erc20Refund); + } + } + receive() external payable {} } From 273de1a56c448f3e2fda34c44bef865f28724577 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 15:43:35 +0300 Subject: [PATCH 69/88] Fix function annotations + fix variable name --- contracts/external/cover/CoverBroker.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 3181baca1a..e3a99151d9 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -68,14 +68,14 @@ contract CoverBroker is ICoverBroker, Ownable { return _handleErc20Payment(params, poolAllocationRequests); } - /// @notice Allows the CoverBroker contract to spend the maximum possible amount of a specified ERC20 token on behalf of the CoverBroker. + /// @notice Allows the Cover contract to spend the maximum possible amount of a specified ERC20 token on behalf of the CoverBroker. /// @param erc20 The ERC20 token for which to approve spending. function maxApproveCoverContract(IERC20 erc20) external onlyOwner { erc20.safeApprove(address(cover), type(uint256).max); } /// @notice Switches CoverBroker's membership to a new address. - /// @dev MemberRoles contract needs to be approved to transfer NXM tokens to new membership address/ + /// @dev MemberRoles contract needs to be approved to transfer NXM tokens to new membership address. /// @param newAddress The address to which the membership will be switched. function switchMembership(address newAddress) external onlyOwner { nxmToken.approve(address(memberRoles), type(uint256).max); @@ -83,7 +83,7 @@ contract CoverBroker is ICoverBroker, Ownable { } /// @notice Transfers all available funds of a specified asset (ETH or ERC20) to the contract owner. - /// @param assetAddress The address of the asset to transfer funds from. + /// @param assetAddress The address of the asset to be transferred. function transferFunds(address assetAddress) external onlyOwner { if (assetAddress == ETH) { @@ -153,8 +153,8 @@ contract CoverBroker is ICoverBroker, Ownable { // transfer any NXM refund back to msg.sender if (nxmBalanceAfter > nxmBalanceBefore) { - uint erc20Refund = nxmBalanceAfter - nxmBalanceBefore; - nxmToken.transfer(msg.sender, erc20Refund); + uint nxmRefund = nxmBalanceAfter - nxmBalanceBefore; + nxmToken.transfer(msg.sender, nxmRefund); } } From 359639cef13fd1292adc5663dfce8677f1c3f62f Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Tue, 2 Apr 2024 12:12:20 +0200 Subject: [PATCH 70/88] Add Integration tests for CoverBroker --- test/integration/Cover/buyCover.js | 144 ++++++++++++++++++ .../CoverBroker/switchMembership.js | 29 ++++ test/integration/setup.js | 13 +- 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 test/integration/CoverBroker/switchMembership.js diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index c0059f9f44..2beb30baf8 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -351,4 +351,148 @@ describe('buyCover', function () { ), ).to.revertedWithCustomError(cover, 'ProductDoesntExistOrIsDeprecated'); }); + + it('should buy cover thorough the broker with ETH', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { tc: tokenController, stakingProducts, p1: pool, ra: ramm, mcr, coverBroker, coverNFT } = fixture.contracts; + const { + nonMembers: [coverBuyer], + } = fixture.accounts; + const { + config: { NXM_PER_ALLOCATION_UNIT, GLOBAL_REWARDS_RATIO }, + productList, + } = fixture; + const { period, amount } = buyCoverFixture; + + const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = currentTimestamp + 1; + const ethRate = await getInternalPrice(ramm, pool, tokenController, mcr, nextBlockTimestamp); + + const { targetPrice } = stakedProductParamTemplate; + + const productId = productList.findIndex( + ({ product: { initialPriceRatio, useFixedPrice } }) => targetPrice !== initialPriceRatio && useFixedPrice, + ); + + const product = await stakingProducts.getProduct(1, productId); + const { premiumInNxm, premiumInAsset: premium } = calculatePremium( + amount, + ethRate, + period, + product.targetPrice, + NXM_PER_ALLOCATION_UNIT, + ); + + const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); + const poolBeforeETH = await ethers.provider.getBalance(pool.address); + + const amountOver = parseEther('1'); + const balanceBefore = await ethers.provider.getBalance(coverBuyer.address); + const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); + await setNextBlockTime(nextBlockTimestamp); + + const tx = await coverBroker + .connect(coverBuyer) + .buyCover( + { ...buyCoverFixture, productId, owner: coverBuyer.address, maxPremiumInAsset: premium }, + [{ poolId: 1, coverAmountInAsset: amount }], + { value: premium.add(amountOver) }, + ); + + const receipt = await tx.wait(); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const balanceAfter = await ethers.provider.getBalance(coverBuyer.address); + const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); + + expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); + expect(balanceAfter).to.be.equal(balanceBefore.sub(premium).sub(receipt.effectiveGasPrice.mul(receipt.gasUsed))); + const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); + + const stakingPoolAfter = await tokenController.stakingPoolNXMBalances(1); + const poolAfterETH = await ethers.provider.getBalance(pool.address); + expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); + expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premium)); + }); + + it('should buy cover thorough the broker with DAI', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { + tc: tokenController, + stakingProducts, + p1: pool, + ra: ramm, + mcr, + coverBroker, + dai, + priceFeedOracle, + coverNFT, + } = fixture.contracts; + const { + nonMembers: [coverBuyer], + } = fixture.accounts; + const { + config: { NXM_PER_ALLOCATION_UNIT, GLOBAL_REWARDS_RATIO }, + productList, + } = fixture; + const { period, amount } = buyCoverFixture; + + await dai.mint(coverBuyer.address, parseEther('1000')); + await dai.connect(coverBuyer).approve(coverBroker.address, parseEther('1000')); + + const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = currentTimestamp + 1; + const ethRate = await getInternalPrice(ramm, pool, tokenController, mcr, nextBlockTimestamp); + const daiRate = await priceFeedOracle.getAssetForEth(dai.address, parseEther('1')); + const nxmDaiRate = ethRate.mul(daiRate).div(parseEther('1')); + + const { targetPrice } = stakedProductParamTemplate; + + const productId = productList.findIndex( + ({ product: { initialPriceRatio, useFixedPrice } }) => targetPrice !== initialPriceRatio && useFixedPrice, + ); + + const product = await stakingProducts.getProduct(1, productId); + const { premiumInNxm, premiumInAsset: premium } = calculatePremium( + amount, + nxmDaiRate, + period, + product.targetPrice, + NXM_PER_ALLOCATION_UNIT, + ); + + const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); + const poolBeforeETH = await ethers.provider.getBalance(pool.address); + + const balanceBefore = await dai.balanceOf(coverBuyer.address); + const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); + await setNextBlockTime(nextBlockTimestamp); + + await coverBroker.connect(coverBuyer).buyCover( + { + ...buyCoverFixture, + productId, + owner: coverBuyer.address, + maxPremiumInAsset: premium, + coverAsset: 1, + paymentAsset: 1, + }, + [{ poolId: 1, coverAmountInAsset: amount }], + { value: '0' }, + ); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const balanceAfter = await dai.balanceOf(coverBuyer.address); + const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); + + expect(balanceAfter).to.be.equal(balanceBefore.sub(premium)); + expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); + const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); + + const stakingPoolAfter = await tokenController.stakingPoolNXMBalances(1); + const poolAfterETH = await ethers.provider.getBalance(pool.address); + const premiumInEth = premium.mul(parseEther('1').div(daiRate)); + expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); + expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); + }); }); diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js new file mode 100644 index 0000000000..063d61da61 --- /dev/null +++ b/test/integration/CoverBroker/switchMembership.js @@ -0,0 +1,29 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const setup = require('../setup'); + +describe('CoverBroker - switchMembership', function () { + it('should switch membership', async function () { + const fixture = await loadFixture(setup); + const { cover, mr, coverBroker } = fixture.contracts; + const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); + + await coverBroker.switchMembership(newCoverBroker.address); + + const check = await mr.isMember(newCoverBroker.address); + expect(check).to.be.equal(true); + }); + + it('should fail to switch membership if the caller is not the owner', async function () { + const fixture = await loadFixture(setup); + const { cover, mr, coverBroker } = fixture.contracts; + const { members } = fixture.accounts; + const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); + + await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( + 'Ownable: caller is not the owner', + ); + }); +}); diff --git a/test/integration/setup.js b/test/integration/setup.js index 91a4308a2f..24dfadb88b 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -5,6 +5,7 @@ const { toBytes2, toBytes8 } = require('../utils').helpers; const { proposalCategories } = require('../utils'); const { enrollMember, enrollABMember, getGovernanceSigner } = require('./utils/enroll'); const { getAccounts } = require('../utils/accounts'); +const { impersonateAccount, setEtherBalance } = require('../utils').evm; const { BigNumber } = ethers; const { parseEther, parseUnits } = ethers.utils; @@ -192,6 +193,9 @@ async function setup() { await coverNFT.changeOperator(cover.address); await cover.changeMasterAddress(master.address); + // deploy CoverBroker + const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); + const ci = await deployProxy('IndividualClaims', [tk.address, coverNFT.address]); const cg = await deployProxy('YieldTokenIncidents', [tk.address, coverNFT.address]); const as = await deployProxy('Assessment', [tk.address]); @@ -551,7 +555,7 @@ async function setup() { cover: await ethers.getContractAt('Cover', cover.address), }; - const nonInternal = { priceFeedOracle, swapOperator }; + const nonInternal = { priceFeedOracle, swapOperator, coverBroker }; fixture.contracts = { ...external, @@ -574,6 +578,13 @@ async function setup() { await enrollMember(fixture.contracts, advisoryBoardMembers, owner); await enrollABMember(fixture.contracts, advisoryBoardMembers); + // enroll coverBroker as member + await impersonateAccount(coverBroker.address); + await setEtherBalance(coverBroker.address, parseEther('1000')); + const coverBrokerSigner = await ethers.getSigner(coverBroker.address); + accounts.coverBrokerSigner = coverBrokerSigner; + await enrollMember(fixture.contracts, [coverBrokerSigner], owner, { initialTokens: parseEther('0') }); + const product = { productId: 0, weight: 100, From 96906ab5998287ab993ed39d64ddd4d14ccdc8dc Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Tue, 2 Apr 2024 12:12:28 +0200 Subject: [PATCH 71/88] Add Unit tests for CoverBroker --- test/unit/CoverBroker/setup.js | 27 ++++++++++ test/unit/CoverBroker/transferFunds.js | 69 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 test/unit/CoverBroker/setup.js create mode 100644 test/unit/CoverBroker/transferFunds.js diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js new file mode 100644 index 0000000000..88e3f0751a --- /dev/null +++ b/test/unit/CoverBroker/setup.js @@ -0,0 +1,27 @@ +const { ethers } = require('hardhat'); + +const { setEtherBalance } = require('../utils').evm; +const { parseEther } = ethers.utils; + +async function setup() { + const coverBrokerOwner = ethers.Wallet.createRandom().connect(ethers.provider); + await setEtherBalance(coverBrokerOwner.address, parseEther('100')); + + const dai = await ethers.deployContract('ERC20Mock'); + const cover = await ethers.deployContract('CMMockCover'); + const memberRoles = await ethers.deployContract('MemberRolesMock'); + const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, memberRoles.address]); + + await memberRoles.setRole(coverBroker.address, 2); + await coverBroker.transferOwnership(coverBrokerOwner.address); + + return { + coverBrokerOwner, + contracts: { + dai, + coverBroker, + }, + }; +} + +module.exports = { setup }; diff --git a/test/unit/CoverBroker/transferFunds.js b/test/unit/CoverBroker/transferFunds.js new file mode 100644 index 0000000000..50101e069b --- /dev/null +++ b/test/unit/CoverBroker/transferFunds.js @@ -0,0 +1,69 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { setup } = require('./setup'); +const { setEtherBalance } = require('../utils').evm; + +const ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; +const { parseEther } = ethers.utils; + +describe('transferFunds', function () { + it('should transfer funds (ETH) to owner', async function () { + const fixture = await loadFixture(setup); + const { + contracts: { coverBroker }, + coverBrokerOwner, + } = fixture; + + await setEtherBalance(coverBroker.address, parseEther('1')); + const brokerBalanceBefore = await ethers.provider.getBalance(coverBroker.address); + const ownerBalanceBefore = await ethers.provider.getBalance(coverBrokerOwner.address); + + const tx = await coverBroker.connect(coverBrokerOwner).transferFunds(ETH); + const receipt = await tx.wait(); + + const brokerBalanceAfter = await ethers.provider.getBalance(coverBroker.address); + const ownerBalanceAfter = await ethers.provider.getBalance(coverBrokerOwner.address); + + expect(brokerBalanceBefore).not.to.equal(0); + expect(brokerBalanceAfter).to.equal(0); + expect(ownerBalanceAfter).to.equal( + ownerBalanceBefore.add(brokerBalanceBefore).sub(receipt.effectiveGasPrice.mul(receipt.gasUsed)), + ); + }); + + it('should transfer funds (nonETH) to owner', async function () { + const fixture = await loadFixture(setup); + const { + contracts: { coverBroker, dai }, + coverBrokerOwner, + } = fixture; + + await dai.mint(coverBroker.address, parseEther('100')); + const brokerBalanceBefore = await dai.balanceOf(coverBroker.address); + const ownerBalanceBefore = await dai.balanceOf(coverBrokerOwner.address); + + await coverBroker.connect(coverBrokerOwner).transferFunds(dai.address); + + const brokerBalanceAfter = await dai.balanceOf(coverBroker.address); + const ownerBalanceAfter = await dai.balanceOf(coverBrokerOwner.address); + + expect(brokerBalanceBefore).not.to.equal(0); + expect(brokerBalanceAfter).to.equal(0); + expect(ownerBalanceAfter).to.equal(ownerBalanceBefore.add(brokerBalanceBefore)); + }); + + it('should fail to transfer funds if the caller is not the owner', async function () { + const fixture = await loadFixture(setup); + const { coverBroker } = fixture.contracts; + const nonOwner = await ethers.Wallet.createRandom().connect(ethers.provider); + await setEtherBalance(nonOwner.address, parseEther('1')); + + const balanceBefore = await ethers.provider.getBalance(coverBroker.address); + + await expect(coverBroker.connect(nonOwner).transferFunds(ETH)).to.revertedWith('Ownable: caller is not the owner'); + const balanceAfter = await ethers.provider.getBalance(coverBroker.address); + expect(balanceAfter).to.equal(balanceBefore); + }); +}); From 04e2191aa015ec0ffe290aa350351ae352863058 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Tue, 2 Apr 2024 12:14:43 +0200 Subject: [PATCH 72/88] Add fork Tests for CoverBroker --- test/fork/cover-broker.js | 355 +++++++++++++++++++++++++++++++ test/fork/utils.js | 2 +- test/integration/utils/enroll.js | 6 +- 3 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 test/fork/cover-broker.js diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js new file mode 100644 index 0000000000..d8357bab77 --- /dev/null +++ b/test/fork/cover-broker.js @@ -0,0 +1,355 @@ +const { ethers, network } = require('hardhat'); +const { expect } = require('chai'); +const { addresses } = require('@nexusmutual/deployments'); + +const { Address, EnzymeAdress, getSigner, UserAddress, calculateCurrentTrancheId } = require('./utils'); +const { ContractCode } = require('../../lib/constants'); +const { enrollMember } = require('../integration/utils/enroll'); +const { daysToSeconds } = require('../../lib/helpers'); +const { setNextBlockTime, mineNextBlock } = require('../utils/evm'); +const { NXM_WHALE_2 } = UserAddress; +const evm = require('./evm')(); +const ASSESSMENT_VOTER_COUNT = 3; + +const { parseEther, toUtf8Bytes } = ethers.utils; +const { AddressZero, MaxUint256 } = ethers.constants; + +const setTime = async timestamp => { + await setNextBlockTime(timestamp); + await mineNextBlock(); +}; + +async function castAssessmentVote(assessmentId) { + // vote + for (const abMember of this.abMembers.slice(0, ASSESSMENT_VOTER_COUNT)) { + await this.assessment.connect(abMember).castVotes([assessmentId], [true], [''], 0); + } + + const { poll: pollResult } = await this.assessment.assessments(assessmentId); + const poll = pollResult; + + const { payoutCooldownInDays } = await this.assessment.config(); + + const futureTime = poll.end + daysToSeconds(payoutCooldownInDays); + + await setTime(futureTime); +} + +describe('CoverBroker', function () { + let coverId; + let coverBrokerProductId; + let poolId; + let tokenId; + let trancheId; + let assessmentId; + let requestedClaimAmount; + let claimDeposit; + + async function getContractByContractCode(contractName, contractCode) { + this.master = this.master ?? (await ethers.getContractAt('NXMaster', addresses.NXMaster)); + const contractAddress = await this.master?.getLatestAddress(toUtf8Bytes(contractCode)); + return ethers.getContractAt(contractName, contractAddress); + } + + before(async function () { + // Initialize evm helper + await evm.connect(ethers.provider); + + // Get or revert snapshot if network is tenderly + if (network.name === 'tenderly') { + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + await evm.revert(TENDERLY_SNAPSHOT_ID); + console.info(`Reverted to snapshot ${TENDERLY_SNAPSHOT_ID}`); + } else { + console.info('Snapshot ID: ', await evm.snapshot()); + } + } + const [deployer] = await ethers.getSigners(); + await evm.setBalance(deployer.address, parseEther('1000')); + trancheId = await calculateCurrentTrancheId(); + }); + + it('load contracts', async function () { + this.stakingProducts = await ethers.getContractAt('StakingProducts', addresses.StakingProducts); + this.cover = await ethers.getContractAt('Cover', addresses.Cover); + this.stakingPoolFactory = await ethers.getContractAt('StakingPoolFactory', addresses.StakingPoolFactory); + this.stakingViewer = await ethers.getContractAt('StakingViewer', addresses.StakingViewer); + this.mcr = await ethers.getContractAt('MCR', addresses.MCR); + this.nxm = await ethers.getContractAt('NXMToken', addresses.NXMToken); + this.master = await ethers.getContractAt('NXMaster', addresses.NXMaster); + this.coverNFT = await ethers.getContractAt('CoverNFT', addresses.CoverNFT); + this.pool = await ethers.getContractAt('ILegacyPool', addresses.Pool); + this.ramm = await ethers.getContractAt('Ramm', addresses.Ramm); + this.assessment = await ethers.getContractAt('Assessment', addresses.Assessment); + this.productsV1 = await ethers.getContractAt('ProductsV1', addresses.ProductsV1); + this.stakingNFT = await ethers.getContractAt('StakingNFT', addresses.StakingNFT); + this.swapOperator = await ethers.getContractAt('SwapOperator', addresses.SwapOperator); + this.priceFeedOracle = await ethers.getContractAt('PriceFeedOracle', addresses.PriceFeedOracle); + this.tokenController = await ethers.getContractAt('TokenController', addresses.TokenController); + this.individualClaims = await ethers.getContractAt('IndividualClaims', addresses.IndividualClaims); + this.quotationData = await ethers.getContractAt('LegacyQuotationData', addresses.LegacyQuotationData); + this.newClaimsReward = await ethers.getContractAt('LegacyClaimsReward', addresses.LegacyClaimsReward); + this.proposalCategory = await ethers.getContractAt('ProposalCategory', addresses.ProposalCategory); + this.yieldTokenIncidents = await ethers.getContractAt('YieldTokenIncidents', addresses.YieldTokenIncidents); + this.pooledStaking = await ethers.getContractAt('LegacyPooledStaking', addresses.LegacyPooledStaking); + this.gateway = await ethers.getContractAt('LegacyGateway', addresses.LegacyGateway); + + this.dai = await ethers.getContractAt('ERC20Mock', Address.DAI_ADDRESS); + this.rEth = await ethers.getContractAt('ERC20Mock', Address.RETH_ADDRESS); + this.stEth = await ethers.getContractAt('ERC20Mock', Address.STETH_ADDRESS); + this.enzymeShares = await ethers.getContractAt('ERC20Mock', EnzymeAdress.ENZYMEV4_VAULT_PROXY_ADDRESS); + + this.governance = await getContractByContractCode('Governance', ContractCode.Governance); + this.memberRoles = await getContractByContractCode('MemberRoles', ContractCode.MemberRoles); + }); + + it('Impersonate AB members', async function () { + const { memberArray: abMembers } = await this.memberRoles.members(1); + this.abMembers = []; + for (const address of abMembers) { + await evm.impersonate(address); + await evm.setBalance(address, parseEther('1000')); + this.abMembers.push(await getSigner(address)); + } + }); + + it('Impersonate new pool manager', async function () { + await evm.impersonate(NXM_WHALE_2); + await evm.setBalance(NXM_WHALE_2, parseEther('1000000')); + this.manager = await getSigner(NXM_WHALE_2); + }); + + it('Change MemberRoles KYC Auth wallet to add new members', async function () { + await evm.impersonate(addresses.Governance); + await evm.setBalance(addresses.Governance, parseEther('1000')); + const governanceSigner = await getSigner(addresses.Governance); + + this.kycAuthSigner = ethers.Wallet.createRandom().connect(ethers.provider); + + await this.memberRoles.connect(governanceSigner).setKycAuthAddress(this.kycAuthSigner.address); + }); + + it('Add product to be used for coverBroker', async function () { + const productsBefore = await this.cover.getProducts(); + + await this.cover.connect(this.abMembers[0]).setProducts([ + { + productName: 'CoverBroker Product', + productId: MaxUint256, + ipfsMetadata: '', + product: { + productType: 0, + yieldTokenAddress: AddressZero, + coverAssets: 0, + initialPriceRatio: 100, + capacityReductionRatio: 0, + useFixedPrice: false, + isDeprecated: false, + }, + allowedPools: [], + }, + ]); + + const productsAfter = await this.cover.getProducts(); + coverBrokerProductId = productsAfter.length - 1; + expect(productsAfter.length).to.be.equal(productsBefore.length + 1); + }); + + it('Create StakingPool', async function () { + const manager = this.manager; + const products = [ + { + productId: coverBrokerProductId, + weight: 100, + initialPrice: 1000, + targetPrice: 1000, + }, + ]; + + const stakingPoolCountBefore = await this.stakingPoolFactory.stakingPoolCount(); + await this.cover.connect(manager).createStakingPool(false, 5, 5, products, 'description'); + const stakingPoolCountAfter = await this.stakingPoolFactory.stakingPoolCount(); + + poolId = stakingPoolCountAfter.toNumber(); + expect(stakingPoolCountAfter).to.be.equal(stakingPoolCountBefore.add(1)); + + const address = await this.cover.stakingPool(poolId); + this.stakingPool = await ethers.getContractAt('StakingPool', address); + }); + + it('Deposit to StakingPool', async function () { + const manager = this.manager; + const managerAddress = await manager.getAddress(); + const managerBalanceBefore = await this.nxm.balanceOf(managerAddress); + const totalSupplyBefore = await this.stakingNFT.totalSupply(); + const amount = parseEther('100'); + + await this.nxm.connect(manager).approve(this.tokenController.address, amount); + await this.stakingPool.connect(manager).depositTo(amount, trancheId + 2, 0, AddressZero); + + const managerBalanceAfter = await this.nxm.balanceOf(managerAddress); + const totalSupplyAfter = await this.stakingNFT.totalSupply(); + tokenId = totalSupplyAfter; + const owner = await this.stakingNFT.ownerOf(tokenId); + + expect(totalSupplyAfter).to.equal(totalSupplyBefore.add(1)); + expect(managerBalanceAfter).to.equal(managerBalanceBefore.sub(amount)); + expect(owner).to.equal(managerAddress); + }); + + it('Cover Broker Owner becomes a member', async function () { + this.coverBrokerOwner = ethers.Wallet.createRandom().connect(ethers.provider); + await evm.setBalance(this.coverBrokerOwner.address, parseEther('1000')); + + await enrollMember( + { mr: this.memberRoles, tk: this.nxm, tc: this.tokenController }, + [this.coverBrokerOwner], + this.kycAuthSigner, + { initialTokens: 0 }, + ); + + const isMember = await this.memberRoles.isMember(this.coverBrokerOwner.address); + expect(isMember).to.be.equal(true); + }); + + it('Deploy CoverBroker contract and transfer ownership and membership', async function () { + this.coverBroker = await ethers.deployContract('CoverBroker', [this.cover.address, this.memberRoles.address]); + + await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); + const ownerAfter = await this.coverBroker.owner(); + expect(this.coverBrokerOwner.address).to.be.equal(ownerAfter); + + await this.memberRoles.connect(this.coverBrokerOwner).switchMembership(this.coverBroker.address); + const isMember = await this.memberRoles.isMember(this.coverBroker.address); + expect(isMember).to.be.equal(true); + }); + + it('Buy cover using CoverBroker', async function () { + this.coverBuyer = await ethers.Wallet.createRandom().connect(ethers.provider); + await evm.setBalance(this.coverBuyer.address, parseEther('1000000')); + + const coverAsset = 0; // ETH + const amount = parseEther('1'); + const commissionRatio = '500'; // 5% + + const coverCountBefore = await this.cover.coverDataCount(); + + await this.coverBroker.connect(this.coverBuyer).buyCover( + { + coverId: 0, + owner: this.coverBuyer.address, + productId: coverBrokerProductId, // find cover product id + coverAsset, + amount, + period: 3600 * 24 * 30, // 30 days + maxPremiumInAsset: parseEther('1').mul(260).div(10000), + paymentAsset: coverAsset, + payWithNXM: false, + commissionRatio, + commissionDestination: this.coverBuyer.address, + ipfsData: '', + }, + [{ poolId, coverAmountInAsset: amount }], + { value: amount }, + ); + + const coverCountAfter = await this.cover.coverDataCount(); + coverId = coverCountAfter; + const isCoverBuyerOwner = await this.coverNFT.isApprovedOrOwner(this.coverBuyer.address, coverId); + + expect(isCoverBuyerOwner).to.be.equal(true); + expect(coverCountAfter).to.be.equal(coverCountBefore.add(1)); + }); + + it('Cover Buyer fails to claim cover without becoming a member', async function () { + const ipfsHash = '0x68747470733a2f2f7777772e796f75747562652e636f6d2f77617463683f763d423365414d47584677316f'; + const requestedAmount = parseEther('1'); + const segmentId = (await this.cover.coverSegmentsCount(coverId)).sub(1); + const segment = await this.cover.coverSegmentWithRemainingAmount(coverId, segmentId); + + const [deposit] = await this.individualClaims.getAssessmentDepositAndReward( + requestedAmount, + segment.period, + 0, // ETH + ); + await expect( + this.individualClaims + .connect(this.coverBuyer) + .submitClaim(coverId, segmentId, requestedAmount, ipfsHash, { value: deposit }), + ).to.revertedWith('Caller is not a member'); + }); + + it('Cover Buyer becomes a member', async function () { + await enrollMember( + { mr: this.memberRoles, tk: this.nxm, tc: this.tokenController }, + [this.coverBuyer], + this.kycAuthSigner, + { initialTokens: 0 }, + ); + + const isMember = await this.memberRoles.isMember(this.coverBuyer.address); + expect(isMember).to.be.equal(true); + }); + + it('Cover Buyer submits claim', async function () { + const claimsCountBefore = await this.individualClaims.getClaimsCount(); + const assessmentCountBefore = await this.assessment.getAssessmentsCount(); + + const ipfsHash = '0x68747470733a2f2f7777772e796f75747562652e636f6d2f77617463683f763d423365414d47584677316f'; + const requestedAmount = parseEther('1'); + const segmentId = (await this.cover.coverSegmentsCount(coverId)).sub(1); + const segment = await this.cover.coverSegmentWithRemainingAmount(coverId, segmentId); + + const [deposit] = await this.individualClaims.getAssessmentDepositAndReward( + requestedAmount, + segment.period, + 0, // ETH + ); + await this.individualClaims + .connect(this.coverBuyer) + .submitClaim(coverId, segmentId, requestedAmount, ipfsHash, { value: deposit }); + + const claimsCountAfter = await this.individualClaims.getClaimsCount(); + const assessmentCountAfter = await this.assessment.getAssessmentsCount(); + + assessmentId = assessmentCountBefore.toString(); + expect(claimsCountAfter).to.be.equal(claimsCountBefore.add(1)); + expect(assessmentCountAfter).to.be.equal(assessmentCountBefore.add(1)); + + requestedClaimAmount = requestedAmount; + claimDeposit = deposit; + }); + + it('Stake for assessment', async function () { + // stake + const amount = parseEther('500'); + for (const abMember of this.abMembers.slice(0, ASSESSMENT_VOTER_COUNT)) { + const memberAddress = await abMember.getAddress(); + const { amount: stakeAmountBefore } = await this.assessment.stakeOf(memberAddress); + await this.assessment.connect(abMember).stake(amount); + const { amount: stakeAmountAfter } = await this.assessment.stakeOf(memberAddress); + expect(stakeAmountAfter).to.be.equal(stakeAmountBefore.add(amount)); + } + }); + + it('Process assessment for custody cover and ETH payout', async function () { + await castAssessmentVote.call(this, assessmentId); + + const claimId = (await this.individualClaims.getClaimsCount()).toNumber() - 1; + + const balanceBefore = await ethers.provider.getBalance(this.coverBuyer.address); + + // redeem payout + const tx = await this.individualClaims.connect(this.coverBuyer).redeemClaimPayout(claimId); + const receipt = await tx.wait(); + + const balanceAfter = await ethers.provider.getBalance(this.coverBuyer.address); + expect(balanceAfter).to.be.equal( + balanceBefore.add(requestedClaimAmount).add(claimDeposit).sub(receipt.effectiveGasPrice.mul(receipt.gasUsed)), + ); + + const { payoutRedeemed } = await this.individualClaims.claims(claimId); + expect(payoutRedeemed).to.be.equal(true); + }); +}); diff --git a/test/fork/utils.js b/test/fork/utils.js index 46bccd7628..64e526963c 100644 --- a/test/fork/utils.js +++ b/test/fork/utils.js @@ -56,7 +56,7 @@ const Address = { const UserAddress = { NXM_WHALE_1: '0x25783b67b5e29c48449163db19842b8531fdde43', - NXM_WHALE_2: '0x598dbe6738e0aca4eabc22fed2ac737dbd13fb8f', + NXM_WHALE_2: '0xd3A6BEB10FFF934543976bC0a30d6B4368c0775b', NXM_AB_MEMBER: '0x87B2a7559d85f4653f13E6546A14189cd5455d45', DAI_HOLDER: '0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503', DAI_NXM_HOLDER: '0x526C7665C5dd9cD7102C6d42D407a0d9DC1e431d', diff --git a/test/integration/utils/enroll.js b/test/integration/utils/enroll.js index 73a86eed08..8ff3b2d579 100644 --- a/test/integration/utils/enroll.js +++ b/test/integration/utils/enroll.js @@ -24,8 +24,10 @@ async function enrollMember({ mr, tk, tc }, members, kycAuthSigner, options = {} value: JOINING_FEE, }); - await tk.connect(member).approve(tc.address, ethers.constants.MaxUint256); - await tk.transfer(member.address, initialTokens); + if (initialTokens && initialTokens.gt(0)) { + await tk.connect(member).approve(tc.address, ethers.constants.MaxUint256); + await tk.transfer(member.address, initialTokens); + } } } async function enrollABMember({ mr, gv }, members) { From ae71ff77619ae10bddf0973788f765b3aad69023 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Tue, 2 Apr 2024 19:52:58 +0300 Subject: [PATCH 73/88] Fix CoverBroker test - add pool to constructor --- test/fork/cover-broker.js | 6 +++++- test/integration/CoverBroker/switchMembership.js | 8 ++++---- test/integration/setup.js | 6 +++--- test/unit/CoverBroker/setup.js | 3 ++- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js index d8357bab77..03b143a0ed 100644 --- a/test/fork/cover-broker.js +++ b/test/fork/cover-broker.js @@ -214,7 +214,11 @@ describe('CoverBroker', function () { }); it('Deploy CoverBroker contract and transfer ownership and membership', async function () { - this.coverBroker = await ethers.deployContract('CoverBroker', [this.cover.address, this.memberRoles.address]); + this.coverBroker = await ethers.deployContract('CoverBroker', [ + this.cover.address, + this.memberRoles.address, + this.pool.address, + ]); await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); const ownerAfter = await this.coverBroker.owner(); diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js index 063d61da61..3621452324 100644 --- a/test/integration/CoverBroker/switchMembership.js +++ b/test/integration/CoverBroker/switchMembership.js @@ -7,8 +7,8 @@ const setup = require('../setup'); describe('CoverBroker - switchMembership', function () { it('should switch membership', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker } = fixture.contracts; - const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); + const { cover, mr, coverBroker, p1 } = fixture.contracts; + const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); await coverBroker.switchMembership(newCoverBroker.address); @@ -18,9 +18,9 @@ describe('CoverBroker - switchMembership', function () { it('should fail to switch membership if the caller is not the owner', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker } = fixture.contracts; + const { cover, mr, coverBroker, p1 } = fixture.contracts; const { members } = fixture.accounts; - const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); + const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( 'Ownable: caller is not the owner', diff --git a/test/integration/setup.js b/test/integration/setup.js index 24dfadb88b..670cb56b79 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -193,9 +193,6 @@ async function setup() { await coverNFT.changeOperator(cover.address); await cover.changeMasterAddress(master.address); - // deploy CoverBroker - const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address]); - const ci = await deployProxy('IndividualClaims', [tk.address, coverNFT.address]); const cg = await deployProxy('YieldTokenIncidents', [tk.address, coverNFT.address]); const as = await deployProxy('Assessment', [tk.address]); @@ -487,6 +484,9 @@ async function setup() { [master, priceFeedOracle, swapOperatorPlaceholder, tk, legacyPool].map(c => c.address), ); + // deploy CoverBroker + const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); + await master.connect(governanceSigner).upgradeMultipleContracts([toBytes2('P1')], [p1.address]); // [todo] We should probably call changeDependentContractAddress on every contract diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index 88e3f0751a..f0cafe6241 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -10,7 +10,8 @@ async function setup() { const dai = await ethers.deployContract('ERC20Mock'); const cover = await ethers.deployContract('CMMockCover'); const memberRoles = await ethers.deployContract('MemberRolesMock'); - const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, memberRoles.address]); + const pool = await ethers.deployContract('PoolMock'); + const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, memberRoles.address, pool.address]); await memberRoles.setRole(coverBroker.address, 2); await coverBroker.transferOwnership(coverBrokerOwner.address); From ad33b2b150c3d127b6332224551173a356449602 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 10:59:08 +0300 Subject: [PATCH 74/88] Fix CoverBroker tests deployment --- test/fork/cover-broker.js | 1 + test/integration/Cover/buyCover.js | 38 ++++++++++++++----- .../CoverBroker/switchMembership.js | 22 +++++++++-- test/integration/setup.js | 2 +- test/unit/CoverBroker/setup.js | 8 +++- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js index 03b143a0ed..e0b87ccad4 100644 --- a/test/fork/cover-broker.js +++ b/test/fork/cover-broker.js @@ -218,6 +218,7 @@ describe('CoverBroker', function () { this.cover.address, this.memberRoles.address, this.pool.address, + this.nxm.address, ]); await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index 2beb30baf8..6c83f0e1cb 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -351,8 +351,23 @@ describe('buyCover', function () { ), ).to.revertedWithCustomError(cover, 'ProductDoesntExistOrIsDeprecated'); }); +}); + +describe('CoverBroker - buyCover', function () { + it('should revert with InvalidPayment if paymentAsset is not ETH and msg.value > 0', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { coverBroker } = fixture.contracts; + const [coverBuyer] = fixture.accounts.nonMembers; + + const poolAllocationRequest = { poolId: 1, coverAmountInAsset: parseEther('1') }; + const buyCover = coverBroker + .connect(coverBuyer) + .buyCover({ ...buyCoverFixture, paymentAsset: 1 }, [poolAllocationRequest], { value: parseEther('1') }); - it('should buy cover thorough the broker with ETH', async function () { + await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidPayment'); + }); + + it('should buy cover through the broker with ETH', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, stakingProducts, p1: pool, ra: ramm, mcr, coverBroker, coverNFT } = fixture.contracts; const { @@ -391,13 +406,17 @@ describe('buyCover', function () { const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); await setNextBlockTime(nextBlockTimestamp); - const tx = await coverBroker - .connect(coverBuyer) - .buyCover( - { ...buyCoverFixture, productId, owner: coverBuyer.address, maxPremiumInAsset: premium }, - [{ poolId: 1, coverAmountInAsset: amount }], - { value: premium.add(amountOver) }, - ); + const tx = await coverBroker.connect(coverBuyer).buyCover( + { + ...buyCoverFixture, + paymentAsset: 0, // ETH + productId, + owner: coverBuyer.address, + maxPremiumInAsset: premium, + }, + [{ poolId: 1, coverAmountInAsset: amount }], + { value: premium.add(amountOver) }, + ); const receipt = await tx.wait(); @@ -415,7 +434,7 @@ describe('buyCover', function () { expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premium)); }); - it('should buy cover thorough the broker with DAI', async function () { + it('should buy cover through the broker with DAI', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, @@ -439,6 +458,7 @@ describe('buyCover', function () { await dai.mint(coverBuyer.address, parseEther('1000')); await dai.connect(coverBuyer).approve(coverBroker.address, parseEther('1000')); + await coverBroker.maxApproveCoverContract(dai.address); const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); const nextBlockTimestamp = currentTimestamp + 1; diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js index 3621452324..948f7217ab 100644 --- a/test/integration/CoverBroker/switchMembership.js +++ b/test/integration/CoverBroker/switchMembership.js @@ -7,20 +7,34 @@ const setup = require('../setup'); describe('CoverBroker - switchMembership', function () { it('should switch membership', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1 } = fixture.contracts; - const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); + const { cover, mr, coverBroker, p1, tk } = fixture.contracts; + const newCoverBroker = await ethers.deployContract('CoverBroker', [ + cover.address, + mr.address, + p1.address, + tk.address, + ]); + // Add NXM balance to CoverBroker + const nxmBalance = ethers.utils.parseEther('10'); + await tk.connect(fixture.accounts.defaultSender).transfer(coverBroker.address, nxmBalance); await coverBroker.switchMembership(newCoverBroker.address); const check = await mr.isMember(newCoverBroker.address); expect(check).to.be.equal(true); + expect(await tk.balanceOf(newCoverBroker.address)).to.equal(nxmBalance); }); it('should fail to switch membership if the caller is not the owner', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1 } = fixture.contracts; + const { cover, mr, coverBroker, p1, tk } = fixture.contracts; const { members } = fixture.accounts; - const newCoverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); + const newCoverBroker = await ethers.deployContract('CoverBroker', [ + cover.address, + mr.address, + p1.address, + tk.address, + ]); await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( 'Ownable: caller is not the owner', diff --git a/test/integration/setup.js b/test/integration/setup.js index 670cb56b79..b6fe3bb727 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -485,7 +485,7 @@ async function setup() { ); // deploy CoverBroker - const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address]); + const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address, tk.address]); await master.connect(governanceSigner).upgradeMultipleContracts([toBytes2('P1')], [p1.address]); diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index f0cafe6241..6224aca360 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -11,7 +11,13 @@ async function setup() { const cover = await ethers.deployContract('CMMockCover'); const memberRoles = await ethers.deployContract('MemberRolesMock'); const pool = await ethers.deployContract('PoolMock'); - const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, memberRoles.address, pool.address]); + const tk = await ethers.deployContract('NXMTokenMock'); + const coverBroker = await ethers.deployContract('CoverBroker', [ + cover.address, + memberRoles.address, + pool.address, + tk.address, + ]); await memberRoles.setRole(coverBroker.address, 2); await coverBroker.transferOwnership(coverBrokerOwner.address); From 153be75a87ef598671a67179da0da41f4e0e73b2 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 3 Apr 2024 12:08:38 +0200 Subject: [PATCH 75/88] Fix: Increase the funds of the callers --- test/unit/CoverBroker/setup.js | 2 +- test/unit/CoverBroker/transferFunds.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index 6224aca360..1b14907521 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -5,7 +5,7 @@ const { parseEther } = ethers.utils; async function setup() { const coverBrokerOwner = ethers.Wallet.createRandom().connect(ethers.provider); - await setEtherBalance(coverBrokerOwner.address, parseEther('100')); + await setEtherBalance(coverBrokerOwner.address, parseEther('1000000')); const dai = await ethers.deployContract('ERC20Mock'); const cover = await ethers.deployContract('CMMockCover'); diff --git a/test/unit/CoverBroker/transferFunds.js b/test/unit/CoverBroker/transferFunds.js index 50101e069b..ce03eb7a74 100644 --- a/test/unit/CoverBroker/transferFunds.js +++ b/test/unit/CoverBroker/transferFunds.js @@ -58,7 +58,7 @@ describe('transferFunds', function () { const fixture = await loadFixture(setup); const { coverBroker } = fixture.contracts; const nonOwner = await ethers.Wallet.createRandom().connect(ethers.provider); - await setEtherBalance(nonOwner.address, parseEther('1')); + await setEtherBalance(nonOwner.address, parseEther('1000000')); const balanceBefore = await ethers.provider.getBalance(coverBroker.address); From 99999e0b925507fb11f51e66ccd45dd8a167d0e1 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 13:15:51 +0300 Subject: [PATCH 76/88] Add CoverBroker.buyCover NXM payment tests --- test/integration/Cover/buyCover.js | 85 +++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index 6c83f0e1cb..478d834087 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -353,7 +353,7 @@ describe('buyCover', function () { }); }); -describe('CoverBroker - buyCover', function () { +describe.only('CoverBroker - buyCover', function () { it('should revert with InvalidPayment if paymentAsset is not ETH and msg.value > 0', async function () { const fixture = await loadFixture(buyCoverSetup); const { coverBroker } = fixture.contracts; @@ -495,7 +495,7 @@ describe('CoverBroker - buyCover', function () { owner: coverBuyer.address, maxPremiumInAsset: premium, coverAsset: 1, - paymentAsset: 1, + paymentAsset: 1, // DAI }, [{ poolId: 1, coverAmountInAsset: amount }], { value: '0' }, @@ -515,4 +515,85 @@ describe('CoverBroker - buyCover', function () { expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); }); + + it.only('should buy cover through the broker from a member with NXM', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { + tc: tokenController, + stakingProducts, + p1: pool, + ra: ramm, + mcr, + coverBroker, + dai, + priceFeedOracle, + coverNFT, + tk: nxm, + } = fixture.contracts; + const { + members: [coverBuyer], + defaultSender, + } = fixture.accounts; + const { + config: { NXM_PER_ALLOCATION_UNIT, GLOBAL_REWARDS_RATIO }, + productList, + } = fixture; + const { period, amount } = buyCoverFixture; + + await nxm.connect(defaultSender).transfer(coverBuyer.address, parseEther('1000')); + await nxm.connect(coverBuyer).approve(coverBroker.address, parseEther('1000')); + await coverBroker.maxApproveCoverContract(nxm.address); + + const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = currentTimestamp + 1; + const nxmRate = parseEther('1').div(50); + + const { targetPrice } = stakedProductParamTemplate; + + const productId = productList.findIndex( + ({ product: { initialPriceRatio, useFixedPrice } }) => targetPrice !== initialPriceRatio && useFixedPrice, + ); + + const product = await stakingProducts.getProduct(1, productId); + const { premiumInNxm, premiumInAsset: premium } = calculatePremium( + amount, + nxmRate, + period, + product.targetPrice, + NXM_PER_ALLOCATION_UNIT, + ); + + const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); + const poolBeforeETH = await ethers.provider.getBalance(pool.address); + + const balanceBefore = await nxm.balanceOf(coverBuyer.address); + const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); + await setNextBlockTime(nextBlockTimestamp); + + await coverBroker.connect(coverBuyer).buyCover( + { + ...buyCoverFixture, + paymentAsset: 255, // NXM + productId, + owner: coverBuyer.address, + maxPremiumInAsset: premium, + }, + [{ poolId: 1, coverAmountInAsset: amount }], + { value: '0' }, + ); + + const { timestamp } = await ethers.provider.getBlock('latest'); + const balanceAfter = await nxm.balanceOf(coverBuyer.address); + const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); + + expect(balanceAfter).to.be.equal(balanceBefore.sub(premiumInNxm)); + expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); + const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); + + const stakingPoolAfter = await tokenController.stakingPoolNXMBalances(1); + const poolAfterETH = await ethers.provider.getBalance(pool.address); + const premiumInEth = premium.mul(nxmRate); + expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); + expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); + }); }); From abb71195b60cccf8bcb5a4d5cb411f5688692357 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 3 Apr 2024 13:02:04 +0200 Subject: [PATCH 77/88] Fix buyCover in NXM test and add NXM approve for tokenController --- contracts/external/cover/CoverBroker.sol | 6 ++++- test/fork/cover-broker.js | 1 + test/integration/Cover/buyCover.js | 26 +++++-------------- .../CoverBroker/switchMembership.js | 6 +++-- test/integration/setup.js | 8 +++++- test/unit/CoverBroker/setup.js | 2 ++ 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index e3a99151d9..6cf2f2b2f1 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -25,17 +25,21 @@ contract CoverBroker is ICoverBroker, Ownable { IMemberRoles public immutable memberRoles; IPool public immutable pool; INXMToken public immutable nxmToken; + address public immutable tokenController; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint private constant ETH_ASSET_ID = 0; uint private constant NXM_ASSET_ID = type(uint8).max; - constructor(address _cover, address _memberRoles, address _pool, address _nxmToken) { + constructor(address _cover, address _memberRoles, address _pool, address _nxmToken, address _tokenController) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); pool = IPool(_pool); nxmToken = INXMToken(_nxmToken); + + tokenController = _tokenController; + nxmToken.approve(tokenController, type(uint256).max); } /// @notice Buys cover on behalf of the caller. Supports ETH, NXM and ERC20 asset payments which are supported by the pool. diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js index e0b87ccad4..f945b93d24 100644 --- a/test/fork/cover-broker.js +++ b/test/fork/cover-broker.js @@ -219,6 +219,7 @@ describe('CoverBroker', function () { this.memberRoles.address, this.pool.address, this.nxm.address, + this.tokenController.address, ]); await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index 478d834087..af4d61dcde 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -353,7 +353,7 @@ describe('buyCover', function () { }); }); -describe.only('CoverBroker - buyCover', function () { +describe('CoverBroker - buyCover', function () { it('should revert with InvalidPayment if paymentAsset is not ETH and msg.value > 0', async function () { const fixture = await loadFixture(buyCoverSetup); const { coverBroker } = fixture.contracts; @@ -516,7 +516,7 @@ describe.only('CoverBroker - buyCover', function () { expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); }); - it.only('should buy cover through the broker from a member with NXM', async function () { + it('should buy cover through the broker from a member with NXM', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, @@ -525,8 +525,6 @@ describe.only('CoverBroker - buyCover', function () { ra: ramm, mcr, coverBroker, - dai, - priceFeedOracle, coverNFT, tk: nxm, } = fixture.contracts; @@ -540,13 +538,13 @@ describe.only('CoverBroker - buyCover', function () { } = fixture; const { period, amount } = buyCoverFixture; - await nxm.connect(defaultSender).transfer(coverBuyer.address, parseEther('1000')); - await nxm.connect(coverBuyer).approve(coverBroker.address, parseEther('1000')); + await nxm.connect(defaultSender).transfer(coverBuyer.address, parseEther('1000000')); + await nxm.connect(coverBuyer).approve(coverBroker.address, parseEther('1000000')); await coverBroker.maxApproveCoverContract(nxm.address); const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); const nextBlockTimestamp = currentTimestamp + 1; - const nxmRate = parseEther('1').div(50); + const ethRate = await getInternalPrice(ramm, pool, tokenController, mcr, nextBlockTimestamp); const { targetPrice } = stakedProductParamTemplate; @@ -555,16 +553,9 @@ describe.only('CoverBroker - buyCover', function () { ); const product = await stakingProducts.getProduct(1, productId); - const { premiumInNxm, premiumInAsset: premium } = calculatePremium( - amount, - nxmRate, - period, - product.targetPrice, - NXM_PER_ALLOCATION_UNIT, - ); + const { premiumInNxm } = calculatePremium(amount, ethRate, period, product.targetPrice, NXM_PER_ALLOCATION_UNIT); const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); - const poolBeforeETH = await ethers.provider.getBalance(pool.address); const balanceBefore = await nxm.balanceOf(coverBuyer.address); const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); @@ -576,7 +567,7 @@ describe.only('CoverBroker - buyCover', function () { paymentAsset: 255, // NXM productId, owner: coverBuyer.address, - maxPremiumInAsset: premium, + maxPremiumInAsset: premiumInNxm, }, [{ poolId: 1, coverAmountInAsset: amount }], { value: '0' }, @@ -591,9 +582,6 @@ describe.only('CoverBroker - buyCover', function () { const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); const stakingPoolAfter = await tokenController.stakingPoolNXMBalances(1); - const poolAfterETH = await ethers.provider.getBalance(pool.address); - const premiumInEth = premium.mul(nxmRate); expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); - expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); }); }); diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js index 948f7217ab..8305663f50 100644 --- a/test/integration/CoverBroker/switchMembership.js +++ b/test/integration/CoverBroker/switchMembership.js @@ -7,12 +7,13 @@ const setup = require('../setup'); describe('CoverBroker - switchMembership', function () { it('should switch membership', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1, tk } = fixture.contracts; + const { cover, mr, coverBroker, p1, tk, tc } = fixture.contracts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, p1.address, tk.address, + tc.address, ]); // Add NXM balance to CoverBroker @@ -27,13 +28,14 @@ describe('CoverBroker - switchMembership', function () { it('should fail to switch membership if the caller is not the owner', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1, tk } = fixture.contracts; + const { cover, mr, coverBroker, p1, tk, tc } = fixture.contracts; const { members } = fixture.accounts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, p1.address, tk.address, + tc.address, ]); await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( diff --git a/test/integration/setup.js b/test/integration/setup.js index b6fe3bb727..e941868198 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -485,7 +485,13 @@ async function setup() { ); // deploy CoverBroker - const coverBroker = await ethers.deployContract('CoverBroker', [cover.address, mr.address, p1.address, tk.address]); + const coverBroker = await ethers.deployContract('CoverBroker', [ + cover.address, + mr.address, + p1.address, + tk.address, + tc.address, + ]); await master.connect(governanceSigner).upgradeMultipleContracts([toBytes2('P1')], [p1.address]); diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index 1b14907521..97cd4edf17 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -12,11 +12,13 @@ async function setup() { const memberRoles = await ethers.deployContract('MemberRolesMock'); const pool = await ethers.deployContract('PoolMock'); const tk = await ethers.deployContract('NXMTokenMock'); + const tc = await ethers.deployContract('TokenControllerMock', [tk.address]); const coverBroker = await ethers.deployContract('CoverBroker', [ cover.address, memberRoles.address, pool.address, tk.address, + tc.address, ]); await memberRoles.setRole(coverBroker.address, 2); From 4d177728965076422c389311c419b1396916ce3a Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Wed, 3 Apr 2024 16:31:06 +0300 Subject: [PATCH 78/88] Add CoverBroker buyCover amountOver tests - NXM / ERC20 --- test/integration/Cover/buyCover.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index af4d61dcde..014768b01c 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -367,7 +367,7 @@ describe('CoverBroker - buyCover', function () { await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidPayment'); }); - it('should buy cover through the broker with ETH', async function () { + it('should enable non-members to buy cover through the broker with ETH', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, stakingProducts, p1: pool, ra: ramm, mcr, coverBroker, coverNFT } = fixture.contracts; const { @@ -425,6 +425,7 @@ describe('CoverBroker - buyCover', function () { const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); + // amountOver should have been refunded expect(balanceAfter).to.be.equal(balanceBefore.sub(premium).sub(receipt.effectiveGasPrice.mul(receipt.gasUsed))); const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); @@ -434,7 +435,7 @@ describe('CoverBroker - buyCover', function () { expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premium)); }); - it('should buy cover through the broker with DAI', async function () { + it('should enable non-members to buy cover through the broker with DAI', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, @@ -484,6 +485,7 @@ describe('CoverBroker - buyCover', function () { const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); const poolBeforeETH = await ethers.provider.getBalance(pool.address); + const amountOver = parseEther('1'); const balanceBefore = await dai.balanceOf(coverBuyer.address); const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); await setNextBlockTime(nextBlockTimestamp); @@ -493,7 +495,7 @@ describe('CoverBroker - buyCover', function () { ...buyCoverFixture, productId, owner: coverBuyer.address, - maxPremiumInAsset: premium, + maxPremiumInAsset: premium.add(amountOver), coverAsset: 1, paymentAsset: 1, // DAI }, @@ -505,6 +507,7 @@ describe('CoverBroker - buyCover', function () { const balanceAfter = await dai.balanceOf(coverBuyer.address); const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); + // amountOver should have been refunded expect(balanceAfter).to.be.equal(balanceBefore.sub(premium)); expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); @@ -516,7 +519,7 @@ describe('CoverBroker - buyCover', function () { expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); }); - it('should buy cover through the broker from a member with NXM', async function () { + it('should enable members to buy cover through the broker with NXM', async function () { const fixture = await loadFixture(buyCoverSetup); const { tc: tokenController, @@ -557,6 +560,7 @@ describe('CoverBroker - buyCover', function () { const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); + const amountOver = parseEther('1'); const balanceBefore = await nxm.balanceOf(coverBuyer.address); const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); await setNextBlockTime(nextBlockTimestamp); @@ -567,7 +571,7 @@ describe('CoverBroker - buyCover', function () { paymentAsset: 255, // NXM productId, owner: coverBuyer.address, - maxPremiumInAsset: premiumInNxm, + maxPremiumInAsset: premiumInNxm.add(amountOver), }, [{ poolId: 1, coverAmountInAsset: amount }], { value: '0' }, @@ -577,6 +581,7 @@ describe('CoverBroker - buyCover', function () { const balanceAfter = await nxm.balanceOf(coverBuyer.address); const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); + // amountOver should have been refunded expect(balanceAfter).to.be.equal(balanceBefore.sub(premiumInNxm)); expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); From ea2d0b8e6e3d713c4603a5b41d0d19c68f62269a Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 5 Apr 2024 16:36:02 +0300 Subject: [PATCH 79/88] Drop NXM payment support + add validation * InvalidOwnerAddress validation * InvalidPaymentAsset validation --- contracts/external/cover/CoverBroker.sol | 104 +++++------------------ contracts/interfaces/ICoverBroker.sol | 2 + 2 files changed, 21 insertions(+), 85 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 6cf2f2b2f1..3f3913bd2d 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -15,7 +15,8 @@ import "../../interfaces/INXMToken.sol"; /// @title Cover Broker Contract /// @notice Enables non-members of the mutual to purchase cover policies. -/// Supports ETH, NXM, and ERC20 asset payments which are supported by the pool. +/// Supports ETH and ERC20 asset payments which are supported by the pool. +/// For NXM payments by members, please call Cover.buyCover instead. /// @dev See supported ERC20 payment methods via pool.getAssets. contract CoverBroker is ICoverBroker, Ownable { using SafeERC20 for IERC20; @@ -23,27 +24,23 @@ contract CoverBroker is ICoverBroker, Ownable { // Immutables ICover public immutable cover; IMemberRoles public immutable memberRoles; - IPool public immutable pool; INXMToken public immutable nxmToken; - address public immutable tokenController; + INXMMaster public immutable master; // Constants address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint private constant ETH_ASSET_ID = 0; uint private constant NXM_ASSET_ID = type(uint8).max; - constructor(address _cover, address _memberRoles, address _pool, address _nxmToken, address _tokenController) { + constructor(address _cover, address _memberRoles, address _nxmToken, address _master) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); - pool = IPool(_pool); nxmToken = INXMToken(_nxmToken); - - tokenController = _tokenController; - nxmToken.approve(tokenController, type(uint256).max); + master = INXMMaster(_master); } - /// @notice Buys cover on behalf of the caller. Supports ETH, NXM and ERC20 asset payments which are supported by the pool. - /// @dev For NXM and ERC20 payments, ensure the Cover contract is approved to spend the tokens first (maxApproveCoverContract). + /// @notice Buys cover on behalf of the caller. Supports ETH and ERC20 asset payments which are supported by the pool. + /// @dev For ERC20 payments, ensure the Cover contract is approved to spend the tokens first (maxApproveCoverContract). /// See supported ERC20 payment methods via pool.getAssets. /// @param params The parameters required to buy cover. /// @param poolAllocationRequests The allocation requests for the pool's liquidity. @@ -53,9 +50,17 @@ contract CoverBroker is ICoverBroker, Ownable { PoolAllocationRequest[] calldata poolAllocationRequests ) external payable returns (uint coverId) { + if (params.owner == address(0) || params.owner == address(this)) { + revert InvalidOwnerAddress(); + } + + if (params.paymentAsset == NXM_ASSET_ID) { + revert InvalidPaymentAsset(); + } + // ETH payment if (params.paymentAsset == ETH_ASSET_ID) { - return _handleEthPayment(params, poolAllocationRequests); + return _buyCoverEthPayment(params, poolAllocationRequests); } // msg.value must be 0 if not an ETH payment @@ -63,54 +68,8 @@ contract CoverBroker is ICoverBroker, Ownable { revert InvalidPayment(); } - // NXM payment - if (params.paymentAsset == NXM_ASSET_ID) { - return _handleNxmPayment(params, poolAllocationRequests); - } - // ERC20 payment - return _handleErc20Payment(params, poolAllocationRequests); - } - - /// @notice Allows the Cover contract to spend the maximum possible amount of a specified ERC20 token on behalf of the CoverBroker. - /// @param erc20 The ERC20 token for which to approve spending. - function maxApproveCoverContract(IERC20 erc20) external onlyOwner { - erc20.safeApprove(address(cover), type(uint256).max); - } - - /// @notice Switches CoverBroker's membership to a new address. - /// @dev MemberRoles contract needs to be approved to transfer NXM tokens to new membership address. - /// @param newAddress The address to which the membership will be switched. - function switchMembership(address newAddress) external onlyOwner { - nxmToken.approve(address(memberRoles), type(uint256).max); - memberRoles.switchMembership(newAddress); - } - - /// @notice Transfers all available funds of a specified asset (ETH or ERC20) to the contract owner. - /// @param assetAddress The address of the asset to be transferred. - function transferFunds(address assetAddress) external onlyOwner { - - if (assetAddress == ETH) { - uint ethBalance = address(this).balance; - if (ethBalance == 0) { - revert ZeroBalance(ETH); - } - - (bool sent, ) = payable(msg.sender).call{value: ethBalance}(""); - if (!sent) { - revert TransferFailed(msg.sender, ethBalance, ETH); - } - - return; - } - - IERC20 asset = IERC20(assetAddress); - uint erc20Balance = asset.balanceOf(address(this)); - if (erc20Balance == 0) { - revert ZeroBalance(assetAddress); - } - - asset.transfer(msg.sender, erc20Balance); + return _buyCoverErc20Payment(params, poolAllocationRequests); } /// @notice Handles ETH payments for buying cover. @@ -118,7 +77,7 @@ contract CoverBroker is ICoverBroker, Ownable { /// @param params The parameters required to buy cover. /// @param poolAllocationRequests The allocation requests for the pool's liquidity. /// @return coverId The ID of the purchased cover. - function _handleEthPayment( + function _buyCoverEthPayment( BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests ) internal returns (uint coverId) { @@ -137,38 +96,13 @@ contract CoverBroker is ICoverBroker, Ownable { } } - /// @notice Handles NXM payments for buying cover. - /// @dev Transfers NXM from the caller to the contract, then buys cover on behalf of the caller. - /// Calculates NXM refunds if any and sends back to msg.sender. - /// @param params The parameters required to buy cover. - /// @param poolAllocationRequests The allocation requests for the pool's liquidity. - /// @return coverId The ID of the purchased cover. - function _handleNxmPayment( - BuyCoverParams calldata params, - PoolAllocationRequest[] calldata poolAllocationRequests - ) internal returns (uint coverId) { - - uint nxmBalanceBefore = nxmToken.balanceOf(address(this)); - - nxmToken.transferFrom(msg.sender, address(this), params.maxPremiumInAsset); - coverId = cover.buyCover(params, poolAllocationRequests); - - uint nxmBalanceAfter = nxmToken.balanceOf(address(this)); - - // transfer any NXM refund back to msg.sender - if (nxmBalanceAfter > nxmBalanceBefore) { - uint nxmRefund = nxmBalanceAfter - nxmBalanceBefore; - nxmToken.transfer(msg.sender, nxmRefund); - } - } - /// @notice Handles ERC20 payments for buying cover. /// @dev Transfers ERC20 tokens from the caller to the contract, then buys cover on behalf of the caller. /// Calculates ERC20 refunds if any and sends back to msg.sender. /// @param params The parameters required to buy cover. /// @param poolAllocationRequests The allocation requests for the pool's liquidity. /// @return coverId The ID of the purchased cover. - function _handleErc20Payment( + function _buyCoverErc20Payment( BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests ) internal returns (uint coverId) { diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol index 887b93bb57..00a1b25f16 100644 --- a/contracts/interfaces/ICoverBroker.sol +++ b/contracts/interfaces/ICoverBroker.sol @@ -24,5 +24,7 @@ interface ICoverBroker { error TransferFailed(address to, uint value, address token); error ZeroBalance(address token); + error InvalidOwnerAddress(); + error InvalidPaymentAsset(); error InvalidPayment(); } From 3bc3f49b438663e031dfe2996593264f8bb91f09 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 5 Apr 2024 16:37:55 +0300 Subject: [PATCH 80/88] Rename method to rescueFunds + grab pool instance from master --- contracts/external/cover/CoverBroker.sol | 52 +++++++++++++++++++++++- contracts/interfaces/ICoverBroker.sol | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 3f3913bd2d..2cdfd4bab4 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -11,6 +11,7 @@ import "../../interfaces/ICoverBroker.sol"; import "../../interfaces/IMemberRoles.sol"; import "../../interfaces/IPool.sol"; import "../../interfaces/INXMToken.sol"; +import "../../interfaces/INXMMaster.sol"; /// @title Cover Broker Contract @@ -107,7 +108,7 @@ contract CoverBroker is ICoverBroker, Ownable { PoolAllocationRequest[] calldata poolAllocationRequests ) internal returns (uint coverId) { - address paymentAsset = pool.getAsset(params.paymentAsset).assetAddress; + address paymentAsset = _pool().getAsset(params.paymentAsset).assetAddress; IERC20 erc20 = IERC20(paymentAsset); uint erc20BalanceBefore = erc20.balanceOf(address(this)); @@ -124,5 +125,54 @@ contract CoverBroker is ICoverBroker, Ownable { } } + /// @notice Allows the Cover contract to spend the maximum possible amount of a specified ERC20 token on behalf of the CoverBroker. + /// @param erc20 The ERC20 token for which to approve spending. + function maxApproveCoverContract(IERC20 erc20) external onlyOwner { + erc20.safeApprove(address(cover), type(uint256).max); + } + + /// @notice Switches CoverBroker's membership to a new address. + /// @dev MemberRoles contract needs to be approved to transfer NXM tokens to new membership address. + /// @param newAddress The address to which the membership will be switched. + function switchMembership(address newAddress) external onlyOwner { + nxmToken.approve(address(memberRoles), type(uint256).max); + memberRoles.switchMembership(newAddress); + } + + /// @notice Recovers all available funds of a specified asset (ETH or ERC20) to the contract owner. + /// @param assetAddress The address of the asset to be rescued. + function rescueFunds(address assetAddress) external onlyOwner { + + if (assetAddress == ETH) { + uint ethBalance = address(this).balance; + if (ethBalance == 0) { + revert ZeroBalance(ETH); + } + + (bool sent, ) = payable(msg.sender).call{value: ethBalance}(""); + if (!sent) { + revert TransferFailed(msg.sender, ethBalance, ETH); + } + + return; + } + + IERC20 asset = IERC20(assetAddress); + uint erc20Balance = asset.balanceOf(address(this)); + if (erc20Balance == 0) { + revert ZeroBalance(assetAddress); + } + + asset.transfer(msg.sender, erc20Balance); + } + + /* ========== DEPENDENCIES ========== */ + + /// @dev Fetches the Pool's instance through master contract + /// @return The Pool's instance + function _pool() internal view returns (IPool) { + return IPool(master.getLatestAddress("P1")); + } + receive() external payable {} } diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol index 00a1b25f16..cc3dcab072 100644 --- a/contracts/interfaces/ICoverBroker.sol +++ b/contracts/interfaces/ICoverBroker.sol @@ -18,7 +18,7 @@ interface ICoverBroker { function switchMembership(address newAddress) external; - function transferFunds(address assetAddress) external; + function rescueFunds(address assetAddress) external; /* ==== ERRORS ==== */ From 53b9f2a6e55949ecc2ae29eef8cb767713f74935 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 5 Apr 2024 16:38:31 +0300 Subject: [PATCH 81/88] Fix and update tests --- test/fork/cover-broker.js | 3 +- test/integration/Cover/buyCover.js | 114 +++++++----------- .../CoverBroker/switchMembership.js | 10 +- test/integration/setup.js | 3 +- .../{transferFunds.js => rescueFunds.js} | 14 +-- test/unit/CoverBroker/setup.js | 6 +- 6 files changed, 56 insertions(+), 94 deletions(-) rename test/unit/CoverBroker/{transferFunds.js => rescueFunds.js} (81%) diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js index f945b93d24..0edcbfc222 100644 --- a/test/fork/cover-broker.js +++ b/test/fork/cover-broker.js @@ -217,9 +217,8 @@ describe('CoverBroker', function () { this.coverBroker = await ethers.deployContract('CoverBroker', [ this.cover.address, this.memberRoles.address, - this.pool.address, this.nxm.address, - this.tokenController.address, + this.master.address, ]); await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index 014768b01c..a4f841b8da 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -354,15 +354,54 @@ describe('buyCover', function () { }); describe('CoverBroker - buyCover', function () { + it('should revert with InvalidOwnerAddress if params.owner is zero address', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { coverBroker } = fixture.contracts; + const [coverBuyer] = fixture.accounts.nonMembers; + + const buyCoverParams = { ...buyCoverFixture, paymentAsset: 1, owner: AddressZero }; + const buyCover = coverBroker + .connect(coverBuyer) + .buyCover(buyCoverParams, [{ poolId: 1, coverAmountInAsset: parseEther('1') }], { value: parseEther('1') }); + + await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidOwnerAddress'); + }); + + it('should revert with InvalidOwnerAddress if params.owner is CoverBroker address', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { coverBroker } = fixture.contracts; + const [coverBuyer] = fixture.accounts.nonMembers; + + const buyCoverParams = { ...buyCoverFixture, paymentAsset: 1, owner: coverBroker.address }; + const buyCover = coverBroker + .connect(coverBuyer) + .buyCover(buyCoverParams, [{ poolId: 1, coverAmountInAsset: parseEther('1') }], { value: parseEther('1') }); + + await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidOwnerAddress'); + }); + + it('should revert with InvalidPaymentAsset if paymentAsset NXM asset ID (255)', async function () { + const fixture = await loadFixture(buyCoverSetup); + const { coverBroker } = fixture.contracts; + const [coverBuyer] = fixture.accounts.nonMembers; + + const buyCoverParams = { ...buyCoverFixture, paymentAsset: 255, owner: coverBuyer.address }; // NXM (invalid) + const buyCover = coverBroker + .connect(coverBuyer) + .buyCover(buyCoverParams, [{ poolId: 1, coverAmountInAsset: parseEther('1') }], { value: parseEther('1') }); + + await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidPaymentAsset'); + }); + it('should revert with InvalidPayment if paymentAsset is not ETH and msg.value > 0', async function () { const fixture = await loadFixture(buyCoverSetup); const { coverBroker } = fixture.contracts; const [coverBuyer] = fixture.accounts.nonMembers; - const poolAllocationRequest = { poolId: 1, coverAmountInAsset: parseEther('1') }; + const buyCoverParams = { ...buyCoverFixture, paymentAsset: 2, owner: coverBuyer.address }; // DAI const buyCover = coverBroker .connect(coverBuyer) - .buyCover({ ...buyCoverFixture, paymentAsset: 1 }, [poolAllocationRequest], { value: parseEther('1') }); + .buyCover(buyCoverParams, [{ poolId: 1, coverAmountInAsset: parseEther('1') }], { value: parseEther('1') }); await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidPayment'); }); @@ -518,75 +557,4 @@ describe('CoverBroker - buyCover', function () { expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); expect(poolAfterETH).to.be.equal(poolBeforeETH.add(premiumInEth)); }); - - it('should enable members to buy cover through the broker with NXM', async function () { - const fixture = await loadFixture(buyCoverSetup); - const { - tc: tokenController, - stakingProducts, - p1: pool, - ra: ramm, - mcr, - coverBroker, - coverNFT, - tk: nxm, - } = fixture.contracts; - const { - members: [coverBuyer], - defaultSender, - } = fixture.accounts; - const { - config: { NXM_PER_ALLOCATION_UNIT, GLOBAL_REWARDS_RATIO }, - productList, - } = fixture; - const { period, amount } = buyCoverFixture; - - await nxm.connect(defaultSender).transfer(coverBuyer.address, parseEther('1000000')); - await nxm.connect(coverBuyer).approve(coverBroker.address, parseEther('1000000')); - await coverBroker.maxApproveCoverContract(nxm.address); - - const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); - const nextBlockTimestamp = currentTimestamp + 1; - const ethRate = await getInternalPrice(ramm, pool, tokenController, mcr, nextBlockTimestamp); - - const { targetPrice } = stakedProductParamTemplate; - - const productId = productList.findIndex( - ({ product: { initialPriceRatio, useFixedPrice } }) => targetPrice !== initialPriceRatio && useFixedPrice, - ); - - const product = await stakingProducts.getProduct(1, productId); - const { premiumInNxm } = calculatePremium(amount, ethRate, period, product.targetPrice, NXM_PER_ALLOCATION_UNIT); - - const stakingPoolBefore = await tokenController.stakingPoolNXMBalances(1); - - const amountOver = parseEther('1'); - const balanceBefore = await nxm.balanceOf(coverBuyer.address); - const nftBalanceBefore = await coverNFT.balanceOf(coverBuyer.address); - await setNextBlockTime(nextBlockTimestamp); - - await coverBroker.connect(coverBuyer).buyCover( - { - ...buyCoverFixture, - paymentAsset: 255, // NXM - productId, - owner: coverBuyer.address, - maxPremiumInAsset: premiumInNxm.add(amountOver), - }, - [{ poolId: 1, coverAmountInAsset: amount }], - { value: '0' }, - ); - - const { timestamp } = await ethers.provider.getBlock('latest'); - const balanceAfter = await nxm.balanceOf(coverBuyer.address); - const nftBalanceAfter = await coverNFT.balanceOf(coverBuyer.address); - - // amountOver should have been refunded - expect(balanceAfter).to.be.equal(balanceBefore.sub(premiumInNxm)); - expect(nftBalanceAfter).to.be.equal(nftBalanceBefore.add(1)); - const rewards = calculateRewards(premiumInNxm, timestamp, period, GLOBAL_REWARDS_RATIO); - - const stakingPoolAfter = await tokenController.stakingPoolNXMBalances(1); - expect(stakingPoolAfter.rewards).to.be.equal(stakingPoolBefore.rewards.add(rewards)); - }); }); diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js index 8305663f50..0f30016278 100644 --- a/test/integration/CoverBroker/switchMembership.js +++ b/test/integration/CoverBroker/switchMembership.js @@ -7,13 +7,12 @@ const setup = require('../setup'); describe('CoverBroker - switchMembership', function () { it('should switch membership', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1, tk, tc } = fixture.contracts; + const { cover, mr, coverBroker, tk, master } = fixture.contracts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, - p1.address, tk.address, - tc.address, + master.address, ]); // Add NXM balance to CoverBroker @@ -28,14 +27,13 @@ describe('CoverBroker - switchMembership', function () { it('should fail to switch membership if the caller is not the owner', async function () { const fixture = await loadFixture(setup); - const { cover, mr, coverBroker, p1, tk, tc } = fixture.contracts; + const { cover, mr, coverBroker, tk, master } = fixture.contracts; const { members } = fixture.accounts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, - p1.address, tk.address, - tc.address, + master.address, ]); await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( diff --git a/test/integration/setup.js b/test/integration/setup.js index e941868198..98f98039c7 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -488,9 +488,8 @@ async function setup() { const coverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, - p1.address, tk.address, - tc.address, + master.address, ]); await master.connect(governanceSigner).upgradeMultipleContracts([toBytes2('P1')], [p1.address]); diff --git a/test/unit/CoverBroker/transferFunds.js b/test/unit/CoverBroker/rescueFunds.js similarity index 81% rename from test/unit/CoverBroker/transferFunds.js rename to test/unit/CoverBroker/rescueFunds.js index ce03eb7a74..c416b9f793 100644 --- a/test/unit/CoverBroker/transferFunds.js +++ b/test/unit/CoverBroker/rescueFunds.js @@ -8,8 +8,8 @@ const { setEtherBalance } = require('../utils').evm; const ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; const { parseEther } = ethers.utils; -describe('transferFunds', function () { - it('should transfer funds (ETH) to owner', async function () { +describe('rescueFunds', function () { + it('should rescue funds (ETH) to owner', async function () { const fixture = await loadFixture(setup); const { contracts: { coverBroker }, @@ -20,7 +20,7 @@ describe('transferFunds', function () { const brokerBalanceBefore = await ethers.provider.getBalance(coverBroker.address); const ownerBalanceBefore = await ethers.provider.getBalance(coverBrokerOwner.address); - const tx = await coverBroker.connect(coverBrokerOwner).transferFunds(ETH); + const tx = await coverBroker.connect(coverBrokerOwner).rescueFunds(ETH); const receipt = await tx.wait(); const brokerBalanceAfter = await ethers.provider.getBalance(coverBroker.address); @@ -33,7 +33,7 @@ describe('transferFunds', function () { ); }); - it('should transfer funds (nonETH) to owner', async function () { + it('should rescue funds (nonETH) to owner', async function () { const fixture = await loadFixture(setup); const { contracts: { coverBroker, dai }, @@ -44,7 +44,7 @@ describe('transferFunds', function () { const brokerBalanceBefore = await dai.balanceOf(coverBroker.address); const ownerBalanceBefore = await dai.balanceOf(coverBrokerOwner.address); - await coverBroker.connect(coverBrokerOwner).transferFunds(dai.address); + await coverBroker.connect(coverBrokerOwner).rescueFunds(dai.address); const brokerBalanceAfter = await dai.balanceOf(coverBroker.address); const ownerBalanceAfter = await dai.balanceOf(coverBrokerOwner.address); @@ -54,7 +54,7 @@ describe('transferFunds', function () { expect(ownerBalanceAfter).to.equal(ownerBalanceBefore.add(brokerBalanceBefore)); }); - it('should fail to transfer funds if the caller is not the owner', async function () { + it('should fail to rescue funds if the caller is not the owner', async function () { const fixture = await loadFixture(setup); const { coverBroker } = fixture.contracts; const nonOwner = await ethers.Wallet.createRandom().connect(ethers.provider); @@ -62,7 +62,7 @@ describe('transferFunds', function () { const balanceBefore = await ethers.provider.getBalance(coverBroker.address); - await expect(coverBroker.connect(nonOwner).transferFunds(ETH)).to.revertedWith('Ownable: caller is not the owner'); + await expect(coverBroker.connect(nonOwner).rescueFunds(ETH)).to.revertedWith('Ownable: caller is not the owner'); const balanceAfter = await ethers.provider.getBalance(coverBroker.address); expect(balanceAfter).to.equal(balanceBefore); }); diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index 97cd4edf17..c81bed5c52 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -10,15 +10,13 @@ async function setup() { const dai = await ethers.deployContract('ERC20Mock'); const cover = await ethers.deployContract('CMMockCover'); const memberRoles = await ethers.deployContract('MemberRolesMock'); - const pool = await ethers.deployContract('PoolMock'); const tk = await ethers.deployContract('NXMTokenMock'); - const tc = await ethers.deployContract('TokenControllerMock', [tk.address]); + const master = await ethers.deployContract('MasterMock'); const coverBroker = await ethers.deployContract('CoverBroker', [ cover.address, memberRoles.address, - pool.address, tk.address, - tc.address, + master.address, ]); await memberRoles.setRole(coverBroker.address, 2); From 6e54138b0d18d0d7385247b4b7cf056d73b09f79 Mon Sep 17 00:00:00 2001 From: Rocky Murdoch Date: Fri, 5 Apr 2024 16:44:23 +0300 Subject: [PATCH 82/88] Improve CoverBroker documentation and test cases wording --- contracts/external/cover/CoverBroker.sol | 8 ++++---- test/integration/Cover/buyCover.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index 2cdfd4bab4..bd58ac795e 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -16,9 +16,9 @@ import "../../interfaces/INXMMaster.sol"; /// @title Cover Broker Contract /// @notice Enables non-members of the mutual to purchase cover policies. -/// Supports ETH and ERC20 asset payments which are supported by the pool. +/// Supports payments in ETH and pool supported ERC20 assets. /// For NXM payments by members, please call Cover.buyCover instead. -/// @dev See supported ERC20 payment methods via pool.getAssets. +/// @dev See supported ERC20 asset payments via pool.getAssets. contract CoverBroker is ICoverBroker, Ownable { using SafeERC20 for IERC20; @@ -40,9 +40,9 @@ contract CoverBroker is ICoverBroker, Ownable { master = INXMMaster(_master); } - /// @notice Buys cover on behalf of the caller. Supports ETH and ERC20 asset payments which are supported by the pool. + /// @notice Buys cover on behalf of the caller. Supports payments in ETH and pool supported ERC20 assets. /// @dev For ERC20 payments, ensure the Cover contract is approved to spend the tokens first (maxApproveCoverContract). - /// See supported ERC20 payment methods via pool.getAssets. + /// See supported ERC20 asset payments via pool.getAssets. /// @param params The parameters required to buy cover. /// @param poolAllocationRequests The allocation requests for the pool's liquidity. /// @return coverId The ID of the purchased cover. diff --git a/test/integration/Cover/buyCover.js b/test/integration/Cover/buyCover.js index a4f841b8da..dcbae5f03f 100644 --- a/test/integration/Cover/buyCover.js +++ b/test/integration/Cover/buyCover.js @@ -380,7 +380,7 @@ describe('CoverBroker - buyCover', function () { await expect(buyCover).to.revertedWithCustomError(coverBroker, 'InvalidOwnerAddress'); }); - it('should revert with InvalidPaymentAsset if paymentAsset NXM asset ID (255)', async function () { + it('should revert with InvalidPaymentAsset if paymentAsset is NXM asset ID (255)', async function () { const fixture = await loadFixture(buyCoverSetup); const { coverBroker } = fixture.contracts; const [coverBuyer] = fixture.accounts.nonMembers; From 1b2d5f4a52067faa8150dffd172c4fece61478b3 Mon Sep 17 00:00:00 2001 From: shark0der Date: Sat, 6 Apr 2024 18:24:49 +0300 Subject: [PATCH 83/88] Add initial owner constructor param for CREATE2 compatibility --- contracts/external/cover/CoverBroker.sol | 22 ++++++++++++++-------- contracts/interfaces/ICoverBroker.sol | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/contracts/external/cover/CoverBroker.sol b/contracts/external/cover/CoverBroker.sol index bd58ac795e..05360e9075 100644 --- a/contracts/external/cover/CoverBroker.sol +++ b/contracts/external/cover/CoverBroker.sol @@ -9,10 +9,9 @@ import "@openzeppelin/contracts-v4/access/Ownable.sol"; import "../../interfaces/ICover.sol"; import "../../interfaces/ICoverBroker.sol"; import "../../interfaces/IMemberRoles.sol"; -import "../../interfaces/IPool.sol"; -import "../../interfaces/INXMToken.sol"; import "../../interfaces/INXMMaster.sol"; - +import "../../interfaces/INXMToken.sol"; +import "../../interfaces/IPool.sol"; /// @title Cover Broker Contract /// @notice Enables non-members of the mutual to purchase cover policies. @@ -33,11 +32,18 @@ contract CoverBroker is ICoverBroker, Ownable { uint private constant ETH_ASSET_ID = 0; uint private constant NXM_ASSET_ID = type(uint8).max; - constructor(address _cover, address _memberRoles, address _nxmToken, address _master) { + constructor( + address _cover, + address _memberRoles, + address _nxmToken, + address _master, + address _owner + ) { cover = ICover(_cover); memberRoles = IMemberRoles(_memberRoles); nxmToken = INXMToken(_nxmToken); master = INXMMaster(_master); + transferOwnership(_owner); } /// @notice Buys cover on behalf of the caller. Supports payments in ETH and pool supported ERC20 assets. @@ -90,13 +96,13 @@ contract CoverBroker is ICoverBroker, Ownable { // transfer any ETH refund back to msg.sender if (ethBalanceAfter > ethBalanceBefore) { uint ethRefund = ethBalanceAfter - ethBalanceBefore; - (bool sent, ) = payable(msg.sender).call{value: ethRefund}(""); + (bool sent,) = payable(msg.sender).call{value: ethRefund}(""); if (!sent) { revert TransferFailed(msg.sender, ethRefund, ETH); } } } - + /// @notice Handles ERC20 payments for buying cover. /// @dev Transfers ERC20 tokens from the caller to the contract, then buys cover on behalf of the caller. /// Calculates ERC20 refunds if any and sends back to msg.sender. @@ -107,7 +113,7 @@ contract CoverBroker is ICoverBroker, Ownable { BuyCoverParams calldata params, PoolAllocationRequest[] calldata poolAllocationRequests ) internal returns (uint coverId) { - + address paymentAsset = _pool().getAsset(params.paymentAsset).assetAddress; IERC20 erc20 = IERC20(paymentAsset); @@ -149,7 +155,7 @@ contract CoverBroker is ICoverBroker, Ownable { revert ZeroBalance(ETH); } - (bool sent, ) = payable(msg.sender).call{value: ethBalance}(""); + (bool sent,) = payable(msg.sender).call{value: ethBalance}(""); if (!sent) { revert TransferFailed(msg.sender, ethBalance, ETH); } diff --git a/contracts/interfaces/ICoverBroker.sol b/contracts/interfaces/ICoverBroker.sol index cc3dcab072..04dd794e02 100644 --- a/contracts/interfaces/ICoverBroker.sol +++ b/contracts/interfaces/ICoverBroker.sol @@ -7,6 +7,7 @@ import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; import "../interfaces/ICover.sol"; interface ICoverBroker { + /* ==== FUNCTIONS ==== */ function buyCover( @@ -15,7 +16,7 @@ interface ICoverBroker { ) external payable returns (uint coverId); function maxApproveCoverContract(IERC20 token) external; - + function switchMembership(address newAddress) external; function rescueFunds(address assetAddress) external; From 3eebe514fdfd6bcbde1d1a334f0ec597cbd16300 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Mon, 8 Apr 2024 10:38:18 +0200 Subject: [PATCH 84/88] Fix tests with adding coverBroker owner to the constructor --- test/fork/cover-broker.js | 2 +- test/integration/CoverBroker/switchMembership.js | 5 ++++- test/integration/setup.js | 1 + test/unit/CoverBroker/setup.js | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/fork/cover-broker.js b/test/fork/cover-broker.js index 0edcbfc222..ca185ddd82 100644 --- a/test/fork/cover-broker.js +++ b/test/fork/cover-broker.js @@ -219,9 +219,9 @@ describe('CoverBroker', function () { this.memberRoles.address, this.nxm.address, this.master.address, + this.coverBrokerOwner.address, ]); - await this.coverBroker.transferOwnership(this.coverBrokerOwner.address); const ownerAfter = await this.coverBroker.owner(); expect(this.coverBrokerOwner.address).to.be.equal(ownerAfter); diff --git a/test/integration/CoverBroker/switchMembership.js b/test/integration/CoverBroker/switchMembership.js index 0f30016278..4c38dc38fe 100644 --- a/test/integration/CoverBroker/switchMembership.js +++ b/test/integration/CoverBroker/switchMembership.js @@ -8,11 +8,13 @@ describe('CoverBroker - switchMembership', function () { it('should switch membership', async function () { const fixture = await loadFixture(setup); const { cover, mr, coverBroker, tk, master } = fixture.contracts; + const { defaultSender } = fixture.accounts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, tk.address, master.address, + defaultSender.address, ]); // Add NXM balance to CoverBroker @@ -28,12 +30,13 @@ describe('CoverBroker - switchMembership', function () { it('should fail to switch membership if the caller is not the owner', async function () { const fixture = await loadFixture(setup); const { cover, mr, coverBroker, tk, master } = fixture.contracts; - const { members } = fixture.accounts; + const { members, defaultSender } = fixture.accounts; const newCoverBroker = await ethers.deployContract('CoverBroker', [ cover.address, mr.address, tk.address, master.address, + defaultSender.address, ]); await expect(coverBroker.connect(members[0]).switchMembership(newCoverBroker.address)).to.revertedWith( diff --git a/test/integration/setup.js b/test/integration/setup.js index 98f98039c7..09c5a1b43a 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -490,6 +490,7 @@ async function setup() { mr.address, tk.address, master.address, + owner.address, ]); await master.connect(governanceSigner).upgradeMultipleContracts([toBytes2('P1')], [p1.address]); diff --git a/test/unit/CoverBroker/setup.js b/test/unit/CoverBroker/setup.js index c81bed5c52..b32be5a9ab 100644 --- a/test/unit/CoverBroker/setup.js +++ b/test/unit/CoverBroker/setup.js @@ -17,10 +17,10 @@ async function setup() { memberRoles.address, tk.address, master.address, + coverBrokerOwner.address, ]); await memberRoles.setRole(coverBroker.address, 2); - await coverBroker.transferOwnership(coverBrokerOwner.address); return { coverBrokerOwner, From 52e46119de091b486d8c4bfc8113d93b3af20a40 Mon Sep 17 00:00:00 2001 From: Miljan Milidrag Date: Wed, 10 Apr 2024 17:33:34 +0200 Subject: [PATCH 85/88] Add CoverBroker to contract/address list --- deployments/build.js | 3 ++- deployments/src/addresses.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deployments/build.js b/deployments/build.js index a1f31819ac..dba48babd4 100644 --- a/deployments/build.js +++ b/deployments/build.js @@ -12,7 +12,7 @@ const contractList = [ 'CoverNFT', 'CoverViewer', ['Aggregator', 'EACAggregatorProxy'], - ['@openzeppelin/contracts/token/ERC20/ERC20.sol:ERC20', 'ERC20'], + ['@openzeppelin/contracts-v4/token/ERC20/ERC20.sol:ERC20', 'ERC20'], 'Governance', 'IndividualClaims', 'LegacyClaimProofs', @@ -38,6 +38,7 @@ const contractList = [ 'StakingViewer', 'SwapOperator', 'TokenController', + 'CoverBroker', 'wNXM', 'YieldTokenIncidents', ]; diff --git a/deployments/src/addresses.json b/deployments/src/addresses.json index 8c39d90138..bd93f00419 100644 --- a/deployments/src/addresses.json +++ b/deployments/src/addresses.json @@ -34,5 +34,6 @@ "SwapOperator": "0xcafea5C050E74a21C11Af78C927e17853153097D", "TokenController": "0x5407381b6c251cFd498ccD4A1d877739CB7960B8", "wNXM": "0x0d438F3b5175Bebc262bF23753C1E53d03432bDE", - "YieldTokenIncidents": "0xcafeac831dC5ca0D7ef467953b7822D2f44C8f83" + "YieldTokenIncidents": "0xcafeac831dC5ca0D7ef467953b7822D2f44C8f83", + "CoverBroker": "0x0000cbD7a26f72Ff222bf5f136901D224b08BE4E" } From af28d84897728593bc1dd97e76c36d15f2dc697f Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Thu, 11 Apr 2024 13:22:11 +0300 Subject: [PATCH 86/88] Bump deployments package version to 2.4.3 --- deployments/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployments/package.json b/deployments/package.json index 92a9d0dd7c..d75d924f49 100644 --- a/deployments/package.json +++ b/deployments/package.json @@ -1,6 +1,6 @@ { "name": "@nexusmutual/deployments", - "version": "2.4.2", + "version": "2.4.3", "description": "Nexus Mutual deployed contract addresses and abis", "typings": "./dist/index.d.ts", "main": "./dist/index.js", diff --git a/package.json b/package.json index daab870c17..96e76de7cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nexusmutual", - "version": "2.4.2", + "version": "2.4.3", "description": "NexusMutual smart contracts", "repository": { "type": "git", From a94a50af8c03b4ff4c20fdde8a7e84a8ca736ffd Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Tue, 21 May 2024 14:49:43 +0300 Subject: [PATCH 87/88] Bump version to 2.5.0 --- deployments/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deployments/package.json b/deployments/package.json index d75d924f49..7600aad0e9 100644 --- a/deployments/package.json +++ b/deployments/package.json @@ -1,6 +1,6 @@ { "name": "@nexusmutual/deployments", - "version": "2.4.3", + "version": "2.5.0", "description": "Nexus Mutual deployed contract addresses and abis", "typings": "./dist/index.d.ts", "main": "./dist/index.js", diff --git a/package-lock.json b/package-lock.json index 6b27a633d0..db116d231a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nexusmutual", - "version": "2.4.2", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nexusmutual", - "version": "2.4.2", + "version": "2.5.0", "license": "GPL-3.0", "dependencies": { "@nexusmutual/deployments": "^2.4.2", diff --git a/package.json b/package.json index 96e76de7cc..b70df91164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nexusmutual", - "version": "2.4.3", + "version": "2.5.0", "description": "NexusMutual smart contracts", "repository": { "type": "git", From fd6764270bfc6867a6314b044b1ffc3ad77ca0d5 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Tue, 21 May 2024 15:46:05 +0300 Subject: [PATCH 88/88] Bump dependencies --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index db116d231a..01e5b78af2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.5.0", "license": "GPL-3.0", "dependencies": { - "@nexusmutual/deployments": "^2.4.2", + "@nexusmutual/deployments": "^2.5.0", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", "dotenv": "^8.6.0", @@ -1341,9 +1341,9 @@ "dev": true }, "node_modules/@nexusmutual/deployments": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.4.2.tgz", - "integrity": "sha512-+ok7Li/+IwGxLO3rSLfeflqMsu7IcrJfsE5jX+bAbCznhRc37ZoQYlsLJBWpAGSxfS6c99t5ppNKLhJe/PC7Ww==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.5.0.tgz", + "integrity": "sha512-eTBswQrRxvYJIGn9GhEcYMBg/pmdW7fElmyfVGKCytSjbfMK/5WRf0h2BGZoO43+JAITL1WiKMuqujTJtKOysQ==" }, "node_modules/@noble/hashes": { "version": "1.1.2", @@ -14991,9 +14991,9 @@ "dev": true }, "@nexusmutual/deployments": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.4.2.tgz", - "integrity": "sha512-+ok7Li/+IwGxLO3rSLfeflqMsu7IcrJfsE5jX+bAbCznhRc37ZoQYlsLJBWpAGSxfS6c99t5ppNKLhJe/PC7Ww==" + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.5.0.tgz", + "integrity": "sha512-eTBswQrRxvYJIGn9GhEcYMBg/pmdW7fElmyfVGKCytSjbfMK/5WRf0h2BGZoO43+JAITL1WiKMuqujTJtKOysQ==" }, "@noble/hashes": { "version": "1.1.2", diff --git a/package.json b/package.json index b70df91164..52349ec86d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "homepage": "https://github.com/NexusMutual/smart-contracts", "dependencies": { - "@nexusmutual/deployments": "^2.4.2", + "@nexusmutual/deployments": "^2.5.0", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", "dotenv": "^8.6.0",