From 7182ee649d22405b4a1131e7d48a7a0a0ea6ab83 Mon Sep 17 00:00:00 2001 From: gitwoz <177856586+gitwoz@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:04:09 +0700 Subject: [PATCH] feat(curation-vault): impl CurationVault --- .env.local.example | 2 + .env.op-mainnet.example | 2 + .env.op-sepolia.example | 2 + .env.polygon-mainnet.example | 2 + .env.polygon-mumbai.example | 2 + .gas-snapshot | 68 +++++---- Makefile | 4 + src/Curation/IVault.sol | 100 +++++++++++-- src/Curation/Vault.sol | 157 +++++++++++++++++++++ src/test/Curation/Curation.t.sol | 1 - src/test/Curation/Vault.t.sol | 233 +++++++++++++++++++++++++++++++ 11 files changed, 531 insertions(+), 42 deletions(-) create mode 100644 src/Curation/Vault.sol create mode 100644 src/test/Curation/Vault.t.sol diff --git a/.env.local.example b/.env.local.example index 8627d8c..4d4c175 100644 --- a/.env.local.example +++ b/.env.local.example @@ -20,3 +20,5 @@ THESPACE_LP_TOKENS= BILLBOARD_CURRENCY_TOKEN= BILLBOARD_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 BILLBOARD_ADMIN_ADDRESS= +CURATION_VAULT_SIGNER= +CURATION_VAULT_OWNER= diff --git a/.env.op-mainnet.example b/.env.op-mainnet.example index 85cb5b5..d239426 100644 --- a/.env.op-mainnet.example +++ b/.env.op-mainnet.example @@ -21,3 +21,5 @@ THESPACE_LP_TOKENS= BILLBOARD_CURRENCY_TOKEN= BILLBOARD_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 BILLBOARD_ADMIN_ADDRESS= +CURATION_VAULT_SIGNER= +CURATION_VAULT_OWNER= diff --git a/.env.op-sepolia.example b/.env.op-sepolia.example index 09d6453..c37894f 100644 --- a/.env.op-sepolia.example +++ b/.env.op-sepolia.example @@ -21,3 +21,5 @@ THESPACE_LP_TOKENS= BILLBOARD_CURRENCY_TOKEN= BILLBOARD_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 BILLBOARD_ADMIN_ADDRESS= +CURATION_VAULT_SIGNER= +CURATION_VAULT_OWNER= diff --git a/.env.polygon-mainnet.example b/.env.polygon-mainnet.example index c30a796..ba2bce3 100644 --- a/.env.polygon-mainnet.example +++ b/.env.polygon-mainnet.example @@ -21,3 +21,5 @@ THESPACE_LP_TOKENS= BILLBOARD_CURRENCY_TOKEN= BILLBOARD_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 BILLBOARD_ADMIN_ADDRESS= +CURATION_VAULT_SIGNER= +CURATION_VAULT_OWNER= diff --git a/.env.polygon-mumbai.example b/.env.polygon-mumbai.example index ec2d6de..3bcccea 100644 --- a/.env.polygon-mumbai.example +++ b/.env.polygon-mumbai.example @@ -21,3 +21,5 @@ THESPACE_LP_TOKENS= BILLBOARD_CURRENCY_TOKEN=0x0FA8781a83E46826621b3BC094Ea2A0212e71B23 BILLBOARD_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 BILLBOARD_ADMIN_ADDRESS= +CURATION_VAULT_SIGNER= +CURATION_VAULT_OWNER= diff --git a/.gas-snapshot b/.gas-snapshot index 52ceb47..d63bdd7 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -30,14 +30,14 @@ BillboardTest:testCannotSetWhitelistByAttacker() (gas: 228664) BillboardTest:testCannotTransferByOperator() (gas: 226771) BillboardTest:testCannotTransferToZeroAddress() (gas: 222258) BillboardTest:testCannotUpgradeRegistryByAttacker() (gas: 9017) -BillboardTest:testCannotWithdrawBidIfAuctionNotEndedOrCleared(uint96) (runs: 256, μ: 632850, ~: 632850) -BillboardTest:testCannotWithdrawBidIfNotFound(uint96) (runs: 256, μ: 750346, ~: 750346) -BillboardTest:testCannotWithdrawBidIfWon(uint96) (runs: 256, μ: 1026516, ~: 1026516) -BillboardTest:testCannotWithdrawBidTwice(uint96) (runs: 256, μ: 1111002, ~: 1111002) -BillboardTest:testClearAuction(uint96) (runs: 256, μ: 731815, ~: 731815) +BillboardTest:testCannotWithdrawBidIfAuctionNotEndedOrCleared(uint96) (runs: 257, μ: 632850, ~: 632850) +BillboardTest:testCannotWithdrawBidIfNotFound(uint96) (runs: 257, μ: 750346, ~: 750346) +BillboardTest:testCannotWithdrawBidIfWon(uint96) (runs: 257, μ: 1026516, ~: 1026516) +BillboardTest:testCannotWithdrawBidTwice(uint96) (runs: 257, μ: 1111002, ~: 1111002) +BillboardTest:testClearAuction(uint96) (runs: 257, μ: 731815, ~: 731815) BillboardTest:testClearAuctionIfAlreadyCleared() (gas: 738895) BillboardTest:testClearAuctions() (gas: 1315317) -BillboardTest:testClearLastAuction(uint96) (runs: 256, μ: 732739, ~: 732739) +BillboardTest:testClearLastAuction(uint96) (runs: 257, μ: 732739, ~: 732739) BillboardTest:testClearLastAuctions() (gas: 1332619) BillboardTest:testGetBidderBids(uint8,uint8,uint8) (runs: 256, μ: 1508601, ~: 1140537) BillboardTest:testGetBids(uint8,uint8,uint8) (runs: 256, μ: 8312589, ~: 6311372) @@ -45,10 +45,10 @@ BillboardTest:testGetBlockFromEpoch() (gas: 16893) BillboardTest:testGetEpochFromBlock() (gas: 17903) BillboardTest:testGetTokenURI() (gas: 391345) BillboardTest:testMintBoard() (gas: 585666) -BillboardTest:testPlaceBid(uint96) (runs: 256, μ: 840080, ~: 840702) +BillboardTest:testPlaceBid(uint96) (runs: 257, μ: 840082, ~: 840702) BillboardTest:testPlaceBidIfBoardWhitelistDisabled() (gas: 598166) -BillboardTest:testPlaceBidWithHigherPrice(uint96) (runs: 256, μ: 1011527, ~: 1011532) -BillboardTest:testPlaceBidWithSamePrices(uint96) (runs: 256, μ: 908843, ~: 909776) +BillboardTest:testPlaceBidWithHigherPrice(uint96) (runs: 257, μ: 1011527, ~: 1011532) +BillboardTest:testPlaceBidWithSamePrices(uint96) (runs: 257, μ: 908846, ~: 909776) BillboardTest:testPlaceBidZeroPrice() (gas: 432705) BillboardTest:testSafeTransferByOperator() (gas: 235259) BillboardTest:testSetBidURIs() (gas: 659834) @@ -57,9 +57,9 @@ BillboardTest:testSetBoardWhitelistDisabled() (gas: 244401) BillboardTest:testSetClosed() (gas: 241110) BillboardTest:testSetWhitelist() (gas: 245426) BillboardTest:testUpgradeRegistry() (gas: 3924811) -BillboardTest:testWithdrawBid(uint96) (runs: 256, μ: 1098966, ~: 1098966) -BillboardTest:testWithdrawBidIfClosed(uint96) (runs: 256, μ: 697036, ~: 697036) -BillboardTest:testWithdrawTax(uint96) (runs: 256, μ: 737116, ~: 737116) +BillboardTest:testWithdrawBid(uint96) (runs: 257, μ: 1098966, ~: 1098966) +BillboardTest:testWithdrawBidIfClosed(uint96) (runs: 257, μ: 697036, ~: 697036) +BillboardTest:testWithdrawTax(uint96) (runs: 257, μ: 737116, ~: 737116) CurationTest:testCannotCurateERC20CurateZeroAmount() (gas: 12194) CurationTest:testCannotCurateERC20EmptyURI() (gas: 15797) CurationTest:testCannotCurateERC20IfNotApproval() (gas: 21624) @@ -77,8 +77,8 @@ DistributionTest:testCannotClaimIfAlreadyClaimed() (gas: 284835) DistributionTest:testCannotClaimIfInsufficientBalance() (gas: 394264) DistributionTest:testCannotClaimIfInvalidProof() (gas: 245236) DistributionTest:testCannotClaimIfInvalidTreeId() (gas: 243332) -DistributionTest:testCannotDropIfInsufficientAllowance(uint256) (runs: 256, μ: 212269, ~: 212284) -DistributionTest:testCannotDropIfInsufficientBalance(uint256) (runs: 256, μ: 214708, ~: 214740) +DistributionTest:testCannotDropIfInsufficientAllowance(uint256) (runs: 257, μ: 212269, ~: 212283) +DistributionTest:testCannotDropIfInsufficientBalance(uint256) (runs: 257, μ: 214708, ~: 214740) DistributionTest:testCannotDropIfZeroAmount() (gas: 148793) DistributionTest:testCannotDropTwiceWithSameTreeId() (gas: 307260) DistributionTest:testCannotSetAdminByAdmin() (gas: 17334) @@ -89,16 +89,16 @@ DistributionTest:testClaim() (gas: 414576) DistributionTest:testDrop() (gas: 568791) DistributionTest:testSetAdmin() (gas: 20239) DistributionTest:testSweep() (gas: 253087) -LogbookNFTSVGTest:testTokenURI(uint8,uint8,uint16) (runs: 256, μ: 2613180, ~: 1746428) +LogbookNFTSVGTest:testTokenURI(uint8,uint8,uint16) (runs: 257, μ: 2619845, ~: 1746470) LogbookTest:testClaim() (gas: 135608) -LogbookTest:testDonate(uint96) (runs: 256, μ: 156549, ~: 156936) -LogbookTest:testDonateWithCommission(uint96,uint96) (runs: 256, μ: 146644, ~: 140444) -LogbookTest:testFork(uint96,string) (runs: 256, μ: 452537, ~: 453928) -LogbookTest:testForkRecursively(uint8,uint96) (runs: 256, μ: 5351224, ~: 1801537) -LogbookTest:testForkWithCommission(uint96,string,uint256) (runs: 256, μ: 342465, ~: 257636) +LogbookTest:testDonate(uint96) (runs: 257, μ: 156550, ~: 156936) +LogbookTest:testDonateWithCommission(uint96,uint96) (runs: 257, μ: 146785, ~: 140444) +LogbookTest:testFork(uint96,string) (runs: 257, μ: 452546, ~: 453928) +LogbookTest:testForkRecursively(uint8,uint96) (runs: 257, μ: 5369772, ~: 1801537) +LogbookTest:testForkWithCommission(uint96,string,uint256) (runs: 257, μ: 342135, ~: 257636) LogbookTest:testMulticall() (gas: 284999) LogbookTest:testPublicSale() (gas: 207337) -LogbookTest:testPublish(string) (runs: 256, μ: 264065, ~: 263590) +LogbookTest:testPublish(string) (runs: 257, μ: 264063, ~: 263590) LogbookTest:testPublishEn1000() (gas: 243477) LogbookTest:testPublishEn140() (gas: 221241) LogbookTest:testPublishEn200() (gas: 222826) @@ -117,7 +117,7 @@ LogbookTest:testPublishZh5000() (gas: 607690) LogbookTest:testSetDescription() (gas: 140760) LogbookTest:testSetForkPrice() (gas: 153925) LogbookTest:testSetTitle() (gas: 168680) -LogbookTest:testSplitRoyalty(uint8,uint8,uint96) (runs: 256, μ: 1959072, ~: 965338) +LogbookTest:testSplitRoyalty(uint8,uint8,uint96) (runs: 257, μ: 1969363, ~: 965338) LogbookTest:testWithdraw() (gas: 7284400) SnapperTest:testCannotInitRegionByNotOwner() (gas: 11365) SnapperTest:testCannotReInitRegion() (gas: 14373) @@ -125,10 +125,10 @@ SnapperTest:testCannotTakeSnapshotBeforeInit() (gas: 15717) SnapperTest:testCannotTakeSnapshotByNotOwner() (gas: 12478) SnapperTest:testCannotTakeSnapshotWrongLastBlock() (gas: 49242) SnapperTest:testCannotTakeSnapshotWrongSnapshotBlock() (gas: 23899) -SnapperTest:testInitRegion(uint256) (runs: 256, μ: 114408, ~: 114408) +SnapperTest:testInitRegion(uint256) (runs: 257, μ: 114408, ~: 114408) SnapperTest:testTakeSnapshot() (gas: 47831) TheSpaceTest:testBatchBid() (gas: 695308) -TheSpaceTest:testBatchSetPixels(uint16,uint8) (runs: 256, μ: 371399, ~: 372904) +TheSpaceTest:testBatchSetPixels(uint16,uint8) (runs: 257, μ: 371405, ~: 372904) TheSpaceTest:testBidDefaultedToken() (gas: 413399) TheSpaceTest:testBidExistingToken() (gas: 360023) TheSpaceTest:testBidNewToken() (gas: 303729) @@ -165,11 +165,11 @@ TheSpaceTest:testSetColor() (gas: 331348) TheSpaceTest:testSetMintTax() (gas: 271715) TheSpaceTest:testSetPixel(uint256) (runs: 256, μ: 403816, ~: 403816) TheSpaceTest:testSetPrice(uint256) (runs: 256, μ: 304652, ~: 304652) -TheSpaceTest:testSetPriceByOperator(uint96) (runs: 256, μ: 354785, ~: 354785) +TheSpaceTest:testSetPriceByOperator(uint96) (runs: 257, μ: 354785, ~: 354785) TheSpaceTest:testSetPriceTooHigh() (gas: 314504) TheSpaceTest:testSetTaxRate() (gas: 347951) TheSpaceTest:testSetTokenImageURI() (gas: 355813) -TheSpaceTest:testSetTotalSupply(uint256) (runs: 256, μ: 352202, ~: 352208) +TheSpaceTest:testSetTotalSupply(uint256) (runs: 257, μ: 352202, ~: 352208) TheSpaceTest:testSetTreasuryShare() (gas: 384288) TheSpaceTest:testSettleTax() (gas: 339465) TheSpaceTest:testTaxCalculation() (gas: 402405) @@ -177,4 +177,18 @@ TheSpaceTest:testTokenShouldBeDefaulted() (gas: 325529) TheSpaceTest:testTotalSupply() (gas: 7613) TheSpaceTest:testUpgradeTo() (gas: 3215197) TheSpaceTest:testWithdrawTreasury() (gas: 355172) -TheSpaceTest:testWithdrawUBI() (gas: 378319) \ No newline at end of file +TheSpaceTest:testWithdrawUBI() (gas: 378319) +VaultTest:testCannotCurationInvalidURI() (gas: 12033) +VaultTest:testCannotCurationZeroAmount() (gas: 12358) +VaultTest:testCannotCurationZeroNativeAmount() (gas: 10004) +VaultTest:testCannotSetZeroSigner() (gas: 11066) +VaultTest:testCannotWithdrawAlreadyWithdrawn() (gas: 100199) +VaultTest:testCannotWithdrawExpired() (gas: 13075) +VaultTest:testCannotWithdrawInvalidSignature() (gas: 40010) +VaultTest:testCannotWithdrawZeroAddress() (gas: 11663) +VaultTest:testCannotWithdrawZeroAmount() (gas: 44392) +VaultTest:testERC20Curation(uint256) (runs: 256, μ: 81202, ~: 81202) +VaultTest:testERC20Withdrawal(uint256) (runs: 256, μ: 112216, ~: 112219) +VaultTest:testNativeCuration(uint256) (runs: 256, μ: 47876, ~: 47876) +VaultTest:testNativeWithdrawal(uint256) (runs: 256, μ: 98483, ~: 98483) +VaultTest:testSetSigner() (gas: 19504) \ No newline at end of file diff --git a/Makefile b/Makefile index 0c3d0ec..1e392bf 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,10 @@ deploy-snapper: clean deploy-curation: clean @forge create Curation --rpc-url ${ETH_RPC_URL} --private-key ${DEPLOYER_PRIVATE_KEY} --legacy --verify --etherscan-api-key ${ETHERSCAN_API_KEY} +## Curation Vault +deploy-curation-vault: clean + @forge create CurationVault --rpc-url ${ETH_RPC_URL} --private-key ${DEPLOYER_PRIVATE_KEY} --constructor-args ${CURATION_VAULT_SIGNER} ${CURATION_VAULT_OWNER} --legacy --verify --etherscan-api-key ${ETHERSCAN_API_KEY} + ## Billboard deploy-billboard: clean @forge create Billboard --rpc-url ${ETH_RPC_URL} --private-key ${DEPLOYER_PRIVATE_KEY} --constructor-args ${BILLBOARD_CURRENCY_TOKEN} ${BILLBOARD_REGISTRY_ADDRESS} ${BILLBOARD_ADMIN_ADDRESS} "Billboard" "BLBD" --legacy --verify --etherscan-api-key ${ETHERSCAN_API_KEY} diff --git a/src/Curation/IVault.sol b/src/Curation/IVault.sol index f5e5e83..7871e37 100644 --- a/src/Curation/IVault.sol +++ b/src/Curation/IVault.sol @@ -8,13 +8,17 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; * @notice CurationVault is the contract for off-chain content curation. * * Curate: - * - Any address (curator) can send native or ERC-20 tokens to domain-specific ID (content creator) to curate the specific content. + * - Any address (curator) can send native or ERC-20 tokens to domain-specific UID (content creator) to curate the specific content. * - Tokens will be locked in the contract. * * Withdraw: * - Contract owner can withdraw locked tokens to a given address. */ interface ICurationVault { + ////////////////////////////// + /// Error types + ////////////////////////////// + /** * @notice Zero ID or address is invalid. */ @@ -40,39 +44,65 @@ interface ICurationVault { */ error InvalidSignature(); + /** + * @notice expired. + */ + error Expired(); + + /** + * @notice Already withdrawn. + */ + error AlreadyWithdrawn(); + + ////////////////////////////// + /// Event types + ////////////////////////////// + /** * @notice Content curation with ERC-20 token. * @param from Address of content curator. - * @param to Domain-specific ID of content creator. + * @param uid Domain-specific ID of content creator. * @param uri Content URI. * @param token ERC20 token address. * @param amount Amount of tokens to curate. */ - event Curation(address indexed from, string indexed to, IERC20 indexed token, string uri, uint256 amount); + event Curation(address indexed from, string indexed uid, IERC20 indexed token, string uri, uint256 amount); /** * @notice Content curation with native token. * @param from Address of content curator. - * @param to Domain-specific ID of content creator. + * @param uid Domain-specific ID of content creator. * @param uri Content URI. * @param amount Amount of tokens to curate. */ - event Curation(address indexed from, string indexed to, string uri, uint256 amount); + event Curation(address indexed from, string indexed uid, string uri, uint256 amount); /** * @notice Native token withdrawal. * @param to Address to withdraw tokens. + * @param uid Domain-specific ID of content creator. * @param amount Amount of tokens to withdraw. */ - event Withdraw(address indexed to, uint256 amount); + event Withdraw(address indexed to, string indexed uid, uint256 amount); /** * @notice ERC-20 token withdrawal. * @param to Address to withdraw tokens. + * @param uid Domain-specific ID of content creator. * @param token ERC20 token address. * @param amount Amount of tokens to withdraw. */ - event Withdraw(address indexed to, IERC20 indexed token, uint256 amount); + event Withdraw(address indexed to, string indexed uid, IERC20 indexed token, uint256 amount); + + /** + * @notice Signer is changed. + * @param signer New signer of the contract. + */ + event SignerChanged(address indexed signer); + + ////////////////////////////// + /// Curate + ////////////////////////////// /** * @notice Curate content by ERC-20 token donation. @@ -80,12 +110,12 @@ interface ICurationVault { * @dev Emits: {Curation} event. * @dev Throws: {ZeroAddress}, {ZeroAmount}, {InvalidURI} error. * - * @param to_ Domain-specific ID of content creator. + * @param uid_ Domain-specific ID of content creator. * @param token_ ERC20 token address. * @param amount_ Amount of tokens to curate. * @param uri_ Content URI. */ - function curate(string calldata to_, IERC20 token_, uint256 amount_, string calldata uri_) external; + function curate(string calldata uid_, IERC20 token_, uint256 amount_, string calldata uri_) external; /** * @notice Curate content by native token donation. @@ -93,22 +123,64 @@ interface ICurationVault { * @dev Emits: {Curation} event. * @dev Throws: {ZeroAddress}, {ZeroAmount}, {InvalidURI} error. * - * @param to_ Domain-specific ID of content creator. + * @param uid_ Domain-specific ID of content creator. * @param uri_ Content URI. */ - function curate(string calldata to_, string calldata uri_) external payable; + function curate(string calldata uid_, string calldata uri_) external payable; + + ////////////////////////////// + /// Withdraw + ////////////////////////////// + + /** + * @notice Withdraw locked ERC-20 tokens to a given address. + * + * @dev Emits: {Withdraw} event. + * @dev Throws: {ZeroAddress}, {TransferFailed}, {InvalidSignature} error. + * + * @param to_ Address to withdraw tokens. + * @param uid_ Domain-specific ID of content creator. + * @param token_ ERC20 token address. + * @param expiredAt_ Expiration timestamp. + * @param v_ ECDSA signature v. + * @param r_ ECDSA signature r. + * @param s_ ECDSA signature s. + */ + function withdraw( + address to_, + string calldata uid_, + IERC20 token_, + uint256 expiredAt_, + uint8 v_, + bytes32 r_, + bytes32 s_ + ) external; /** - * @notice Withdraw locked tokens to a given address. + * @notice Withdraw locked native tokens to a given address. * * @dev Emits: {Withdraw} event. * @dev Throws: {ZeroAddress}, {TransferFailed}, {InvalidSignature} error. * * @param to_ Address to withdraw tokens. - * @param hashedMessage_ Hashed message. + * @param uid_ Domain-specific ID of content creator. + * @param expiredAt_ Expiration timestamp. * @param v_ ECDSA signature v. * @param r_ ECDSA signature r. * @param s_ ECDSA signature s. */ - function withdraw(address to_, bytes32 hashedMessage_, uint8 v_, bytes32 r_, bytes32 s_) external; + function withdraw(address to_, string calldata uid_, uint256 expiredAt_, uint8 v_, bytes32 r_, bytes32 s_) external; + + ////////////////////////////// + /// Verify + ////////////////////////////// + + /** + * @notice Set a new signer for the contract + * @dev Emits: {SignerChanged} event. + * @dev Throws: {ZeroAddress} error. + * + * @param signer_ Address of the new signer. + */ + function setSigner(address signer_) external; } diff --git a/src/Curation/Vault.sol b/src/Curation/Vault.sol new file mode 100644 index 0000000..044c4da --- /dev/null +++ b/src/Curation/Vault.sol @@ -0,0 +1,157 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import "./IVault.sol"; + +contract CurationVault is ICurationVault, Ownable { + using ECDSA for bytes32; + + address public signer; + + // balances[creatorId][token] + mapping(string => mapping(address => uint256)) public erc20Balances; + + // balances[creatorId] + mapping(string => uint256) public nativeBalances; + + // withdrawals[creatorId][expiredAt] + mapping(string => mapping(uint256 => bool)) public withdrawals; + + constructor(address signer_, address owner_) { + if (signer_ == address(0) || owner_ == address(0)) { + revert ZeroAddress(); + } + + signer = signer_; + + // immediately transfer ownership to a multisig + if (owner_ != address(0)) { + transferOwnership(owner_); + } + } + + /** + * @notice See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId_) external view virtual returns (bool) { + return interfaceId_ == type(ICurationVault).interfaceId; + } + + /// @inheritdoc ICurationVault + function curate(string calldata uid_, IERC20 token_, uint256 amount_, string calldata uri_) public { + if (bytes(uid_).length == 0) revert ZeroAddress(); + if (amount_ <= 0) revert ZeroAmount(); + if (bytes(uri_).length == 0) revert InvalidURI(); + + SafeERC20.safeTransferFrom(token_, msg.sender, address(this), amount_); + + erc20Balances[uid_][address(token_)] += amount_; + + emit Curation(msg.sender, uid_, token_, uri_, amount_); + } + + /// @inheritdoc ICurationVault + function curate(string calldata uid_, string calldata uri_) public payable { + if (bytes(uid_).length == 0) revert ZeroAddress(); + if (msg.value <= 0) revert ZeroAmount(); + if (bytes(uri_).length == 0) revert InvalidURI(); + + nativeBalances[uid_] += msg.value; + + emit Curation(msg.sender, uid_, uri_, msg.value); + } + + /// @inheritdoc ICurationVault + function withdraw( + address to_, + string calldata uid_, + IERC20 token_, + uint256 expiredAt_, + uint8 v_, + bytes32 r_, + bytes32 s_ + ) public { + if (to_ == address(0)) revert ZeroAddress(); + + // Check if the claim is expired + if (expiredAt_ < block.timestamp) { + revert Expired(); + } + + // Check if already withdrawn + if (withdrawals[uid_][expiredAt_]) { + revert AlreadyWithdrawn(); + } + withdrawals[uid_][expiredAt_] = true; + + // Verify the signature + bytes32 hash = keccak256(abi.encodePacked(to_, uid_, token_, expiredAt_, address(this))) + .toEthSignedMessageHash(); + if (!_verify(hash, v_, r_, s_)) { + revert InvalidSignature(); + } + + // Check if balance is enough + uint256 amount_ = erc20Balances[uid_][address(token_)]; + if (amount_ <= 0) revert ZeroAmount(); + erc20Balances[uid_][address(token_)] = 0; + + // Transfer + SafeERC20.safeTransfer(token_, to_, amount_); + + emit Withdraw(to_, uid_, token_, amount_); + } + + /// @inheritdoc ICurationVault + function withdraw(address to_, string calldata uid_, uint256 expiredAt_, uint8 v_, bytes32 r_, bytes32 s_) public { + if (to_ == address(0)) revert ZeroAddress(); + + // Check if the claim is expired + if (expiredAt_ < block.timestamp) { + revert Expired(); + } + + // Check if already withdrawn + if (withdrawals[uid_][expiredAt_]) { + revert AlreadyWithdrawn(); + } + withdrawals[uid_][expiredAt_] = true; + + // Verify the signature + bytes32 hash = keccak256(abi.encodePacked(to_, uid_, expiredAt_, address(this))).toEthSignedMessageHash(); + if (!_verify(hash, v_, r_, s_)) { + revert InvalidSignature(); + } + + // Check if balance is enough + uint256 amount_ = nativeBalances[uid_]; + if (amount_ <= 0) revert ZeroAmount(); + nativeBalances[uid_] = 0; + + // Transfer + (bool success, ) = to_.call{value: amount_}(""); + if (!success) revert TransferFailed(); + + emit Withdraw(to_, uid_, amount_); + } + + /// @inheritdoc ICurationVault + function setSigner(address signer_) external onlyOwner { + if (signer_ == address(0)) revert ZeroAddress(); + + signer = signer_; + emit SignerChanged(signer_); + } + + /** + * @dev verify if a signature is signed by signer + */ + function _verify(bytes32 hash_, uint8 v_, bytes32 r_, bytes32 s_) internal view returns (bool isSignedBySigner) { + address recoveredAddress = hash_.recover(v_, r_, s_); + isSignedBySigner = recoveredAddress != address(0) && recoveredAddress == signer; + } +} diff --git a/src/test/Curation/Curation.t.sol b/src/test/Curation/Curation.t.sol index c275d2c..6787f08 100644 --- a/src/test/Curation/Curation.t.sol +++ b/src/test/Curation/Curation.t.sol @@ -18,7 +18,6 @@ contract CurationTest is Test { Rejector internal contractRejector; event Curation(address indexed from, address indexed to, IERC20 indexed token, string uri, uint256 amount); - event Curation(address indexed from, address indexed to, string uri, uint256 amount); address constant DEPLOYER = address(176); diff --git a/src/test/Curation/Vault.t.sol b/src/test/Curation/Vault.t.sol new file mode 100644 index 0000000..300e99a --- /dev/null +++ b/src/test/Curation/Vault.t.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {USDT} from "../utils/USDT.sol"; +import {CurationVault} from "../../Curation/Vault.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +contract VaultTest is Test { + using ECDSA for bytes32; + + error ZeroAddress(); + error ZeroAmount(); + error TransferFailed(); + error InvalidURI(); + error InvalidSignature(); + error Expired(); + error AlreadyWithdrawn(); + + CurationVault internal vault; + USDT internal usdt; + + event Curation(address indexed from, string indexed uid, IERC20 indexed token, string uri, uint256 amount); + event Curation(address indexed from, string indexed uid, string uri, uint256 amount); + event Withdraw(address indexed to, string indexed uid, IERC20 indexed token, uint256 amount); + event Withdraw(address indexed to, string indexed uid, uint256 amount); + event SignerChanged(address indexed newSigner); + + address constant DEPLOYER = address(176); + address constant CURATOR = address(178); + address constant OWNER = address(179); + address constant RECIPIENT = address(180); + + address SIGNER; + uint256 SIGNER_PRIVATE_KEY; + + uint256 constant MAX_CURATION_AMOUNT = 1000 ether; + + string constant CREATOR_UID = "creator1"; + string constant CONTENT_URI = "ipfs://content"; + + function setUp() public { + (address signer, uint256 signerPrivateKey) = makeAddrAndKey("signer"); + SIGNER = signer; + SIGNER_PRIVATE_KEY = signerPrivateKey; + + vm.label(DEPLOYER, "DEPLOYER"); + vm.label(SIGNER, "SIGNER"); + vm.label(CURATOR, "CURATOR"); + vm.label(OWNER, "OWNER"); + vm.label(RECIPIENT, "RECIPIENT"); + + // Deploy contracts + vm.prank(DEPLOYER); + vault = new CurationVault(SIGNER, OWNER); + usdt = new USDT(CURATOR, 1000); + + // Setup curator + vm.deal(CURATOR, MAX_CURATION_AMOUNT); + vm.startPrank(CURATOR); + usdt.approve(address(vault), type(uint256).max); + vm.stopPrank(); + } + + function testERC20Curation(uint256 amount) public { + vm.assume(amount > 0); + vm.assume(amount <= MAX_CURATION_AMOUNT); + + vm.expectEmit(true, true, true, true); + emit Curation(CURATOR, CREATOR_UID, usdt, CONTENT_URI, amount); + + vm.prank(CURATOR); + vault.curate(CREATOR_UID, usdt, amount, CONTENT_URI); + + assertEq(vault.erc20Balances(CREATOR_UID, address(usdt)), amount); + } + + function testNativeCuration(uint256 amount) public { + vm.assume(amount > 0); + vm.assume(amount <= MAX_CURATION_AMOUNT); + + vm.expectEmit(true, true, true, true); + emit Curation(CURATOR, CREATOR_UID, CONTENT_URI, amount); + + vm.prank(CURATOR); + vault.curate{value: amount}(CREATOR_UID, CONTENT_URI); + + assertEq(vault.nativeBalances(CREATOR_UID), amount); + } + + function testCannotCurationInvalidURI() public { + vm.expectRevert(InvalidURI.selector); + vm.prank(CURATOR); + vault.curate(CREATOR_UID, usdt, 10 ether, ""); + } + + function testCannotCurationZeroAmount() public { + vm.expectRevert(ZeroAmount.selector); + vm.prank(CURATOR); + vault.curate(CREATOR_UID, usdt, 0, CONTENT_URI); + } + + function testCannotCurationZeroNativeAmount() public { + vm.expectRevert(ZeroAmount.selector); + vm.prank(CURATOR); + vault.curate{value: 0}(CREATOR_UID, CONTENT_URI); + } + + function testCannotWithdrawZeroAddress() public { + vm.expectRevert(ZeroAddress.selector); + vault.withdraw(address(0), CREATOR_UID, usdt, 0, 0, bytes32(0), bytes32(0)); + } + + function testERC20Withdrawal(uint256 amount) public { + vm.assume(amount > 0); + vm.assume(amount <= MAX_CURATION_AMOUNT); + + // Curate first + vm.prank(CURATOR); + vault.curate(CREATOR_UID, usdt, amount, CONTENT_URI); + + // Create signature + uint256 expiry = block.timestamp + 1 hours; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, usdt, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, hash); + + vm.expectEmit(true, true, true, true); + emit Withdraw(RECIPIENT, CREATOR_UID, usdt, amount); + + vault.withdraw(RECIPIENT, CREATOR_UID, usdt, expiry, v, r, s); + + assertEq(vault.erc20Balances(CREATOR_UID, address(usdt)), 0); + assertEq(usdt.balanceOf(RECIPIENT), amount); + } + + function testNativeWithdrawal(uint256 amount) public { + vm.assume(amount > 0); + vm.assume(amount <= MAX_CURATION_AMOUNT); + + // Curate first + vm.prank(CURATOR); + vault.curate{value: amount}(CREATOR_UID, CONTENT_URI); + + // Create signature + uint256 expiry = block.timestamp + 1 hours; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, hash); + + vm.expectEmit(true, true, true, true); + emit Withdraw(RECIPIENT, CREATOR_UID, amount); + + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + + assertEq(vault.nativeBalances(CREATOR_UID), 0); + assertEq(RECIPIENT.balance, amount); + } + + function testCannotWithdrawExpired() public { + uint256 expiry = block.timestamp - 1; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, hash); + + vm.expectRevert(Expired.selector); + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + } + + function testCannotWithdrawInvalidSignature() public { + uint256 expiry = block.timestamp + 1 hours; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1234567, hash); // Wrong private key + + vm.expectRevert(InvalidSignature.selector); + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + } + + function testCannotWithdrawZeroAmount() public { + uint256 expiry = block.timestamp + 1 hours; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, hash); + + vm.expectRevert(ZeroAmount.selector); + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + } + + function testCannotWithdrawAlreadyWithdrawn() public { + // Curate first + uint256 amount = 1 ether; + vm.prank(CURATOR); + vault.curate{value: amount}(CREATOR_UID, CONTENT_URI); + + // Create signature + uint256 expiry = block.timestamp + 1 hours; + bytes32 hash = keccak256(abi.encodePacked(RECIPIENT, CREATOR_UID, expiry, address(vault))) + .toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PRIVATE_KEY, hash); + + vm.expectEmit(true, true, true, true); + emit Withdraw(RECIPIENT, CREATOR_UID, amount); + + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + + assertEq(vault.nativeBalances(CREATOR_UID), 0); + assertEq(RECIPIENT.balance, amount); + + // Try to withdraw again + vm.expectRevert(AlreadyWithdrawn.selector); + vault.withdraw(RECIPIENT, CREATOR_UID, expiry, v, r, s); + } + + function testSetSigner() public { + address newSigner = address(1234); + + vm.expectEmit(true, true, true, true); + emit SignerChanged(newSigner); + + vm.prank(OWNER); + vault.setSigner(newSigner); + + assertEq(vault.signer(), newSigner); + } + + function testCannotSetZeroSigner() public { + vm.expectRevert(ZeroAddress.selector); + vm.prank(OWNER); + vault.setSigner(address(0)); + } +}