diff --git a/addresses/421614.json b/addresses/421614.json index b110ac27..804e7a48 100644 --- a/addresses/421614.json +++ b/addresses/421614.json @@ -5,7 +5,7 @@ "credit": "0x1343d88885eE888CEe79FEb3DfD0C5fC8fd65Af1" }, "implementation": { - "marketV1": "0x573B6a07d1Eb414B9CED0707DEca19b07798D105", + "marketV1": "0xC1162a62E48Dc8bb215Bd445D724c8f4EeF59586", "credit": "0x587A50Fd13161503384A2a431b838868Db0b3b39" } } \ No newline at end of file diff --git a/contracts/enclaves/MarketV1.sol b/contracts/enclaves/MarketV1.sol index 99cd5012..ad32ea64 100644 --- a/contracts/enclaves/MarketV1.sol +++ b/contracts/enclaves/MarketV1.sol @@ -181,6 +181,7 @@ contract MarketV1 is uint256 rate; uint256 balance; uint256 lastSettled; // payment has been settled up to this timestamp + uint256 maxRate; // max rate for the job } mapping(bytes32 => Job) public jobs; @@ -197,9 +198,9 @@ contract MarketV1 is event CreditTokenUpdated(address indexed oldCreditToken, address indexed newCreditToken); event NoticePeriodUpdated(uint256 noticePeriod); - event JobOpened(bytes32 indexed jobId, string metadata, address indexed owner, address indexed provider); + event JobOpened(bytes32 indexed jobId, string metadata, address indexed owner, address indexed provider, uint256 timestamp); event JobSettled(bytes32 indexed jobId, uint256 lastSettled); - event JobClosed(bytes32 indexed jobId); + event JobClosed(bytes32 indexed jobId, uint256 timestamp); event JobDeposited(bytes32 indexed jobId, address indexed token, address indexed from, uint256 amount); event JobWithdrawn(bytes32 indexed jobId, address indexed token, address indexed to, uint256 amount); event JobSettlementWithdrawn( @@ -250,14 +251,16 @@ contract MarketV1 is function _emergencyWithdrawCredit(address _to, bytes32[] calldata _jobIds) internal { require(hasRole(EMERGENCY_WITHDRAW_ROLE, _to), "only to emergency withdraw role"); - uint256 settleTill = block.timestamp + noticePeriod; - for (uint256 i = 0; i < _jobIds.length; i++) { bytes32 jobId = _jobIds[i]; - _jobSettle(jobId, jobs[jobId].rate, settleTill); + _jobSettle(jobId, jobs[jobId].rate); uint256 creditBalance = jobCreditBalance[jobId]; if (creditBalance > 0) { - _withdraw(jobId, _to, creditBalance); + jobs[jobId].balance -= creditBalance; + // set job credit balance to 0 + jobCreditBalance[jobId] = 0; + creditToken.safeTransfer(_to, creditBalance); + emit JobWithdrawn(jobId, address(creditToken), _to, creditBalance); } } } @@ -274,8 +277,8 @@ contract MarketV1 is bytes32 jobId = bytes32(_jobIndex); // create job with initial balance 0 - jobs[jobId] = Job(_metadata, _owner, _provider, 0, 0, block.timestamp); - emit JobOpened(jobId, _metadata, _owner, _provider); + jobs[jobId] = Job(_metadata, _owner, _provider, 0, 0, block.timestamp, 0); + emit JobOpened(jobId, _metadata, _owner, _provider, block.timestamp); // deposit initial balance _deposit(jobId, _msgSender(), _balance); @@ -284,25 +287,28 @@ contract MarketV1 is _jobReviseRate(jobId, _rate); } - function _jobSettle(bytes32 _jobId, uint256 _rate, uint256 _settleTill) internal returns (bool isBalanceEnough) { + function _jobSettle(bytes32 _jobId, uint256 _rate) internal returns (bool isBalanceEnough) { uint256 lastSettled = jobs[_jobId].lastSettled; - if (_settleTill == lastSettled) return true; - require(_settleTill > lastSettled, "cannot settle before lastSettled"); + if (block.timestamp <= lastSettled) { + return true; + } + require(jobs[_jobId].balance > 0, "insufficient funds to settle"); + require(jobs[_jobId].rate > 0, "invalid rate"); - uint256 usageDuration = _settleTill - lastSettled; + uint256 usageDuration = block.timestamp - lastSettled; uint256 amountUsed = _calcAmountUsed(_rate, usageDuration); uint256 settleAmount = _min(amountUsed, jobs[_jobId].balance); _settle(_jobId, settleAmount); - jobs[_jobId].lastSettled = _settleTill; - emit JobSettled(_jobId, _settleTill); + jobs[_jobId].lastSettled = block.timestamp; + emit JobSettled(_jobId, block.timestamp); isBalanceEnough = amountUsed <= settleAmount; } function _jobClose(bytes32 _jobId) internal { // deduct shutdown delay cost - _jobSettle(_jobId, jobs[_jobId].rate, block.timestamp + noticePeriod); + _jobSettle(_jobId, jobs[_jobId].rate); // refund leftover balance uint256 _balance = jobs[_jobId].balance; @@ -311,19 +317,19 @@ contract MarketV1 is } delete jobs[_jobId]; - emit JobClosed(_jobId); + emit JobClosed(_jobId, block.timestamp); } function _jobDeposit(bytes32 _jobId, uint256 _amount) internal { require(_amount > 0, "invalid amount"); - require(_jobSettle(_jobId, jobs[_jobId].rate, block.timestamp + noticePeriod), "insufficient funds to deposit"); + require(_jobSettle(_jobId, jobs[_jobId].rate), "insufficient funds to deposit"); _deposit(_jobId, _msgSender(), _amount); } function _jobWithdraw(bytes32 _jobId, uint256 _amount) internal { require(_amount > 0, "invalid amount"); - require(_jobSettle(_jobId, jobs[_jobId].rate, block.timestamp + noticePeriod), "insufficient funds to withdraw"); + require(_jobSettle(_jobId, jobs[_jobId].rate), "insufficient funds to withdraw"); // withdraw _withdraw(_jobId, _msgSender(), _amount); @@ -336,7 +342,7 @@ contract MarketV1 is uint256 lastSettled = jobs[_jobId].lastSettled; if (block.timestamp > lastSettled) { require( - _jobSettle(_jobId, jobs[_jobId].rate, block.timestamp), + _jobSettle(_jobId, jobs[_jobId].rate), "insufficient funds to settle before revising rate" ); } @@ -349,7 +355,13 @@ contract MarketV1 is // deduct shutdown delay cost // higher rate is used to calculate shutdown delay cost uint256 higherRate = _max(oldRate, _newRate); - require(_jobSettle(_jobId, higherRate, block.timestamp + noticePeriod), "insufficient funds to revise rate"); + uint256 prevHighestRate = jobs[_jobId].maxRate; + if (higherRate > prevHighestRate) { + jobs[_jobId].maxRate = higherRate; + uint256 noticePeriodExtraCost = _calcAmountUsed((higherRate - prevHighestRate), noticePeriod); + require(jobs[_jobId].balance > noticePeriodExtraCost, "insufficient funds to revise rate"); + _settle(_jobId, noticePeriodExtraCost); + } } function _jobMetadataUpdate(bytes32 _jobId, string calldata _metadata) internal { @@ -388,7 +400,7 @@ contract MarketV1 is * @param _jobId The job to settle. */ function jobSettle(bytes32 _jobId) external onlyExistingJob(_jobId) { - _jobSettle(_jobId, jobs[_jobId].rate, block.timestamp); + _jobSettle(_jobId, jobs[_jobId].rate); } /** diff --git a/contracts/token/Credit.sol b/contracts/token/Credit.sol index 281555de..38480f1f 100644 --- a/contracts/token/Credit.sol +++ b/contracts/token/Credit.sol @@ -18,6 +18,12 @@ import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +/** + * @title Credit + * @notice To transfer Credit tokens, either the sender or the recipient must have `TRANSFER_ALLOWED_ROLE`. + * @dev Admin must track the balance of USDC in the contract compared to the total supply of Credit. + */ + contract Credit is ContextUpgradeable, // _msgSender, _msgData AccessControlEnumerableUpgradeable, // RBAC enumeration @@ -29,19 +35,18 @@ contract Credit is uint256[500] private __gap0; - error OnlyAdmin(); - error OnlyTransferAllowedRole(); error NoAdminExists(); - error OnlyOysterMarket(); - error NotEnoughUSDC(); + error OnlyAdmin(); error OnlyToEmergencyWithdrawRole(); + error OnlyTransferAllowedRole(); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); // 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6 bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); // 0x3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a848 bytes32 public constant TRANSFER_ALLOWED_ROLE = keccak256("TRANSFER_ALLOWED_ROLE"); // 0xed89ee80d998965e2804dad373576bf7ffc490ba5986d52deb7d526e93617101 bytes32 public constant REDEEMER_ROLE = keccak256("REDEEMER_ROLE"); // 0x44ac9762eec3a11893fefb11d028bb3102560094137c3ed4518712475b2577cc bytes32 public constant EMERGENCY_WITHDRAW_ROLE = keccak256("EMERGENCY_WITHDRAW_ROLE"); // 0x66f144ecd65ad16d38ecdba8687842af4bc05fde66fe3d999569a3006349785f - + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // 0x65d7a28e3265b37a6474929f336521b332c1681b933f6cb9f3376673440d862a + modifier onlyAdmin() { require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), OnlyAdmin()); _; @@ -75,7 +80,7 @@ contract Credit is //-------------------------------- Overrides end --------------------------------// /// @custom:oz-upgrades-unsafe-allow state-variable-immutable - address immutable USDC; + address public immutable USDC; uint256[500] private __gap1; @@ -92,7 +97,8 @@ contract Credit is __AccessControlEnumerable_init_unchained(); __ERC20_init_unchained("Oyster Credit", "CREDIT"); __UUPSUpgradeable_init_unchained(); - + __Pausable_init_unchained(); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -100,10 +106,22 @@ contract Credit is //-------------------------------- Token Mint/Burn start --------------------------------/ + /** + * @notice Mint Credit tokens. + * @dev Caller must have `MINTER_ROLE`. + * @param _to Address to mint tokens to. Must have `TRANSFER_ALLOWED_ROLE`. + * @param _amount Amount of tokens to mint. + */ function mint(address _to, uint256 _amount) external whenNotPaused onlyRole(MINTER_ROLE) { _mint(_to, _amount); } + /** + * @notice Burn Credit tokens. + * @dev Caller must have `BURNER_ROLE`. + * @param _from Address to burn tokens from. Must have `TRANSFER_ALLOWED_ROLE` + * @param _amount Amount of tokens to burn. + */ function burn(address _from, uint256 _amount) external whenNotPaused onlyRole(BURNER_ROLE) { _burn(_from, _amount); } @@ -112,15 +130,43 @@ contract Credit is //-------------------------------- Oyster Market start --------------------------------// + /** + * @notice Burn Credit tokens and receive USDC. + * `_amount` of Credit tokens will be burned and `_amount` of USDC will be sent to `_to`. + * @dev Caller must have `REDEEMER_ROLE`. + * @dev Can revert if `Credit` contract does not have enough balance of USDC. + * @param _to Address to receive USDC. + * @param _amount Amount of tokens to redeem. + */ function redeemAndBurn(address _to, uint256 _amount) external whenNotPaused onlyRole(REDEEMER_ROLE) { + _burn(_msgSender(), _amount); IERC20(USDC).safeTransfer(_to, _amount); - _burn(_msgSender(), _amount); } //-------------------------------- Oyster Market end --------------------------------// + //-------------------------------- Pause/Unpause start --------------------------------// + + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + //-------------------------------- Pause/Unpause end --------------------------------// + //-------------------------------- Emergency Withdraw start --------------------------------// + /** + * @notice Emergency withdraw tokens from the contract. + * @dev Caller must have `DEFAULT_ADMIN_ROLE` + * and `_to` address must have `EMERGENCY_WITHDRAW_ROLE`. + * @param _token Address of the token to withdraw. + * @param _to Address to receive the tokens. Must have `EMERGENCY_WITHDRAW_ROLE`. + * @param _amount Amount of tokens to withdraw. + */ function emergencyWithdraw(address _token, address _to, uint256 _amount) external onlyAdmin { require(hasRole(EMERGENCY_WITHDRAW_ROLE, _to), OnlyToEmergencyWithdrawRole()); IERC20(_token).safeTransfer(_to, _amount); diff --git a/scripts/deploy/enclaves/UpgradeMarketV1.ts b/scripts/deploy/enclaves/UpgradeMarketV1.ts index 0a1361e8..187e1e88 100644 --- a/scripts/deploy/enclaves/UpgradeMarketV1.ts +++ b/scripts/deploy/enclaves/UpgradeMarketV1.ts @@ -36,10 +36,10 @@ async function deployAndUpgradeMarketV1() { const marketV1UpgradeTx = await marketV1Proxy.connect(admin).upgradeTo(newMarketV1Impl.address); await marketV1UpgradeTx.wait(); - // Reinitialize MarketV1 (noticePeriod, creditToken) - const marketV1 = MarketV1__factory.connect(marketV1Proxy.address, admin); - const reinitializeTx = await marketV1.connect(admin).reinitialize(FIVE_MINUTES, addresses.proxy.credit); - await reinitializeTx.wait(); + // // Reinitialize MarketV1 (noticePeriod, creditToken) + // const marketV1 = MarketV1__factory.connect(marketV1Proxy.address, admin); + // const reinitializeTx = await marketV1.connect(admin).reinitialize(FIVE_MINUTES, addresses.proxy.credit); + // await reinitializeTx.wait(); /*////////////////////////////////////////////////////////////// VERIFY CONTRACTS diff --git a/test/enclaves/MarketV1.ts b/test/enclaves/MarketV1.ts index d42380d1..951e4108 100644 --- a/test/enclaves/MarketV1.ts +++ b/test/enclaves/MarketV1.ts @@ -66,7 +66,8 @@ const calcNoticePeriodCost = (rate: BN) => { }; const calcAmountToPay = (rate: BN, duration: number) => { - return rate.mul(BN.from(duration)).add(10 ** 12 - 1).div(10 ** 12); + const DECIMALS = BN.from("1000000000000"); + return rate.mul(BN.from(duration)).add(DECIMALS.sub(1)).div(DECIMALS); } const incrementJobId = (jobId: string, increment: number) => { @@ -465,17 +466,22 @@ describe("MarketV1", function () { const initialBalance = usdc(50); const noticePeriodCost = calcNoticePeriodCost(JOB_RATE_1); - await marketv1 + const tx = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, initialBalance); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobOpenTs = block.timestamp; + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); expect(jobInfo.metadata).to.equal("some metadata"); expect(jobInfo.owner).to.equal(await user.getAddress()); expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(initialBalance.sub(noticePeriodCost)); - expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + FIVE_MINUTES, INITIAL_TIMESTAMP + FIVE_MINUTES + 1); + expect(jobInfo.lastSettled).to.equal(jobOpenTs); + expect(jobInfo.maxRate).to.equal(JOB_RATE_1); expect(await token.balanceOf(await user.getAddress())).to.equal(SIGNER1_INITIAL_FUND.sub(initialBalance)); expect(await token.balanceOf(marketv1.address)).to.equal(initialBalance.sub(noticePeriodCost)); @@ -518,17 +524,20 @@ describe("MarketV1", function () { const noticePeriodCost = calcNoticePeriodCost(JOB_RATE_1); await creditToken.connect(user).approve(marketv1.address, initialBalance); - await marketv1 + const tx = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, initialBalance); - + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobOpenTs = block.timestamp; + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); expect(jobInfo.metadata).to.equal("some metadata"); expect(jobInfo.owner).to.equal(await user.getAddress()); expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(initialBalance.sub(noticePeriodCost)); - expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + FIVE_MINUTES, INITIAL_TIMESTAMP + FIVE_MINUTES + 1); + expect(jobInfo.lastSettled).to.equal(jobOpenTs); expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal(initialBalance.sub(noticePeriodCost)); }); @@ -570,52 +579,52 @@ describe("MarketV1", function () { const initialDeposit = usdc(50); const initialBalance = initialDeposit.sub(calcNoticePeriodCost(JOB_RATE_1)); - beforeEach(async () => { - JOB_OPENED_TIMESTAMP = (await ethers.provider.getBlock('latest')).timestamp; - }); - takeSnapshotBeforeAndAfterEveryTest(async () => { }); describe("USDC Only", function () { beforeEach(async () => { await token.connect(user).approve(marketv1.address, initialDeposit); - await marketv1 + const tx = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, initialDeposit); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + JOB_OPENED_TIMESTAMP = block.timestamp; }); describe("CASE1: Settle Job immediately after Job Open", function () { - it("should revert before lastSettled", async () => { - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + it("should lose notice period cost", async () => { + const tx = await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobSettleTs = block.timestamp; + + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + expect(jobInfo.lastSettled).to.equal(jobSettleTs); }); }); - + describe("CASE2: Settle Job 2 minutes after Job Open", function () { - it("should revert before lastSettled", async () => { + it("Balance should decrease by 2 minutes + Notice period worth", async () => { const TWO_MINUTES = 60 * 2; - await time.increaseTo(INITIAL_TIMESTAMP + TWO_MINUTES); + await time.increaseTo(JOB_OPENED_TIMESTAMP + TWO_MINUTES); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); - }); - }); - - describe("CASE3: Settle Job 1 second before notice period", function () { - it("should revert before lastSettled", async () => { - const jobOpenedTimestamp = (await ethers.provider.getBlock('latest')).timestamp; - await time.increaseTo(jobOpenedTimestamp + NOTICE_PERIOD - 1); - - const lastSettled = (await marketv1.jobs(INITIAL_JOB_INDEX)).lastSettled; - expect(lastSettled).to.equal(jobOpenedTimestamp + NOTICE_PERIOD); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.be.equal(JOB_OPENED_TIMESTAMP + TWO_MINUTES); + expect(jobInfo.balance).to.equal( + initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber())), + ); }); }); describe("CASE4: Settle Job 2 minutes after notice period", function () { - it("should spend notice period cost and 2 minutes worth tokens", async () => { + it("should spend notice period cost and 2 minutes + notice period worth tokens", async () => { const TWO_MINUTES = 60 * 2; - await time.increaseTo(INITIAL_TIMESTAMP + NOTICE_PERIOD + TWO_MINUTES); - + await time.increaseTo(JOB_OPENED_TIMESTAMP + NOTICE_PERIOD + TWO_MINUTES); + // Job Settle await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); @@ -628,7 +637,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialBalance.sub(calcAmountToPay(JOB_RATE_1, TWO_MINUTES)); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = INITIAL_TIMESTAMP + NOTICE_PERIOD + TWO_MINUTES; + const lastSettledTimestampExpected = JOB_OPENED_TIMESTAMP + NOTICE_PERIOD + TWO_MINUTES; expect(jobInfo.lastSettled).to.equal(lastSettledTimestampExpected); // User balance @@ -650,14 +659,27 @@ describe("MarketV1", function () { beforeEach(async () => { // await token.connect(user).approve(marketv1.address, initialDeposit); await creditToken.connect(user).approve(marketv1.address, initialDeposit); - await marketv1 + const tx = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, initialDeposit); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + JOB_OPENED_TIMESTAMP = block.timestamp; }); describe("CASE1: Settle Job immediately after Job Open", function () { - it("should revert before lastSettled", async () => { - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + it("should deduct notice period worth from credit balance", async () => { + const tx = await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobSettleTs = block.timestamp; + + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.equal(jobSettleTs); + expect(jobInfo.balance).to.equal(initialBalance); + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + initialDeposit.sub(calcNoticePeriodCost(JOB_RATE_1)), + ); }); }); @@ -666,7 +688,17 @@ describe("MarketV1", function () { const TWO_MINUTES = 60 * 2; await time.increaseTo(INITIAL_TIMESTAMP + TWO_MINUTES); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + const tx = await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobSettleTs = block.timestamp; + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.be.equal(jobSettleTs); + expect(jobInfo.lastSettled).to.be.closeTo(INITIAL_TIMESTAMP + TWO_MINUTES, 1); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + initialDeposit.sub(calcNoticePeriodCost(JOB_RATE_1)).sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()) + )); }); }); @@ -674,7 +706,17 @@ describe("MarketV1", function () { it("should revert before lastSettled", async () => { await time.increaseTo(INITIAL_TIMESTAMP + NOTICE_PERIOD); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + const tx = await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const jobSettleTs = block.timestamp; + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.be.equal(jobSettleTs); + expect(jobInfo.lastSettled).to.be.closeTo(INITIAL_TIMESTAMP + NOTICE_PERIOD, 1); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + initialDeposit.sub(calcNoticePeriodCost(JOB_RATE_1)).sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()) + )); }); }); @@ -726,14 +768,23 @@ describe("MarketV1", function () { await token.connect(user).approve(marketv1.address, usdcDeposit); await creditToken.connect(user).approve(marketv1.address, creditDeposit); // deposit 10 credit and 40 usdc - await marketv1 + const tx = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, usdcDeposit.add(creditDeposit)); + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + JOB_OPENED_TIMESTAMP = block.timestamp; }); describe("CASE1: Settle Job immediately after Job Open", function () { it("should revert before lastSettled", async () => { - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.be.closeTo(INITIAL_TIMESTAMP, 2); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + creditDeposit.sub(calcNoticePeriodCost(JOB_RATE_1)).sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber())) + ); }); }); @@ -742,15 +793,30 @@ describe("MarketV1", function () { const TWO_MINUTES = 60 * 2; await time.increaseTo(INITIAL_TIMESTAMP + TWO_MINUTES); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + + expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + TWO_MINUTES, INITIAL_TIMESTAMP + TWO_MINUTES + 1); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + creditDeposit.sub(calcNoticePeriodCost(JOB_RATE_1).add(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))) + ); }); }); describe("CASE3: Settle Job exactly after notice period", function () { - it("should revert before lastSettled", async () => { + it("Balance should decrease by NOTICE PERIOD * 2 duration", async () => { await time.increaseTo(INITIAL_TIMESTAMP + NOTICE_PERIOD); - await expect(marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX)).to.be.revertedWith("cannot settle before lastSettled"); + await marketv1.connect(user).jobSettle(INITIAL_JOB_INDEX); + const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); + expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + NOTICE_PERIOD, INITIAL_TIMESTAMP + NOTICE_PERIOD + 1); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))); + + expect(await marketv1.jobCreditBalance(INITIAL_JOB_INDEX)).to.equal( + creditDeposit.sub(calcNoticePeriodCost(JOB_RATE_1).add(calcAmountToPay(JOB_RATE_1, jobInfo.lastSettled.sub(JOB_OPENED_TIMESTAMP).toNumber()))) + ); }); }); @@ -887,7 +953,7 @@ describe("MarketV1", function () { expect(jobInfo.provider).to.equal(addrs[2]); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(initialBalance.add(additionalDepositAmount)); - expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + FIVE_MINUTES - 3, INITIAL_TIMESTAMP + FIVE_MINUTES + 3); + expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP - 3, INITIAL_TIMESTAMP + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit).sub(additionalDepositAmount); expect(await token.balanceOf(addrs[1])).to.be.within(userBalanceExpected.sub(JOB_RATE_1), userBalanceExpected.add(JOB_RATE_1)); @@ -960,26 +1026,30 @@ describe("MarketV1", function () { const NOTICE_PERIOD_COST = calcNoticePeriodCost(JOB_RATE_1); it("should deposit to job with Credit", async () => { - await marketv1 + const tx_open = await marketv1 .connect(user) .jobOpen("some metadata", await provider.getAddress(), JOB_RATE_1, INITIAL_DEPOSIT_AMOUNT); - + const receipt_open = await tx_open.wait(); + const block_open = await ethers.provider.getBlock(receipt_open.blockHash); + const jobOpenTs = block_open.timestamp; + // Deposit 25 Credit await creditToken.connect(user).approve(marketv1.address, ADDITIONAL_DEPOSIT_AMOUNT); - await marketv1 + const tx = await marketv1 .connect(signers[1]) .jobDeposit(INITIAL_JOB_INDEX, ADDITIONAL_DEPOSIT_AMOUNT); - - const currentTimestamp = (await ethers.provider.getBlock('latest')).timestamp; - + const receipt = await tx.wait(); + const block = await ethers.provider.getBlock(receipt.blockHash); + const currentTimestamp = block.timestamp; // Job after deposit const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); expect(jobInfo.metadata).to.equal("some metadata"); expect(jobInfo.owner).to.equal(await user.getAddress()); expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); - expect(jobInfo.balance).to.equal(INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).add(ADDITIONAL_DEPOSIT_AMOUNT)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.balance).to.equal(INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST.add(calcAmountToPay(JOB_RATE_1, currentTimestamp - jobOpenTs))).add(ADDITIONAL_DEPOSIT_AMOUNT)); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); + expect(jobInfo.lastSettled).to.be.closeTo(jobOpenTs, 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(INITIAL_DEPOSIT_AMOUNT).sub(ADDITIONAL_DEPOSIT_AMOUNT); expect(await token.balanceOf(addrs[1])).to.be.within(userBalanceExpected.sub(JOB_RATE_1), userBalanceExpected.add(JOB_RATE_1)); @@ -1080,7 +1150,7 @@ describe("MarketV1", function () { expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(TOTAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); expect(creditBalance).to.equal(ADDITIONAL_CREDIT_DEPOSIT_AMOUNT); // User Balance @@ -1170,7 +1240,7 @@ describe("MarketV1", function () { expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(withdrawAmount)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); expect(await token.balanceOf(await user.getAddress())).to.equal(SIGNER1_INITIAL_FUND.sub(INITIAL_DEPOSIT_AMOUNT).add(withdrawAmount)); expect(await token.balanceOf(await provider.getAddress())).to.equal(NOTICE_PERIOD_COST); @@ -1199,7 +1269,7 @@ describe("MarketV1", function () { expect(jobInfo.rate).to.equal(JOB_RATE_1); const jobBalanceExpected = INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(SETTLED_AMOUNT).sub(TOTAL_WITHDRAW_AMOUNT); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + FIVE_MINUTES + TWO_MINUTES - 3, INITIAL_TIMESTAMP + FIVE_MINUTES + TWO_MINUTES + 3); + expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + TWO_MINUTES - 3, INITIAL_TIMESTAMP + TWO_MINUTES + 3); // Check User USDC balance const userUSDCBalanceExpected = SIGNER1_INITIAL_FUND.sub(INITIAL_DEPOSIT_AMOUNT).add(TOTAL_WITHDRAW_AMOUNT); @@ -1295,7 +1365,7 @@ describe("MarketV1", function () { expect(jobInfo.provider).to.equal(await provider.getAddress()); expect(jobInfo.rate).to.equal(JOB_RATE_1); expect(jobInfo.balance).to.equal(INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(CREDIT_WITHDRAW_AMOUNT)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); expect(jobCreditBalance).to.equal(INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(CREDIT_WITHDRAW_AMOUNT)); @@ -1332,7 +1402,7 @@ describe("MarketV1", function () { expect(jobInfo.rate).to.equal(JOB_RATE_1); const jobBalanceExpected = INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(SETTLED_AMOUNT).sub(TOTAL_WITHDRAW_AMOUNT); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + FIVE_MINUTES + TWO_MINUTES - 3, INITIAL_TIMESTAMP + FIVE_MINUTES + TWO_MINUTES + 3); + expect(jobInfo.lastSettled).to.be.within(INITIAL_TIMESTAMP + TWO_MINUTES - 3, INITIAL_TIMESTAMP + TWO_MINUTES + 3); const jobCreditBalanceExpected = INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(SETTLED_AMOUNT).sub(TOTAL_WITHDRAW_AMOUNT); expect(jobCreditBalance).to.be.within(jobCreditBalanceExpected.sub(JOB_RATE_1), jobCreditBalanceExpected.add(JOB_RATE_1)); @@ -1420,7 +1490,7 @@ describe("MarketV1", function () { expect(jobInfo.rate).to.equal(JOB_RATE_1); const jobBalanceExpected = INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(TOTAL_WITHDRAW_AMOUNT); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); const jobCreditBalanceExpected = INITIAL_CREDIT_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST); expect(jobCreditBalance).to.equal(jobCreditBalanceExpected); @@ -1464,7 +1534,7 @@ describe("MarketV1", function () { expect(jobInfo.rate).to.equal(JOB_RATE_1); const jobBalanceExpected = INITIAL_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(TOTAL_WITHDRAW_AMOUNT); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + NOTICE_PERIOD); + expect(jobInfo.lastSettled).to.equal(currentTimestamp); expect(jobCreditBalance).to.equal(INITIAL_CREDIT_DEPOSIT_AMOUNT.sub(NOTICE_PERIOD_COST).sub(withdrawnCreditAmountExpected)); // User Balance @@ -1512,12 +1582,12 @@ describe("MarketV1", function () { const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); expect(jobInfo.rate).to.equal(JOB_HIGHER_RATE); - expect(jobInfo.balance).to.equal(initialBalance); + expect(jobInfo.balance).to.equal(initialBalance.sub(calcNoticePeriodCost(JOB_HIGHER_RATE.sub(JOB_RATE_1)))); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + FIVE_MINUTES); + expect(jobInfo.lastSettled).to.equal(currentTimestamp ); expect(await token.balanceOf(await user.getAddress())).to.equal(SIGNER1_INITIAL_FUND.sub(initialDeposit)); - expect(await token.balanceOf(await provider.getAddress())).to.equal(calcNoticePeriodCost(JOB_RATE_1)); - expect(await token.balanceOf(marketv1.address)).to.equal(initialBalance); + expect(await token.balanceOf(await provider.getAddress())).to.equal(calcNoticePeriodCost(JOB_HIGHER_RATE)); + expect(await token.balanceOf(marketv1.address)).to.equal(initialBalance.sub(calcNoticePeriodCost(JOB_HIGHER_RATE.sub(JOB_RATE_1)))); }); it("should revise rate lower", async () => { @@ -1530,7 +1600,7 @@ describe("MarketV1", function () { const jobInfo = await marketv1.jobs(INITIAL_JOB_INDEX); expect(jobInfo.rate).to.equal(JOB_LOWER_RATE); expect(jobInfo.balance).to.equal(initialBalance); - expect(jobInfo.lastSettled).to.equal(currentTimestamp + FIVE_MINUTES); + expect(jobInfo.lastSettled).to.equal(currentTimestamp ); expect(await token.balanceOf(await user.getAddress())).to.equal(SIGNER1_INITIAL_FUND.sub(initialDeposit)); expect(await token.balanceOf(await provider.getAddress())).to.equal(calcNoticePeriodCost(JOB_RATE_1)); expect(await token.balanceOf(marketv1.address)).to.equal(initialBalance); @@ -1627,7 +1697,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(usdcSpentExpected); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit).sub(usdcSpentExpected); @@ -1659,7 +1729,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(usdcSpentExpected); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit).sub(usdcSpentExpected); @@ -1695,7 +1765,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(firstNoticePeriodCost).sub(secondNoticePeriodCost); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit); @@ -1727,7 +1797,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(firstNoticePeriodCost).sub(secondNoticePeriodCost); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit); @@ -1764,7 +1834,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(firstNoticePeriodCost).sub(secondNoticePeriodCost); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit); @@ -1796,7 +1866,7 @@ describe("MarketV1", function () { const jobBalanceExpected = initialDeposit.sub(firstNoticePeriodCost).sub(secondNoticePeriodCost); expect(jobInfo.balance).to.be.within(jobBalanceExpected.sub(JOB_RATE_1), jobBalanceExpected.add(JOB_RATE_1)); - const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp + FIVE_MINUTES; + const lastSettledTimestampExpected = (await ethers.provider.getBlock('latest')).timestamp ; expect(jobInfo.lastSettled).to.be.within(lastSettledTimestampExpected - 3, lastSettledTimestampExpected + 3); const userBalanceExpected = SIGNER1_INITIAL_FUND.sub(initialDeposit); @@ -2063,7 +2133,7 @@ describe("MarketV1", function () { it("should settle all jobs and withdraw all credit", async () => { const totalSettledAmountExpected = calcNoticePeriodCost(JOB_RATE_1).mul(NUM_TOTAL_JOB); - await marketv1.connect(admin).emergencyWithdrawCredit(await admin2.getAddress(), jobs); + await marketv1.connect(admin).emergencyWithdrawCredit(await admin2.getAddress(), jobs, { gasLimit: 10000000 }); const CURRENT_TIMESTAMP = (await ethers.provider.getBlock('latest')).timestamp; for (let i = 0; i < NUM_TOTAL_JOB; i++) { @@ -2071,7 +2141,7 @@ describe("MarketV1", function () { const jobInfo = await marketv1.jobs(jobs[i]); // should settle all jobs - expect(jobInfo.lastSettled).to.equal((CURRENT_TIMESTAMP + NOTICE_PERIOD).toString()); + expect(jobInfo.lastSettled).to.equal((CURRENT_TIMESTAMP).toString()); // job credit balance should be 0 expect(await marketv1.jobCreditBalance(jobs[i])).to.equal(0); diff --git a/test/token/Credit.ts b/test/token/Credit.ts new file mode 100644 index 00000000..221dcdc8 --- /dev/null +++ b/test/token/Credit.ts @@ -0,0 +1,393 @@ +import { expect } from "chai"; +import { Signer } from "ethers"; +import { + ethers, + upgrades, +} from "hardhat"; + +import { + Credit, + Credit__factory, + Pond, + Pond__factory, +} from "../../typechain-types"; + +const creditAmount = (amount: number) => { + return ethers.utils.parseUnits(amount.toString(), "6"); +} + +describe("Credit", function () { + let signers: Signer[]; + let addrs: string[]; + let credit: Credit; + let usdc: Pond; + + let admin: Signer; + let user: Signer; + let user2: Signer; + + beforeEach(async function () { + // Signers + signers = await ethers.getSigners(); + admin = signers[0]; + user = signers[1]; + user2 = signers[2]; + + // Deploy Pond + const USDC = await ethers.getContractFactory("Pond"); + const usdcProxy = await upgrades.deployProxy(USDC, [], { + kind: "uups", + unsafeAllow: ["missing-initializer-call"], + initializer: false, + }); + usdc = Pond__factory.connect(usdcProxy.address, admin); + await usdc.initialize("USDC", "USDC"); + + // Deploy Credit + const Credit = await ethers.getContractFactory("Credit"); + const creditTokenContract = await upgrades.deployProxy(Credit, { + kind: "uups", + constructorArgs: [usdc.address], + initializer: false, + }); + credit = Credit__factory.connect(creditTokenContract.address, admin); + await credit.initialize(await admin.getAddress()); + }); + + describe("Access Control", function () { + it("should revert when 0 admins", async function () { + await expect(credit.connect(admin).revokeRole(await credit.DEFAULT_ADMIN_ROLE(), await admin.getAddress())).to.be.revertedWithCustomError(credit, "NoAdminExists"); + }); + }); + + describe("Getters", function () { + it("should get USDC", async function () { + expect(await credit.USDC()).to.equal(usdc.address); + }); + + it("should get decimals", async function () { + expect(await credit.decimals()).to.equal(6); + }); + + it("should get role", async function () { + expect(await credit.hasRole(await credit.DEFAULT_ADMIN_ROLE(), await admin.getAddress())).to.be.true; + }); + }); + + describe("Initialize", function () { + it("should deploy with initialization disabled", async function () { + await expect(credit.initialize(await admin.getAddress())).to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should deploy as proxy and initialize", async function () { + const Credit = await ethers.getContractFactory("Credit"); + const creditTokenContract = await upgrades.deployProxy(Credit, { + kind: "uups", + constructorArgs: [usdc.address], + initializer: false, + }); + const credit = Credit__factory.connect(creditTokenContract.address, admin); + await credit.initialize(await admin.getAddress()); + + expect(await credit.USDC()).to.equal(usdc.address); + expect(await credit.hasRole(await credit.DEFAULT_ADMIN_ROLE(), await admin.getAddress())).to.be.true; + }); + }); + + describe("Mint/Burn", function () { + describe("Mint", function () { + it("should mint", async function () { + await credit.connect(admin).grantRole(await credit.MINTER_ROLE(), await admin.getAddress()); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await admin.getAddress()); + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + expect(await credit.balanceOf(await admin.getAddress())).to.equal(creditAmount(1000)); + }); + + it("should revert when not minter", async function () { + await expect(credit.connect(user).mint(await user.getAddress(), creditAmount(1000))).to.be.reverted; + }); + + it("should revert when not transfer allowed", async function () { + await credit.connect(admin).grantRole(await credit.MINTER_ROLE(), await admin.getAddress()); + await expect(credit.connect(admin).mint(await user.getAddress(), creditAmount(1000))).to.be.reverted; + }); + }); + + describe("Burn", function () { + beforeEach(async function () { + // Grant `MINTER_ROLE` and `TRANSFER_ALLOWED_ROLE` to admin + await credit.connect(admin).grantRole(await credit.MINTER_ROLE(), await admin.getAddress()); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await admin.getAddress()); + + // Mint 1000 Credit to admin + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + }); + + it("should burn", async function () { + // Transfer 1000 Credit to user + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `BURNER_ROLE` to admin + await credit.connect(admin).grantRole(await credit.BURNER_ROLE(), await admin.getAddress()); + + // Burn 1000 Credit from user + await credit.connect(admin).burn(await user.getAddress(), creditAmount(1000)); + + // Check that the balance of the admin is 0 + expect(await credit.balanceOf(await user.getAddress())).to.equal(0); + }); + + it("should revert without BURNER_ROLE", async function () { + // Transfer 1000 Credit to user + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Burn 1000 Credit from user + const revertString = new RegExp(`AccessControl: account ${await admin.getAddress()} is missing role ${await credit.BURNER_ROLE()}`, 'i'); + await expect(credit.connect(admin).burn(await admin.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("should revert when token holder does not have TRANSFER_ALLOWED_ROLE", async function () { + // Transfer 1000 Credit to user + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + // Note: only either of sender or recipient needs to have TRANSFER_ALLOWED_ROLE + // so in this case, admin has TRANSFER_ALLOWED_ROLE so no need for user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `BURNER_ROLE` to admin + await credit.connect(admin).grantRole(await credit.BURNER_ROLE(), await admin.getAddress()); + + // Burn `1000` Credit from user + await expect(credit.connect(admin).burn(await user.getAddress(), creditAmount(1000))).to.be.revertedWithCustomError(credit, "OnlyTransferAllowedRole"); + }); + + it("should revert when token holder has insufficient balance", async function () { + // Transfer 1000 Credit to user + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `BURNER_ROLE` to admin + await credit.connect(admin).grantRole(await credit.BURNER_ROLE(), await admin.getAddress()); + + // Revoke `TRANSFER_ALLOWED_ROLE` from user + await credit.connect(admin).revokeRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + const revertString = "ERC20: burn amount exceeds balance"; + await expect(credit.connect(admin).burn(await admin.getAddress(), creditAmount(1001))).to.be.revertedWith(revertString); + }); + }); + }); + + describe("Redeem And Burn", function () { + beforeEach(async function () { + // Grant `MINTER_ROLE` and `TRANSFER_ALLOWED_ROLE` to admin + await credit.connect(admin).grantRole(await credit.MINTER_ROLE(), await admin.getAddress()); + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await admin.getAddress()); + + // Mint 1000 Credit to admin + await credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000)); + + // Transfer 5000 USDC to admin + const usdcAmount = ethers.utils.parseUnits("5000", "6"); + await usdc.connect(admin).transfer(await admin.getAddress(), usdcAmount); + }); + + it("should redeem and burn", async function () { + // Transfer 5000 USDC to Credit contract + await usdc.connect(admin).transfer(credit.address, ethers.utils.parseUnits("5000", "6")); + + // Transfer 1000 Credit to user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `TRANSFER_ALLOWED_ROLE` to user + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + // Grant `REDEEMER_ROLE` to user + await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await user.getAddress()); + + // Redeem and burn 1000 Credit from user + await credit.connect(user).redeemAndBurn(await user.getAddress(), creditAmount(1000)); + + // Check that Credit balance of the user is 0 + expect(await credit.balanceOf(await user.getAddress())).to.equal(0); + // Check that USDC balance of the Credit contract is 1000 + expect(await usdc.balanceOf(await user.getAddress())).to.equal(creditAmount(1000)); + // Check that USDC balance of Credit has decreased by 1000 (from 5000 to 4000) + expect(await usdc.balanceOf(credit.address)).to.equal(ethers.utils.parseUnits("4000", "6")); + }); + + it("should revert when redeemer does not have `REDEEMER_ROLE`", async function () { + // Transfer 5000 USDC to Credit contract + await usdc.connect(admin).transfer(credit.address, ethers.utils.parseUnits("5000", "6")); + + // Transfer 1000 Credit to user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `TRANSFER_ALLOWED_ROLE` to user + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + //! Grant `REDEEMER_ROLE` to user + // await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await user.getAddress()); + + // Redeem and burn 1000 Credit from user + const revertString = new RegExp(`AccessControl: account ${await user.getAddress()} is missing role ${await credit.REDEEMER_ROLE()}`, 'i'); + await expect(credit.connect(user).redeemAndBurn(await user.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("should revert when token holder does not have `TRANSFER_ALLOWED_ROLE`", async function () { + // Transfer 5000 USDC to Credit contract + await usdc.connect(admin).transfer(credit.address, ethers.utils.parseUnits("5000", "6")); + + // Transfer 1000 Credit to user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + //! Grant `TRANSFER_ALLOWED_ROLE` to user + // await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + // Grant `REDEEMER_ROLE` to user + await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await user.getAddress()); + + // Redeem and burn 1000 Credit from user + await expect(credit.connect(user).redeemAndBurn(await user.getAddress(), creditAmount(1000))).to.be.revertedWithCustomError(credit, "OnlyTransferAllowedRole"); + }); + + it("should revert when token holder does not have enough Credit balance", async function () { + // Transfer 5000 USDC to Credit contract + await usdc.connect(admin).transfer(credit.address, ethers.utils.parseUnits("5000", "6")); + + //! Transfer 100 Credit to user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(100)); + + // Grant `TRANSFER_ALLOWED_ROLE` to user + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + // Grant `REDEEMER_ROLE` to user + await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await user.getAddress()); + + // Redeem and burn 1000 Credit from user + const revertString = "ERC20: burn amount exceeds balance"; + await expect(credit.connect(user).redeemAndBurn(await user.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("should revert when Credit contract does not have enough USDC balance", async function () { + //! Transfer 500 USDC to Credit contract + await usdc.connect(admin).transfer(credit.address, ethers.utils.parseUnits("500", "6")); + + // Transfer 1000 Credit to user + await credit.connect(admin).transfer(await user.getAddress(), creditAmount(1000)); + + // Grant `TRANSFER_ALLOWED_ROLE` to user + await credit.connect(admin).grantRole(await credit.TRANSFER_ALLOWED_ROLE(), await user.getAddress()); + + // Grant `REDEEMER_ROLE` to user + await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await user.getAddress()); + + // Redeem and burn 1000 Credit from user + const revertString = "ERC20: transfer amount exceeds balance"; + await expect(credit.connect(user).redeemAndBurn(await user.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + }); + + describe("Pause/Unpause", function () { + beforeEach(async function () { + await credit.connect(admin).grantRole(await credit.PAUSER_ROLE(), await admin.getAddress()); + await credit.connect(admin).pause(); + }); + + it("should revert when calling mint", async function () { + await credit.connect(admin).grantRole(await credit.MINTER_ROLE(), await admin.getAddress()); + + const revertString = "Pausable: paused"; + await expect(credit.connect(admin).mint(await admin.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("should revert when calling burn", async function () { + await credit.connect(admin).grantRole(await credit.BURNER_ROLE(), await admin.getAddress()); + + const revertString = "Pausable: paused"; + await expect(credit.connect(admin).burn(await admin.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("should revert when calling redeemAndBurn", async function () { + await credit.connect(admin).grantRole(await credit.REDEEMER_ROLE(), await admin.getAddress()); + + const revertString = "Pausable: paused"; + await expect(credit.connect(admin).redeemAndBurn(await admin.getAddress(), creditAmount(1000))).to.be.revertedWith(revertString); + }); + + it("emergency withdraw should be possible", async function () { + const ADMIN_USDC_BALANCE_BEFORE = await usdc.balanceOf(await admin.getAddress()); + await credit.connect(admin).grantRole(await credit.EMERGENCY_WITHDRAW_ROLE(), await admin.getAddress()); + + // Transfer 5000 USDC to Credit contract + const USDC_AMOUNT = ethers.utils.parseUnits("5000", "6"); + await usdc.connect(admin).transfer(credit.address, USDC_AMOUNT); + + expect(await usdc.balanceOf(credit.address)).to.equal(USDC_AMOUNT); + expect(await usdc.balanceOf(await admin.getAddress())).to.equal(ADMIN_USDC_BALANCE_BEFORE.sub(USDC_AMOUNT)); + + // Emergency withdraw + await credit.connect(admin).emergencyWithdraw(usdc.address, await admin.getAddress(), USDC_AMOUNT); + + // Check that USDC balance of the admin is restored + expect(await usdc.balanceOf(await admin.getAddress())).to.equal(ADMIN_USDC_BALANCE_BEFORE); + // Check that USDC balance of the Credit contract is 0 + expect(await usdc.balanceOf(credit.address)).to.equal(0); + }); + }); + + describe("Emergency Withdraw", function () { + it("should revert when not admin", async function () { + await expect(credit.connect(user).emergencyWithdraw(usdc.address, await user.getAddress(), ethers.utils.parseUnits("5000", "6"))).to.be.revertedWithCustomError(credit, "OnlyAdmin"); + }); + }); + + describe("Emergency Withdraw", function () { + it("should withdraw USDC", async function () { + const ADMIN_USDC_BALANCE_BEFORE = await usdc.balanceOf(await admin.getAddress()); + + // Grant `EMERGENCY_WITHDRAW_ROLE` to admin + await credit.connect(admin).grantRole(await credit.EMERGENCY_WITHDRAW_ROLE(), await admin.getAddress()); + + // Transfer 5000 USDC to Credit contract + const USDC_AMOUNT = ethers.utils.parseUnits("5000", "6"); + await usdc.connect(admin).transfer(credit.address, USDC_AMOUNT); + + // Emergency withdraw + await credit.connect(admin).emergencyWithdraw(usdc.address, await admin.getAddress(), USDC_AMOUNT); + + // Check that USDC balance of the admin is restored + expect(await usdc.balanceOf(await admin.getAddress())).to.equal(ADMIN_USDC_BALANCE_BEFORE); + // Check that USDC balance of the Credit contract is 0 + expect(await usdc.balanceOf(credit.address)).to.equal(0); + }); + + it("should revert when not admin", async function () { + // Grant `EMERGENCY_WITHDRAW_ROLE` to admin + await credit.connect(admin).grantRole(await credit.EMERGENCY_WITHDRAW_ROLE(), await admin.getAddress()); + + // Transfer 5000 USDC to Credit contract + const USDC_AMOUNT = ethers.utils.parseUnits("5000", "6"); + await usdc.connect(admin).transfer(credit.address, USDC_AMOUNT); + + //! user calls Emergency withdraw + await expect(credit.connect(user).emergencyWithdraw(usdc.address, await admin.getAddress(), USDC_AMOUNT)).to.be.revertedWithCustomError(credit, "OnlyAdmin"); + }); + + it("should revert when recipient does not have `EMERGENCY_WITHDRAW_ROLE`", async function () { + // Grant `EMERGENCY_WITHDRAW_ROLE` to admin + await credit.connect(admin).grantRole(await credit.EMERGENCY_WITHDRAW_ROLE(), await admin.getAddress()); + + // Transfer 5000 USDC to Credit contract + const USDC_AMOUNT = ethers.utils.parseUnits("5000", "6"); + await usdc.connect(admin).transfer(credit.address, USDC_AMOUNT); + + // Emergency withdraw to user + await expect(credit.connect(admin).emergencyWithdraw(usdc.address, await user.getAddress(), USDC_AMOUNT)).to.be.revertedWithCustomError(credit, "OnlyToEmergencyWithdrawRole"); + }); + }); +});