From 2478387b36ae0fa58a254fcf5dc1d184ef3db4f4 Mon Sep 17 00:00:00 2001 From: maxcoto Date: Thu, 20 Feb 2025 13:59:50 -0300 Subject: [PATCH 1/5] adds amount based closing config and functionality --- src/DealNFT.sol | 92 +++++++++++++++++++++++++++------------ src/Reader.sol | 1 + src/interfaces/IDeal.sol | 1 + test/DealNFTClaim.t.sol | 5 ++- test/DealNFTRecover.t.sol | 8 +++- test/DealNFTUnstake.t.sol | 8 +++- test/DealSetup.sol | 5 ++- 7 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/DealNFT.sol b/src/DealNFT.sol index 895a214..d46c3dd 100644 --- a/src/DealNFT.sol +++ b/src/DealNFT.sol @@ -43,8 +43,8 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { error OwnerMismatch(); // Events - event Init(string social, string website, uint256 multiple, uint256 closingDelay, uint256 unstakingFee, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 deliveryType, bool active, bool transferable); - event Setup(address escrowToken, uint256 closingDelay, uint256 unstakingFee, string website, string social, string image, string description, uint256 deliveryType); + event Init(string social, string website, uint256 multiple, uint256 closingDelay, uint256 unstakingFee, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 deliveryType, bool active, bool transferable, bool timeBasedClosing); + event Setup(address escrowToken, uint256 closingDelay, uint256 unstakingFee, uint256 dealMinimum, uint256 dealMaximum, string website, string social, string image, string description, uint256 deliveryType); event Configure(string description, string social, string website, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 multiple); event StateUpdated(State state); event Transferable(bool transferable); @@ -81,6 +81,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { IWhitelist public stakersWhitelist; IWhitelist public claimsWhitelist; + uint256 lastStakeTimestamp; mapping(uint256 tokenId => uint256) public stakedAmount; mapping(uint256 tokenId => uint256) public claimedAmount; mapping(address staker => uint256) public stakes; @@ -110,6 +111,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { bool active; bool cancelled; bool transferable; + bool timeBasedClosing; } Configuration private config; @@ -142,7 +144,9 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { if(bytes(symbol_).length == 0) revert ZeroDetected(); if(config_.sponsor == ADDRESS_ZERO) revert ZeroDetected(); - _validClosingTime(config_.closingTime, config_.closingDelay); + if(config_.timeBasedClosing) { + _validClosingTime(config_.closingTime, config_.closingDelay); + } if(config_.dealMinimum > config_.dealMaximum) revert BadStakesRange(); if(config_.multiple < 1e18) revert ZeroDetected(); @@ -168,7 +172,8 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { config_.dealMaximum, config_.deliveryType, config_.active, - config_.transferable + config_.transferable, + config_.timeBasedClosing ); } @@ -207,6 +212,8 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { address escrowToken_, uint256 closingDelay_, uint256 unstakingFee_, + uint256 dealMinimum_, + uint256 dealMaximum_, string memory social_, string memory website_, string memory image_, @@ -214,17 +221,20 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { uint256 deliveryType_ ) external onlySponsor { if(state() != State.Setup) revert CannotSetup(); + if(dealMinimum_ > dealMaximum_) revert BadStakesRange(); config.escrowToken = escrowToken_; config.closingDelay = closingDelay_; config.unstakingFee = unstakingFee_; + config.dealMinimum = dealMinimum_; + config.dealMaximum = dealMaximum_; config.social = social_; config.website = website_; config.image = image_; config.description = description_; config.deliveryType = deliveryType_; - emit Setup(escrowToken_, closingDelay_, unstakingFee_, website_, social_, image_, description_, deliveryType_); + emit Setup(escrowToken_, closingDelay_, unstakingFee_, dealMaximum_, dealMinimum_, website_, social_, image_, description_, deliveryType_); } /** @@ -255,17 +265,22 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { uint256 dealMaximum_, uint256 multiple_ ) external onlySponsor { - _canConfigure(); - _validClosingTime(closingTime_, config.closingDelay); - if(dealMinimum_ > dealMaximum_) revert BadStakesRange(); + _canConfigure(); if(multiple_ < 1e18) revert ZeroDetected(); + if(config.timeBasedClosing) { + _validClosingTime(closingTime_, config.closingDelay); + if(dealMinimum_ > dealMaximum_) revert BadStakesRange(); + + config.dealMinimum = dealMinimum_; + config.dealMaximum = dealMaximum_; + config.closingTime = closingTime_; + } + config.description = description_; config.social = social_; config.website = website_; - config.closingTime = closingTime_; - config.dealMinimum = dealMinimum_; - config.dealMaximum = dealMaximum_; + config.multiple = multiple_; emit Configure(description_, social_, website_, closingTime_, dealMinimum_, dealMaximum_, multiple_); @@ -301,9 +316,6 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { * @param transferable_ Boolean indicating if NFTs are transferable */ function setTransferable(bool transferable_) external onlyArbitrator { - if(state() == State.Cancelled) revert CannotConfigure(); - if(_afterClosed()) revert CannotConfigure(); - config.transferable = transferable_; emit Transferable(transferable_); } @@ -374,7 +386,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { if(state() < State.Claiming) revert CannotRecover(); if(state() == State.Claiming) { - if(_totalStaked(_tokenId) >= config.dealMinimum) revert MinimumReached(); + if(_minimumReached()) revert MinimumReached(); } AccountV3TBD tokenBoundAccount = getTokenBoundAccount(tokenId); @@ -449,17 +461,31 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { */ function state() public view returns (State) { if(config.cancelled) return State.Cancelled; + if(_isClaimed()) return State.Closed; - if(_beforeClose()) { - if(config.active) return State.Active; - return State.Setup; - } + if(config.timeBasedClosing){ + if(_afterClosed(config.closingTime)) { + return State.Cancelled; + } + + if(_beforeClose()) { + if(config.active) return State.Active; + return State.Setup; + } - if(_afterClosed()) return State.Closed; + return State.Claiming; + } else { + if(_minimumReached()) { + if(_afterClosed(lastStakeTimestamp)){ + return State.Cancelled; + } - if(_isClaimed()) return State.Closed; + return State.Claiming; + } - return State.Claiming; + if(config.active) return State.Active; + return State.Setup; + } } /** @@ -557,6 +583,13 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { return config; } + /** + * @notice Check if the minimum has been reached + */ + function _minimumReached() private view returns (bool) { + return _totalStaked(_tokenId) >= config.dealMinimum; + } + /** * @notice Check if all tokens have been claimed by the sponsor */ @@ -574,8 +607,8 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { /** * @notice Check if the current time is after closing time */ - function _afterClosed() private view returns (bool) { - return config.closingTime > 0 && block.timestamp > (config.closingTime + CLAIMING_PERIOD); + function _afterClosed(uint256 since) private view returns (bool) { + return since > 0 && block.timestamp > (since + CLAIMING_PERIOD); } /** @@ -625,7 +658,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { function _canClaim() internal view { if(_claimId == _tokenId) revert TokenOutOfBounds(); if(state() != State.Claiming) revert NotInClaimingState(); - if(_totalStaked(_tokenId) < config.dealMinimum) revert MinimumNotReached(); + if(!_minimumReached()) revert MinimumNotReached(); } /** @@ -634,14 +667,16 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { function _canConfigure() internal view { if(state() >= State.Closed) revert CannotConfigure(); if(state() == State.Claiming) { - if(_totalStaked(_tokenId) >= config.dealMinimum) revert MinimumReached(); + if(_minimumReached()) revert MinimumReached(); } } function _validateActivation() internal view { if(config.escrowToken == ADDRESS_ZERO) revert ZeroDetected(); - if(config.closingDelay <= 0) revert ZeroDetected(); - if(config.closingDelay > MAX_CLOSING_RANGE) revert ClosingDelayTooBig(); + if(config.timeBasedClosing) { + if(config.closingDelay <= 0) revert ZeroDetected(); + if(config.closingDelay > MAX_CLOSING_RANGE) revert ClosingDelayTooBig(); + } if(config.unstakingFee > MAX_FEE) revert ClosingFeeTooBig(); if(bytes(config.website).length == 0) revert ZeroDetected(); if(bytes(config.social).length == 0) revert ZeroDetected(); @@ -670,6 +705,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { uint256 newTokenId = _tokenId++; stakedAmount[newTokenId] = amount; stakes[staker] = currentStake; + lastStakeTimestamp = block.timestamp; _safeMint(staker, newTokenId); diff --git a/src/Reader.sol b/src/Reader.sol index daf0fea..37296ed 100644 --- a/src/Reader.sol +++ b/src/Reader.sol @@ -44,6 +44,7 @@ contract Reader { dealMaximum: config.dealMaximum, deliveryType: config.deliveryType, transferable: config.transferable, + timeBasedClosing: config.timeBasedClosing, stakersWhitelist: dealInstance.stakersWhitelist(), claimsWhitelist: dealInstance.claimsWhitelist(), deliveryToken: address(dealInstance.deliveryToken()), diff --git a/src/interfaces/IDeal.sol b/src/interfaces/IDeal.sol index c2996c1..b5bbdb6 100644 --- a/src/interfaces/IDeal.sol +++ b/src/interfaces/IDeal.sol @@ -44,6 +44,7 @@ interface IDeal { uint8 escrowDecimals; StakeData[] claimed; bool transferable; + bool timeBasedClosing; uint256 deliveryType; } diff --git a/test/DealNFTClaim.t.sol b/test/DealNFTClaim.t.sol index 4866554..44d9d98 100644 --- a/test/DealNFTClaim.t.sol +++ b/test/DealNFTClaim.t.sol @@ -81,7 +81,8 @@ contract DealNFTClaimTest is Test { deliveryType: 0, active: false, cancelled: false, - transferable: false + transferable: false, + timeBasedClosing: true }); deal = new DealNFT( @@ -113,7 +114,7 @@ contract DealNFTClaimTest is Test { escrowToken.approve(address(deal), amount); vm.startPrank(sponsor); - deal.setup(address(escrowToken), 30 minutes, 50000, "https://social", "https://website", "https://image", "desc", 0); + deal.setup(address(escrowToken), 30 minutes, 50000, 0, 0, "https://social", "https://website", "https://image", "desc", 0); deal.configure("desc", "https://social", "https://website", block.timestamp + 2 weeks, 0, 2000000, 1e18); deal.activate(); vm.stopPrank(); diff --git a/test/DealNFTRecover.t.sol b/test/DealNFTRecover.t.sol index fc43609..eaf2f35 100644 --- a/test/DealNFTRecover.t.sol +++ b/test/DealNFTRecover.t.sol @@ -61,11 +61,11 @@ contract DealNFTRecoverTest is Test, DealSetup { assertEq(escrowToken.balanceOf(staker1), amount); } - function test_RecoverAfterClosed() public { + function test_RecoverAfterExpired() public { _stake(staker1); skip(22 days); - assertEq(uint(deal.state()), uint256(DealNFT.State.Closed)); + assertEq(uint(deal.state()), uint256(DealNFT.State.Cancelled)); assertEq(escrowToken.balanceOf(address(deal.getTokenBoundAccount(0))), amount); assertEq(escrowToken.balanceOf(staker1), 0); @@ -76,4 +76,8 @@ contract DealNFTRecoverTest is Test, DealSetup { assertEq(escrowToken.balanceOf(address(deal.getTokenBoundAccount(0))), 0); assertEq(escrowToken.balanceOf(staker1), amount); } + + function test_RecoverAfterClosed() public { + // TODO + } } diff --git a/test/DealNFTUnstake.t.sol b/test/DealNFTUnstake.t.sol index 9dd13aa..bd001ce 100644 --- a/test/DealNFTUnstake.t.sol +++ b/test/DealNFTUnstake.t.sol @@ -68,7 +68,7 @@ contract DealNFTUnstakeTest is Test, DealSetup { deal.unstake(0); } - function test_RevertWhen_UnstakeAfterClosed() public { + function test_RevertWhen_UnstakeAfterExpired() public { _stake(staker1); _stake(staker2); assertEq(deal.totalStaked(), amount * 2); @@ -78,11 +78,15 @@ contract DealNFTUnstakeTest is Test, DealSetup { skip(22 days); assertEq(deal.totalStaked(), amount); - assertEq(uint(deal.state()), uint256(DealNFT.State.Closed)); + assertEq(uint(deal.state()), uint256(DealNFT.State.Cancelled)); vm.expectRevert(DealNFT.CannotUnstake.selector); vm.prank(staker2); deal.unstake(1); } + + function test_RevertWhen_UnstakeAfterClosed() public { + // TODO + } } diff --git a/test/DealSetup.sol b/test/DealSetup.sol index c36ce19..b387230 100644 --- a/test/DealSetup.sol +++ b/test/DealSetup.sol @@ -67,7 +67,8 @@ contract DealSetup is Test { deliveryType: 0, active: false, cancelled: false, - transferable: false + transferable: false, + timeBasedClosing: true }); deal = new DealNFT( @@ -98,7 +99,7 @@ contract DealSetup is Test { function _setup() internal { vm.prank(sponsor); - deal.setup(address(escrowToken), 30 minutes, 50000, "https://social", "https://website", "https://image", "", 1); + deal.setup(address(escrowToken), 30 minutes, 50000, 0, 0, "https://social", "https://website", "https://image", "", 1); } function _configure() internal { From 02475ccdd5a3a0846ab346ba0315c2539e642e7d Mon Sep 17 00:00:00 2001 From: Cela Pablo Date: Thu, 20 Feb 2025 16:37:08 -0300 Subject: [PATCH 2/5] adds tests for amount based closing config and functionality --- src/DealNFT.sol | 4 +- test/AmountBased/DealNFTStates.t.sol | 76 ++++++++++++++ test/AmountBased/DealSetupAmountBased.sol | 121 +++++++++++++++++++++ test/DealFactory.t.sol | 122 ++++++++++++++++++++++ test/DealNFTConfigure.t.sol | 2 +- test/DealNFTRecover.t.sol | 21 +++- test/DealNFTUnstake.t.sol | 18 +++- test/DealSetup.sol | 4 +- test/Reader.t.sol | 2 +- test/StakingRelayer.t.sol | 13 +++ test/WhitelistFactory.t.sol | 21 ++++ 11 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 test/AmountBased/DealNFTStates.t.sol create mode 100644 test/AmountBased/DealSetupAmountBased.sol create mode 100644 test/DealFactory.t.sol create mode 100644 test/WhitelistFactory.t.sol diff --git a/src/DealNFT.sol b/src/DealNFT.sol index d46c3dd..1943e47 100644 --- a/src/DealNFT.sol +++ b/src/DealNFT.sol @@ -476,7 +476,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { return State.Claiming; } else { if(_minimumReached()) { - if(_afterClosed(lastStakeTimestamp)){ + if(_afterClosed(lastStakeTimestamp)){ // --- return State.Cancelled; } @@ -587,7 +587,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { * @notice Check if the minimum has been reached */ function _minimumReached() private view returns (bool) { - return _totalStaked(_tokenId) >= config.dealMinimum; + return config.dealMinimum > 0 && _totalStaked(_tokenId) >= config.dealMinimum; } /** diff --git a/test/AmountBased/DealNFTStates.t.sol b/test/AmountBased/DealNFTStates.t.sol new file mode 100644 index 0000000..594944a --- /dev/null +++ b/test/AmountBased/DealNFTStates.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test, console} from "forge-std/Test.sol"; +import {DealNFT} from "../../src/DealNFT.sol"; +import {DealSetupAmountBased} from "./DealSetupAmountBased.sol"; + +contract DealNFTStatesTest is Test, DealSetupAmountBased { + function setUp() public { + _init(); + _setup(); + _activate(); + } + + function test_State_Claiming() public { + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + + _stake(staker1); + tokenId = 0; + assertEq(deal.stakedAmount(tokenId), amount); + assertEq(escrowToken.balanceOf(staker1), 0); + assertEq(deal.ownerOf(tokenId), staker1); + assertEq(deal.totalStaked(), amount); + assertEq( + escrowToken.balanceOf(address(deal.getTokenBoundAccount(tokenId))), + amount + ); + + _stake(staker2); + tokenId = 1; + assertEq(deal.stakedAmount(tokenId), amount); + assertEq(escrowToken.balanceOf(staker2), 0); + assertEq(deal.ownerOf(tokenId), staker2); + assertEq(deal.totalStaked(), amount * 2); + assertEq( + escrowToken.balanceOf(address(deal.getTokenBoundAccount(tokenId))), + amount + ); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + } + + function test_State_Cancelled() public { + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + skip(1 weeks); + _stake(staker1); + _stake(staker2); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + skip(8 days); + + assertEq(uint256(deal.state()), uint256(DealNFT.State.Cancelled)); + } + + function test_RevertWhen_StakeClaiming() public { + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + _stake(staker1); + _stake(staker2); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + + vm.expectRevert(DealNFT.NotActive.selector); + _stake(staker1); + } + + function test_RevertWhen_StakeAfterCancelled() public { + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + skip(1 weeks); + _stake(staker1); + _stake(staker2); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + skip(8 days); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Cancelled)); + + vm.expectRevert(DealNFT.NotActive.selector); + _stake(staker1); + } + +} diff --git a/test/AmountBased/DealSetupAmountBased.sol b/test/AmountBased/DealSetupAmountBased.sol new file mode 100644 index 0000000..6548f59 --- /dev/null +++ b/test/AmountBased/DealSetupAmountBased.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test, console} from "forge-std/Test.sol"; +import {DealNFT} from "../../src/DealNFT.sol"; +import {AccountV3TBD} from "../../src/AccountV3TBD.sol"; + +import "multicall-authenticated/Multicall3.sol"; +import "erc6551/ERC6551Registry.sol"; +import "tokenbound/src/AccountGuardian.sol"; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {IERC20Metadata} from "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; + +contract DealSetupAmountBased is Test { + DealNFT public deal; + IERC20Metadata public escrowToken; + + uint256 tokenId = 0; + uint256 amount = 1000000; + address sponsor; + address treasury; + address arbitrator; + address staker1; + address staker2; + + function _init() internal { + sponsor = vm.addr(1); + treasury = vm.addr(2); + arbitrator = vm.addr(3); + + staker1 = vm.addr(4); + staker2 = vm.addr(5); + + escrowToken = new ERC20PresetFixedSupply("escrow", "escrow", 50000000, address(this)); + escrowToken.transfer(address(staker1), amount); + escrowToken.transfer(address(staker2), amount); + escrowToken.transfer(address(sponsor), amount*3); + escrowToken.transfer(address(arbitrator), amount*3); + + ERC6551Registry registry = new ERC6551Registry(); + Multicall3 forwarder = new Multicall3(); + AccountGuardian guardian = new AccountGuardian(address(this)); + + AccountV3TBD implementation = new AccountV3TBD( + address(1), + address(forwarder), + address(registry), + address(guardian) + ); + + DealNFT.Configuration memory dealConfig = DealNFT.Configuration({ + escrowToken: address(0), + sponsor: sponsor, + arbitrator: arbitrator, + image: "https://image.jpg", + description: "", + social: "", + website: "", + multiple: 1e18, + closingDelay: 0, + unstakingFee: 0, + closingTime: 0, + dealMinimum: 0, + dealMaximum: 0, + deliveryType: 0, + active: false, + cancelled: false, + transferable: false, + timeBasedClosing: false + }); + + deal = new DealNFT( + treasury, + address(registry), + payable(address(implementation)), + "https://test.com/chain/1/deal/", + "SurgeDealTEST", + "SRGTEST", + dealConfig + ); + + vm.prank(treasury); + deal.setArbitrator(arbitrator); + + vm.prank(staker1); + escrowToken.approve(address(deal), amount); + + vm.prank(staker2); + escrowToken.approve(address(deal), amount); + + } + + function _stake(address staker) internal { + vm.prank(staker); + deal.stake(staker, amount); + } + + function _setup() internal { + vm.prank(sponsor); + deal.setup(address(escrowToken), 30 minutes, 50000, 2000000, 2000000, "https://social", "https://website", "https://image", "", 1); + } + + function _configure() internal { + vm.prank(sponsor); + deal.configure("desc", "https://social", "https://website", 0, 0, 0, 5e18); + } + + function _activate() internal { + vm.prank(sponsor); + deal.activate(); + } + + function _depositDeliveryTokens() internal { + vm.startPrank(arbitrator); + escrowToken.approve(address(deal), amount*3); + deal.depositDeliveryTokens(address(escrowToken), amount*3); + vm.stopPrank(); + } +} diff --git a/test/DealFactory.t.sol b/test/DealFactory.t.sol new file mode 100644 index 0000000..6f2467c --- /dev/null +++ b/test/DealFactory.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test, console} from "forge-std/Test.sol"; +import {DealFactory} from "../src/DealFactory.sol"; +import {DealNFT} from "../src/DealNFT.sol"; +import {StakingRelayer} from "../src/StakingRelayer.sol"; + +contract DealFactoryTest is Test { + address sponsor; + address treasury; + address registry; + address implementation; + + DealFactory factory; + StakingRelayer relayer; + + function setUp() public { + sponsor = vm.addr(1); + treasury = vm.addr(2); + registry = vm.addr(3); + implementation = vm.addr(4); + + relayer = new StakingRelayer(sponsor); + } + + function test_Create_Deal() public { + _setup(); + address deal = _create(); + assertNotEq(deal, address(0)); + } + + function test_SetRelayer() public { + _setup(); + vm.startPrank(sponsor); + factory.setRelayer(address(relayer)); + relayer.setFactory(address(factory)); + vm.stopPrank(); + + address deal = _create(); + assertNotEq(deal, address(0)); + assertEq(relayer.enabledDeals(deal), true); + } + + // --- Revert tests --- + function test_RevertWhen_ZeroAddress() public { + vm.expectRevert(DealFactory.ZeroDetected.selector); + new DealFactory(address(0), treasury, registry, implementation, "https://example.com/nft/"); + + vm.expectRevert(DealFactory.ZeroDetected.selector); + new DealFactory(sponsor, address(0), registry, implementation, "https://example.com/nft/"); + + vm.expectRevert(DealFactory.ZeroDetected.selector); + new DealFactory(sponsor, treasury, address(0), implementation, "https://example.com/nft/"); + + vm.expectRevert(DealFactory.ZeroDetected.selector); + new DealFactory(sponsor, treasury, registry, address(0), "https://example.com/nft/"); + + vm.expectRevert(DealFactory.ZeroDetected.selector); + new DealFactory(sponsor, treasury, registry, implementation, ""); + } + + function test_RevertWhen_TurnedOff() public { + _setup(); + _create(); + vm.prank(sponsor); + factory.turnOff(); + vm.expectRevert(DealFactory.FactoryTurnedOff.selector); + _create(); + } + + function test_RevertWhen_TurnOffWithWrongOwner() public { + _setup(); + vm.prank(treasury); + vm.expectRevert(DealFactory.OnlyOwner.selector); + factory.turnOff(); + } + + function test_RevertWhen_SetRelayerWithWrongOwner() public { + _setup(); + vm.prank(treasury); + vm.expectRevert(DealFactory.OnlyOwner.selector); + factory.setRelayer(address(relayer)); + } + + function test_RevertWhen_SetRelayerWithZeroAddress() public { + _setup(); + vm.prank(sponsor); + vm.expectRevert(DealFactory.ZeroDetected.selector); + factory.setRelayer(address(0)); + } + + + // --- private functions --- + function _setup() private { + factory = new DealFactory(sponsor, treasury, registry, implementation, "https://example.com/nft/"); + } + + function _create() private returns (address) { + DealNFT.Configuration memory dealConfig = DealNFT.Configuration({ + escrowToken: address(0), + sponsor: sponsor, + arbitrator: treasury, + image: "https://image.jpg", + description: "", + social: "", + website: "", + multiple: 1e18, + closingDelay: 0, + unstakingFee: 0, + closingTime: 0, + dealMinimum: 0, + dealMaximum: 0, + deliveryType: 0, + active: false, + cancelled: false, + transferable: false, + timeBasedClosing: true + }); + return factory.create("name", "symbol", dealConfig); + } +} \ No newline at end of file diff --git a/test/DealNFTConfigure.t.sol b/test/DealNFTConfigure.t.sol index cf742ca..f7f25af 100644 --- a/test/DealNFTConfigure.t.sol +++ b/test/DealNFTConfigure.t.sol @@ -60,7 +60,7 @@ contract DealNFTConfigureTest is Test, DealSetup { DealNFT.Configuration memory configAfter = deal.getConfiguration(); assertEq(configAfter.description, "desc"); assertEq(configAfter.closingTime, block.timestamp + 2 weeks); - assertEq(configAfter.dealMinimum, 0); + assertEq(configAfter.dealMinimum, 1000000); assertEq(configAfter.dealMaximum, 2000000); assertEq(configAfter.transferable, false); } diff --git a/test/DealNFTRecover.t.sol b/test/DealNFTRecover.t.sol index eaf2f35..03964e6 100644 --- a/test/DealNFTRecover.t.sol +++ b/test/DealNFTRecover.t.sol @@ -78,6 +78,25 @@ contract DealNFTRecoverTest is Test, DealSetup { } function test_RecoverAfterClosed() public { - // TODO + _stake(staker1); + _stake(staker2); + _stake(arbitrator); + + skip(15 days); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + + vm.expectRevert(DealNFT.MinimumReached.selector); + vm.prank(staker1); + deal.recover(0); + + vm.prank(arbitrator); + deal.claim(); + + assertEq(deal.totalClaimed(), amount * 2); + + uint256 balance = escrowToken.balanceOf(arbitrator); + vm.prank(arbitrator); + deal.recover(2); + assertEq(escrowToken.balanceOf(arbitrator), balance + amount); } } diff --git a/test/DealNFTUnstake.t.sol b/test/DealNFTUnstake.t.sol index bd001ce..1c35c1d 100644 --- a/test/DealNFTUnstake.t.sol +++ b/test/DealNFTUnstake.t.sol @@ -87,6 +87,22 @@ contract DealNFTUnstakeTest is Test, DealSetup { } function test_RevertWhen_UnstakeAfterClosed() public { - // TODO + _stake(staker1); + _stake(staker2); + _stake(arbitrator); + + skip(15 days); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + + vm.expectRevert(DealNFT.MinimumReached.selector); + vm.prank(staker1); + deal.recover(0); + + vm.prank(arbitrator); + deal.claim(); + + vm.expectRevert(DealNFT.CannotUnstake.selector); + vm.prank(arbitrator); + deal.unstake(2); } } diff --git a/test/DealSetup.sol b/test/DealSetup.sol index b387230..ccd71af 100644 --- a/test/DealSetup.sol +++ b/test/DealSetup.sol @@ -90,6 +90,8 @@ contract DealSetup is Test { vm.prank(staker2); escrowToken.approve(address(deal), amount); + vm.prank(arbitrator); + escrowToken.approve(address(deal), amount); } function _stake(address staker) internal { @@ -104,7 +106,7 @@ contract DealSetup is Test { function _configure() internal { vm.prank(sponsor); - deal.configure("desc", "https://social", "https://website", block.timestamp + 2 weeks, 0, 2000000, 5e18); + deal.configure("desc", "https://social", "https://website", block.timestamp + 2 weeks, 1000000, 2000000, 5e18); } function _activate() internal { diff --git a/test/Reader.t.sol b/test/Reader.t.sol index 6725b84..dc73aa2 100644 --- a/test/Reader.t.sol +++ b/test/Reader.t.sol @@ -34,7 +34,7 @@ contract ReaderTest is Test, DealSetup { assertEq(_deal.totalClaimed, 0); assertEq(_deal.totalStaked, 2000000); assertEq(_deal.multiple, 5e18); - assertEq(_deal.dealMinimum, 0); + assertEq(_deal.dealMinimum, 1000000); assertEq(_deal.dealMaximum, 2000000); assertEq(_deal.unstakingFee, 50000); assertEq(_deal.nextId, 2); diff --git a/test/StakingRelayer.t.sol b/test/StakingRelayer.t.sol index 0e13907..8a326ab 100644 --- a/test/StakingRelayer.t.sol +++ b/test/StakingRelayer.t.sol @@ -41,6 +41,13 @@ contract StakingRelayerTest is Test, DealSetup { assertEq(escrowToken.balanceOf(staker2), 0); } + function test_EnableDealWithOwner() public { + vm.prank(treasury); + relayer.enableDeal(address(deal)); + + assert(relayer.enabledDeals(address(deal))); + } + function test_RevertWhen_StakeWithStakingRelayerBeforeEnable() public { vm.startPrank(staker1); escrowToken.approve(address(relayer), amount); @@ -89,6 +96,12 @@ contract StakingRelayerTest is Test, DealSetup { relayer.disableDeal(address(deal)); } + function test_RevertWhen_setFactoryWithWrongSender() public { + vm.expectRevert("StakingRelayer: not owner"); + vm.prank(staker1); + relayer.setFactory(staker1); + } + function _stake(address staker, uint256 amount_) internal { vm.startPrank(staker); escrowToken.approve(address(relayer), amount_); diff --git a/test/WhitelistFactory.t.sol b/test/WhitelistFactory.t.sol new file mode 100644 index 0000000..3b52478 --- /dev/null +++ b/test/WhitelistFactory.t.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import {Test, console} from "forge-std/Test.sol"; +import {WhitelistFactory} from "../src/WhitelistFactory.sol"; +import {Whitelists} from "../src/Whitelists.sol"; + +contract WhitelistFactoryTest is Test { + address sponsor; + WhitelistFactory factory; + + function setUp() public { + sponsor = vm.addr(1); + factory = new WhitelistFactory(); + } + + function test_CreateWhitelist() public { + address whitelist = factory.create(sponsor); + assertEq(Whitelists(whitelist).sponsor(), sponsor); + } +} \ No newline at end of file From 5bb9171553111aa30aead4e6d2abbddf0dc5a93d Mon Sep 17 00:00:00 2001 From: Cela Pablo Date: Fri, 21 Feb 2025 11:27:29 -0300 Subject: [PATCH 3/5] removes extra code --- test/AmountBased/DealSetupAmountBased.sol | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/AmountBased/DealSetupAmountBased.sol b/test/AmountBased/DealSetupAmountBased.sol index 6548f59..69ebecf 100644 --- a/test/AmountBased/DealSetupAmountBased.sol +++ b/test/AmountBased/DealSetupAmountBased.sol @@ -102,20 +102,9 @@ contract DealSetupAmountBased is Test { deal.setup(address(escrowToken), 30 minutes, 50000, 2000000, 2000000, "https://social", "https://website", "https://image", "", 1); } - function _configure() internal { - vm.prank(sponsor); - deal.configure("desc", "https://social", "https://website", 0, 0, 0, 5e18); - } - function _activate() internal { vm.prank(sponsor); deal.activate(); } - function _depositDeliveryTokens() internal { - vm.startPrank(arbitrator); - escrowToken.approve(address(deal), amount*3); - deal.depositDeliveryTokens(address(escrowToken), amount*3); - vm.stopPrank(); - } } From 43c0ef3264b70d4eb00692fb9704fa0d7d8d6aa7 Mon Sep 17 00:00:00 2001 From: Cela Pablo Date: Mon, 24 Feb 2025 14:08:17 -0300 Subject: [PATCH 4/5] tests --- src/DealNFT.sol | 2 +- test/AmountBased/DealNFTStates.t.sol | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/DealNFT.sol b/src/DealNFT.sol index 1943e47..6646167 100644 --- a/src/DealNFT.sol +++ b/src/DealNFT.sol @@ -476,7 +476,7 @@ contract DealNFT is ERC721, IDealNFT, ReentrancyGuard { return State.Claiming; } else { if(_minimumReached()) { - if(_afterClosed(lastStakeTimestamp)){ // --- + if(_afterClosed(lastStakeTimestamp)){ return State.Cancelled; } diff --git a/test/AmountBased/DealNFTStates.t.sol b/test/AmountBased/DealNFTStates.t.sol index 594944a..d643bd9 100644 --- a/test/AmountBased/DealNFTStates.t.sol +++ b/test/AmountBased/DealNFTStates.t.sol @@ -12,6 +12,21 @@ contract DealNFTStatesTest is Test, DealSetupAmountBased { _activate(); } + function test_State_Active() public { + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + skip(3 weeks); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + + _stake(staker1); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + + skip(2 weeks); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); + + _stake(staker2); + assertEq(uint256(deal.state()), uint256(DealNFT.State.Claiming)); + } + function test_State_Claiming() public { assertEq(uint256(deal.state()), uint256(DealNFT.State.Active)); From d98a0f5abbe6b72ab16043971aa7ce820ab41821 Mon Sep 17 00:00:00 2001 From: Cela Pablo Date: Thu, 27 Feb 2025 14:07:05 -0300 Subject: [PATCH 5/5] GhostLog --- .ghost/events.sol | 8 ++++++-- .ghost/indexer.sol | 28 ++++++++++++++++++++++------ .ghost/schema.sol | 3 +++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.ghost/events.sol b/.ghost/events.sol index 2d29e30..8ef3d8a 100644 --- a/.ghost/events.sol +++ b/.ghost/events.sol @@ -1,8 +1,9 @@ interface Events { event Create(address indexed deal, address indexed sponsor, address arbitrator, address escrowToken, string name, string symbol, string image, string description); - event Init(string social, string website, uint256 multiple, uint256 closingDelay, uint256 unstakingFee, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 deliveryType, bool active, bool transferable); - event Setup(address escrowToken, uint256 closingDelay, uint256 unstakingFee, string website, string social, string image, string description, uint256 deliveryType); + event Init(string social, string website, uint256 multiple, uint256 closingDelay, uint256 unstakingFee, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 deliveryType, bool active, bool transferable, bool timeBasedClosing); + event Setup(address escrowToken, uint256 closingDelay, uint256 unstakingFee, uint256 dealMinimum, uint256 dealMaximum, string website, string social, string image, string description, uint256 deliveryType); + event Configure(string description, string social, string website, uint256 closingTime, uint256 dealMinimum, uint256 dealMaximum, uint256 multiple); event StateUpdated(uint8 state); event Transferable(bool transferable); @@ -11,4 +12,7 @@ interface Events { event Stake(address indexed staker, address tokenBoundAccount, uint256 tokenId, uint256 amount); event Unstake(address indexed staker, address tokenBoundAccount, uint256 tokenId, uint256 amount); event Recover(address indexed staker, address tokenBoundAccount, uint256 tokenId, uint256 amount); + + event SetStakersWhitelist(address whitelist); + event SetClaimsWhitelist(address whitelist); } diff --git a/.ghost/indexer.sol b/.ghost/indexer.sol index 717911b..f563c9d 100644 --- a/.ghost/indexer.sol +++ b/.ghost/indexer.sol @@ -1,4 +1,3 @@ - // SPDX-License-Identifier: MIT pragma solidity 0.8.19; @@ -9,6 +8,7 @@ import "./gen_helpers.sol"; interface IDeal { function totalStaked() external view returns (uint256); + function state() external view returns (uint8); } interface IERC20 { @@ -23,8 +23,8 @@ contract MyIndex is GhostGraph { using StringHelpers for address; function registerHandles() external { - graph.registerFactory(0xA831B98cD0190bC973cAfd0551BfCfed0Ff3f510, GhostEventName.Create, "deal"); - graph.registerHandle(0xA831B98cD0190bC973cAfd0551BfCfed0Ff3f510); + graph.registerFactory(0xB59392C43F454D505CB2ead541a5BFeF3858b1E7, GhostEventName.Create, "deal"); + graph.registerHandle(0xB59392C43F454D505CB2ead541a5BFeF3858b1E7); } function onCreate(EventDetails memory details, CreateEvent memory ev) external { @@ -61,8 +61,9 @@ contract MyIndex is GhostGraph { deal.dealMaximum = ev.dealMaximum; deal.deliveryType = ev.deliveryType; deal.transferable = ev.transferable; + deal.timeBasedClosing = ev.timeBasedClosing; - if(ev.active){ + if (ev.active) { deal.state = 1; } @@ -83,6 +84,8 @@ contract MyIndex is GhostGraph { deal.image = ev.image; deal.description = ev.description; deal.deliveryType = ev.deliveryType; + deal.dealMinimum = ev.dealMinimum; + deal.dealMaximum = ev.dealMaximum; graph.saveDeal(deal); } @@ -117,7 +120,7 @@ contract MyIndex is GhostGraph { Deal memory deal = graph.getDeal(details.emitter); deal.state = ev.state; graph.saveDeal(deal); - } + } function onStake(EventDetails memory details, StakeEvent memory ev) external { // DEAL @@ -125,6 +128,7 @@ contract MyIndex is GhostGraph { deal.totalStaked = IDeal(details.emitter).totalStaked(); deal.lastStakeTime = details.timestamp; deal.lastStakeTxHash = details.transactionHash; + deal.state = IDeal(details.emitter).state(); graph.saveDeal(deal); // STAKE @@ -167,8 +171,20 @@ contract MyIndex is GhostGraph { graph.saveStake(stake); } + function onSetStakersWhitelist(EventDetails memory details, SetStakersWhitelistEvent memory ev) external { + Deal memory deal = graph.getDeal(details.emitter); + deal.stakersWhitelist = ev.whitelist; + graph.saveDeal(deal); + } + + function onSetClaimsWhitelist(EventDetails memory details, SetClaimsWhitelistEvent memory ev) external { + Deal memory deal = graph.getDeal(details.emitter); + deal.claimsWhitelist = ev.whitelist; + graph.saveDeal(deal); + } + // HELPER - function getStakeId(address deal, uint256 token) private returns(string memory){ + function getStakeId(address deal, uint256 token) private returns (string memory) { return string(abi.encodePacked(deal.toString(), ":", token.toString())); } } diff --git a/.ghost/schema.sol b/.ghost/schema.sol index b142a5b..38a9310 100644 --- a/.ghost/schema.sol +++ b/.ghost/schema.sol @@ -5,6 +5,8 @@ struct Deal { address sponsor; address arbitrator; address escrowToken; + address stakersWhitelist; + address claimsWhitelist; string escrowSymbol; uint8 escrowDecimals; string name; @@ -21,6 +23,7 @@ struct Deal { uint256 dealMaximum; uint256 deliveryType; bool transferable; + bool timeBasedClosing; // calculated uint8 state;