diff --git a/README.md b/README.md index a63df3b2..789c1229 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Before contributing, please read our detailed [Contributing Guidelines](./CONTRI ### Community -Join our community on [Discord](https://discord.gg/tnBhVxSDDS) for questions and discussions. +Join our community on [Discord](https://discord.gg/NnPKaB2Qdr) for questions and discussions. Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful. diff --git a/audits/ImmuneFi-Audit-Report-OakNetwork-PaymentTreasury.pdf b/audits/ImmuneFi-Audit-Report-OakNetwork-PaymentTreasury.pdf new file mode 100644 index 00000000..76b0ed04 Binary files /dev/null and b/audits/ImmuneFi-Audit-Report-OakNetwork-PaymentTreasury.pdf differ diff --git a/docs/src/src/TestUSD.sol/contract.TestUSD.md b/docs/src/src/TestUSD.sol/contract.TestUSD.md index 0d401e69..011be3b1 100644 --- a/docs/src/src/TestUSD.sol/contract.TestUSD.md +++ b/docs/src/src/TestUSD.sol/contract.TestUSD.md @@ -1,9 +1,5 @@ # TestUSD -<<<<<<< Updated upstream -[Git Source](https://github.com/ccprotocol/reference-client-sc/blob/32b7b1617200d0c6f3248845ef972180411f1f65/src/TestUSD.sol) -======= [Git Source](https://github.com/ccprotocol/ccprotocol-contracts-internal/blob/4245ef0ad7914158999986aa0d8b5d2614efc6c2/src/TestUSD.sol) ->>>>>>> Stashed changes **Inherits:** [ERC20](/src/.deps/npm/@openzeppelin/contracts/token/ERC20/ERC20.sol/abstract.ERC20.md), [Ownable](/src/.deps/npm/@openzeppelin/contracts/access/Ownable.sol/abstract.Ownable.md) diff --git a/script/DeployAll.s.sol b/script/DeployAll.s.sol index c1bc4d54..c2c7fcca 100644 --- a/script/DeployAll.s.sol +++ b/script/DeployAll.s.sol @@ -80,7 +80,6 @@ contract DeployAll is DeployBase { // Prepare initialization data for CampaignInfoFactory bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployerAddress, IGlobalParams(address(globalParamsProxy)), address(campaignInfoImplementation), address(treasuryFactoryProxy) @@ -90,6 +89,9 @@ contract DeployAll is DeployBase { ERC1967Proxy campaignFactoryProxy = new ERC1967Proxy(address(campaignFactoryImpl), campaignFactoryInitData); console2.log("CampaignInfoFactory proxy deployed at:", address(campaignFactoryProxy)); + // Wire CampaignInfoFactory into TreasuryFactory so deploy() validation passes + TreasuryFactory(address(treasuryFactoryProxy)).setCampaignInfoFactory(address(campaignFactoryProxy)); + // Configure registry values uint256 bufferTime = vm.envOr("BUFFER_TIME", uint256(0)); uint256 campaignLaunchBuffer = vm.envOr("CAMPAIGN_LAUNCH_BUFFER", uint256(0)); diff --git a/script/DeployAllAndSetupAllOrNothing.s.sol b/script/DeployAllAndSetupAllOrNothing.s.sol index 7f340905..377d6f30 100644 --- a/script/DeployAllAndSetupAllOrNothing.s.sol +++ b/script/DeployAllAndSetupAllOrNothing.s.sol @@ -202,7 +202,6 @@ contract DeployAllAndSetupAllOrNothing is DeployBase { campaignInfoFactoryImplementation = address(campaignFactoryImpl); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployerAddress, IGlobalParams(globalParams), campaignInfoImplementation, treasuryFactory @@ -216,6 +215,14 @@ contract DeployAllAndSetupAllOrNothing is DeployBase { console2.log("Reusing CampaignInfoFactory at:", campaignInfoFactory); } + // Wire CampaignInfoFactory into TreasuryFactory so deploy() validation passes. + // Must run when either contract is freshly deployed: a new TF needs initial wiring, + // and a new CIF deployed against a reused TF must update the existing proxy. + if (treasuryFactoryDeployed || campaignInfoFactoryDeployed) { + TreasuryFactory(treasuryFactory).setCampaignInfoFactory(campaignInfoFactory); + console2.log("CampaignInfoFactory wired into TreasuryFactory"); + } + // Deploy or reuse AllOrNothing implementation if (allOrNothingImplementation == address(0)) { allOrNothingImplementation = address(new AllOrNothing()); @@ -350,13 +357,7 @@ contract DeployAllAndSetupAllOrNothing is DeployBase { console2.log("Transferring protocol admin rights to:", finalProtocolAdmin); GlobalParams(globalParams).updateProtocolAdminAddress(finalProtocolAdmin); - //Transfer admin rights to the final protocol admin - GlobalParams(globalParams).transferOwnership(finalProtocolAdmin); - console2.log("GlobalParams transferred to:", finalProtocolAdmin); - if (campaignInfoFactoryDeployed) { - CampaignInfoFactory(campaignInfoFactory).transferOwnership(finalProtocolAdmin); - console2.log("CampaignInfoFactory transferred to:", finalProtocolAdmin); - } + // CampaignInfoFactory reads admin from GlobalParams, no separate transfer needed } if (simulate) { @@ -428,8 +429,7 @@ contract DeployAllAndSetupAllOrNothing is DeployBase { console2.log("Protocol Admin:", finalProtocolAdmin); console2.log("Platform Admin:", finalPlatformAdmin); console2.log("Platform Adapter (Trusted Forwarder):", platformAdapter); - console2.log("GlobalParams owner:", GlobalParams(globalParams).owner()); - console2.log("CampaignInfoFactory owner:", CampaignInfoFactory(campaignInfoFactory).owner()); + console2.log("Protocol Admin (GlobalParams):", GlobalParams(globalParams).getProtocolAdminAddress()); console2.log("\n--- Supported Currencies & Tokens ---"); string memory currenciesConfig = vm.envOr("CURRENCIES", string("")); diff --git a/script/DeployAllAndSetupKeepWhatsRaised.s.sol b/script/DeployAllAndSetupKeepWhatsRaised.s.sol index fe349c06..0568164f 100644 --- a/script/DeployAllAndSetupKeepWhatsRaised.s.sol +++ b/script/DeployAllAndSetupKeepWhatsRaised.s.sol @@ -198,7 +198,6 @@ contract DeployAllAndSetupKeepWhatsRaised is DeployBase { campaignInfoFactoryImplementation = address(campaignFactoryImpl); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployerAddress, IGlobalParams(globalParams), campaignInfo, treasuryFactory @@ -212,6 +211,14 @@ contract DeployAllAndSetupKeepWhatsRaised is DeployBase { console2.log("Reusing CampaignInfoFactory at:", campaignInfoFactory); } + // Wire CampaignInfoFactory into TreasuryFactory so deploy() validation passes. + // Must run when either contract is freshly deployed: a new TF needs initial wiring, + // and a new CIF deployed against a reused TF must update the existing proxy. + if (treasuryFactoryDeployed || campaignInfoFactoryDeployed) { + TreasuryFactory(treasuryFactory).setCampaignInfoFactory(campaignInfoFactory); + console2.log("CampaignInfoFactory wired into TreasuryFactory"); + } + // Deploy or reuse KeepWhatsRaised implementation if (keepWhatsRaisedImplementation == address(0)) { keepWhatsRaisedImplementation = address(new KeepWhatsRaised()); @@ -346,13 +353,7 @@ contract DeployAllAndSetupKeepWhatsRaised is DeployBase { console2.log("Transferring protocol admin rights to:", finalProtocolAdmin); GlobalParams(globalParams).updateProtocolAdminAddress(finalProtocolAdmin); - //Transfer admin rights to the final protocol admin - GlobalParams(globalParams).transferOwnership(finalProtocolAdmin); - console2.log("GlobalParams transferred to:", finalProtocolAdmin); - if (campaignInfoFactoryDeployed) { - CampaignInfoFactory(campaignInfoFactory).transferOwnership(finalProtocolAdmin); - console2.log("CampaignInfoFactory transferred to:", finalProtocolAdmin); - } + // CampaignInfoFactory reads admin from GlobalParams, no separate transfer needed } if (simulate) { @@ -424,8 +425,7 @@ contract DeployAllAndSetupKeepWhatsRaised is DeployBase { console2.log("Protocol Admin:", finalProtocolAdmin); console2.log("Platform Admin:", finalPlatformAdmin); console2.log("Platform Adapter (Trusted Forwarder):", platformAdapter); - console2.log("GlobalParams owner:", GlobalParams(globalParams).owner()); - console2.log("CampaignInfoFactory owner:", CampaignInfoFactory(campaignInfoFactory).owner()); + console2.log("Protocol Admin (GlobalParams):", GlobalParams(globalParams).getProtocolAdminAddress()); console2.log("\n--- Supported Currencies & Tokens ---"); string memory currenciesConfig = vm.envOr("CURRENCIES", string("")); diff --git a/script/DeployAllAndSetupPaymentTreasury.s.sol b/script/DeployAllAndSetupPaymentTreasury.s.sol index 5810e1fb..bd9e5b98 100644 --- a/script/DeployAllAndSetupPaymentTreasury.s.sol +++ b/script/DeployAllAndSetupPaymentTreasury.s.sol @@ -233,7 +233,6 @@ contract DeployAllAndSetupPaymentTreasury is DeployBase { campaignInfoFactoryImplementation = address(campaignFactoryImpl); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployerAddress, IGlobalParams(globalParams), campaignInfoImplementation, treasuryFactory @@ -247,6 +246,14 @@ contract DeployAllAndSetupPaymentTreasury is DeployBase { console2.log("Reusing CampaignInfoFactory at:", campaignInfoFactory); } + // Wire CampaignInfoFactory into TreasuryFactory so deploy() validation passes. + // Must run when either contract is freshly deployed: a new TF needs initial wiring, + // and a new CIF deployed against a reused TF must update the existing proxy. + if (treasuryFactoryDeployed || campaignInfoFactoryDeployed) { + TreasuryFactory(treasuryFactory).setCampaignInfoFactory(campaignInfoFactory); + console2.log("CampaignInfoFactory wired into TreasuryFactory"); + } + // Deploy or reuse PaymentTreasury implementation if (paymentTreasuryImplementation == address(0)) { paymentTreasuryImplementation = address(new PaymentTreasury()); @@ -381,13 +388,7 @@ contract DeployAllAndSetupPaymentTreasury is DeployBase { console2.log("Transferring protocol admin rights to:", finalProtocolAdmin); GlobalParams(globalParams).updateProtocolAdminAddress(finalProtocolAdmin); - //Transfer admin rights to the final protocol admin - GlobalParams(globalParams).transferOwnership(finalProtocolAdmin); - console2.log("GlobalParams transferred to:", finalProtocolAdmin); - if (campaignInfoFactoryDeployed) { - CampaignInfoFactory(campaignInfoFactory).transferOwnership(finalProtocolAdmin); - console2.log("CampaignInfoFactory transferred to:", finalProtocolAdmin); - } + // CampaignInfoFactory reads admin from GlobalParams, no separate transfer needed } if (simulate) { @@ -460,8 +461,7 @@ contract DeployAllAndSetupPaymentTreasury is DeployBase { console2.log("Protocol Admin:", finalProtocolAdmin); console2.log("Platform Admin:", finalPlatformAdmin); console2.log("Platform Adapter (Trusted Forwarder):", platformAdapter); - console2.log("GlobalParams owner:", GlobalParams(globalParams).owner()); - console2.log("CampaignInfoFactory owner:", CampaignInfoFactory(campaignInfoFactory).owner()); + console2.log("Protocol Admin (GlobalParams):", GlobalParams(globalParams).getProtocolAdminAddress()); console2.log("\n--- Supported Currencies & Tokens ---"); string memory currenciesConfig = vm.envOr("CURRENCIES", string("")); diff --git a/script/DeployAllAndSetupTimeConstrainedPaymentTreasury.s.sol b/script/DeployAllAndSetupTimeConstrainedPaymentTreasury.s.sol index d15e1393..baf9fc38 100644 --- a/script/DeployAllAndSetupTimeConstrainedPaymentTreasury.s.sol +++ b/script/DeployAllAndSetupTimeConstrainedPaymentTreasury.s.sol @@ -234,7 +234,6 @@ contract DeployAllAndSetupTimeConstrainedPaymentTreasury is DeployBase { campaignInfoFactoryImplementation = address(campaignFactoryImpl); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployerAddress, IGlobalParams(globalParams), campaignInfoImplementation, treasuryFactory @@ -248,6 +247,14 @@ contract DeployAllAndSetupTimeConstrainedPaymentTreasury is DeployBase { console2.log("Reusing CampaignInfoFactory at:", campaignInfoFactory); } + // Wire CampaignInfoFactory into TreasuryFactory so deploy() validation passes. + // Must run when either contract is freshly deployed: a new TF needs initial wiring, + // and a new CIF deployed against a reused TF must update the existing proxy. + if (treasuryFactoryDeployed || campaignInfoFactoryDeployed) { + TreasuryFactory(treasuryFactory).setCampaignInfoFactory(campaignInfoFactory); + console2.log("CampaignInfoFactory wired into TreasuryFactory"); + } + // Deploy or reuse TimeConstrainedPaymentTreasury implementation if (timeConstrainedPaymentTreasuryImplementation == address(0)) { timeConstrainedPaymentTreasuryImplementation = address(new TimeConstrainedPaymentTreasury()); @@ -388,13 +395,7 @@ contract DeployAllAndSetupTimeConstrainedPaymentTreasury is DeployBase { console2.log("Transferring protocol admin rights to:", finalProtocolAdmin); GlobalParams(globalParams).updateProtocolAdminAddress(finalProtocolAdmin); - //Transfer admin rights to the final protocol admin - GlobalParams(globalParams).transferOwnership(finalProtocolAdmin); - console2.log("GlobalParams transferred to:", finalProtocolAdmin); - if (campaignInfoFactoryDeployed) { - CampaignInfoFactory(campaignInfoFactory).transferOwnership(finalProtocolAdmin); - console2.log("CampaignInfoFactory transferred to:", finalProtocolAdmin); - } + // CampaignInfoFactory reads admin from GlobalParams, no separate transfer needed } if (simulate) { @@ -467,8 +468,7 @@ contract DeployAllAndSetupTimeConstrainedPaymentTreasury is DeployBase { console2.log("Protocol Admin:", finalProtocolAdmin); console2.log("Platform Admin:", finalPlatformAdmin); console2.log("Platform Adapter (Trusted Forwarder):", platformAdapter); - console2.log("GlobalParams owner:", GlobalParams(globalParams).owner()); - console2.log("CampaignInfoFactory owner:", CampaignInfoFactory(campaignInfoFactory).owner()); + console2.log("Protocol Admin (GlobalParams):", GlobalParams(globalParams).getProtocolAdminAddress()); console2.log("\n--- Supported Currencies & Tokens ---"); string memory currenciesConfig = vm.envOr("CURRENCIES", string("")); diff --git a/script/DeployCampaignInfoFactory.s.sol b/script/DeployCampaignInfoFactory.s.sol index c2c5b0d0..88e1bbcb 100644 --- a/script/DeployCampaignInfoFactory.s.sol +++ b/script/DeployCampaignInfoFactory.s.sol @@ -28,7 +28,6 @@ contract DeployCampaignInfoFactory is DeployBase { // Prepare initialization data bytes memory initData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - deployer, IGlobalParams(globalParams), campaignInfo, treasuryFactory diff --git a/script/UpgradeTreasuryFactory.s.sol b/script/UpgradeTreasuryFactory.s.sol index f8859700..8a504e4c 100644 --- a/script/UpgradeTreasuryFactory.s.sol +++ b/script/UpgradeTreasuryFactory.s.sol @@ -14,8 +14,10 @@ contract UpgradeTreasuryFactory is Script { function run() external { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); address proxyAddress = vm.envAddress("TREASURY_FACTORY_ADDRESS"); + address campaignInfoFactoryAddress = vm.envAddress("CAMPAIGN_INFO_FACTORY_ADDRESS"); require(proxyAddress != address(0), "Proxy address must be set"); + require(campaignInfoFactoryAddress != address(0), "CAMPAIGN_INFO_FACTORY_ADDRESS must be set"); vm.startBroadcast(deployerKey); @@ -31,6 +33,10 @@ contract UpgradeTreasuryFactory is Script { console2.log("Proxy address:", proxyAddress); console2.log("New implementation address:", address(newImplementation)); + // Wire in CampaignInfoFactory so the new validation guard does not brick deploy() + proxy.setCampaignInfoFactory(campaignInfoFactoryAddress); + console2.log("CampaignInfoFactory wired into TreasuryFactory:", campaignInfoFactoryAddress); + vm.stopBroadcast(); } } diff --git a/src/CampaignInfo.sol b/src/CampaignInfo.sol index cd650eb1..177cbbf9 100644 --- a/src/CampaignInfo.sol +++ b/src/CampaignInfo.sol @@ -17,6 +17,7 @@ import {PausableCancellable} from "./utils/PausableCancellable.sol"; import {PledgeNFT} from "./utils/PledgeNFT.sol"; import {Counters} from "./utils/Counters.sol"; import {DataRegistryKeys} from "./constants/DataRegistryKeys.sol"; +import {ProtocolErrors} from "./errors/ProtocolErrors.sol"; /** * @title CampaignInfo @@ -34,6 +35,15 @@ contract CampaignInfo is { using Counters for Counters.Counter; + /** + * @dev Struct to hold campaign configuration information. + */ + struct Config { + address treasuryFactory; + uint256 protocolFeePercent; + bytes32 identifierHash; + } + CampaignData private s_campaignData; mapping(bytes32 => address) private s_platformTreasuryAddress; @@ -46,7 +56,7 @@ contract CampaignInfo is // Multi-token support address[] private s_acceptedTokens; // Accepted tokens for this campaign - mapping(address => bool) private s_isAcceptedToken; // O(1) token validation + mapping(address => bool) private s_isAcceptedToken; // Tracks whether a specific ERC20 token is accepted for this campaign, allowing O(1) validation during pledges. // Lock mechanism - prevents certain operations after treasury deployment bool private s_isLocked; @@ -109,8 +119,9 @@ contract CampaignInfo is /** * @dev Emitted when an invalid input is detected. + * @param code Which validation failed. */ - error CampaignInfoInvalidInput(); + error CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput code); /** * @dev Emitted when a platform is not selected for the campaign. @@ -129,6 +140,13 @@ contract CampaignInfo is */ error CampaignInfoIsLocked(); + /** + * @dev Throws when a platform data key is not owned by the platform being updated. + * @param platformHash The platform being updated. + * @param platformDataKey The key that does not belong to this platform. + */ + error CampaignInfoPlatformDataKeyNotOwnedByPlatform(bytes32 platformHash, bytes32 platformDataKey); + /** * @dev Modifier that checks if the campaign is not locked. */ @@ -142,7 +160,7 @@ contract CampaignInfo is /** * @notice Constructor passes empty strings to ERC721 */ - constructor() Ownable(_msgSender()) ERC721("", "") { + constructor() Ownable(msg.sender) ERC721("", "") { _disableInitializers(); } @@ -164,34 +182,34 @@ contract CampaignInfo is s_campaignData = campaignData; // Store accepted tokens - uint256 tokenLen = acceptedTokens.length; - for (uint256 i = 0; i < tokenLen; ++i) { + uint256 len = acceptedTokens.length; + for (uint256 i = 0; i < len;) { address token = acceptedTokens[i]; + if (s_isAcceptedToken[token]) { + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.DUPLICATE_ACCEPTED_TOKEN); + } s_acceptedTokens.push(token); s_isAcceptedToken[token] = true; + unchecked { ++i; } } - uint256 len = selectedPlatformHash.length; - for (uint256 i = 0; i < len; ++i) { + len = selectedPlatformHash.length; + for (uint256 i = 0; i < len;) { s_platformFeePercent[selectedPlatformHash[i]] = _getGlobalParams().getPlatformFeePercent(selectedPlatformHash[i]); s_isSelectedPlatform[selectedPlatformHash[i]] = true; + unchecked { ++i; } } len = platformDataKey.length; - for (uint256 i = 0; i < len; ++i) { + for (uint256 i = 0; i < len;) { s_platformData[platformDataKey[i]] = platformDataValue[i]; + unchecked { ++i; } } // Initialize NFT metadata _initializeNFT(nftName, nftSymbol, nftImageURI, nftContractURI); } - struct Config { - address treasuryFactory; - uint256 protocolFeePercent; - bytes32 identifierHash; - } - function getCampaignConfig() public view returns (Config memory config) { bytes memory args = Clones.fetchCloneArgs(address(this)); (config.treasuryFactory, config.protocolFeePercent, config.identifierHash) = @@ -231,15 +249,14 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalRaisedAmount() external view override returns (uint256) { + function getTotalRaisedAmount() external view override returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; // Skip cancelled treasuries - if (!ICampaignTreasury(tempTreasury).cancelled()) { + if (!PausableCancellable(tempTreasury).cancelled()) { amount += ICampaignTreasury(tempTreasury).getRaisedAmount(); } } @@ -249,10 +266,9 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalLifetimeRaisedAmount() external view returns (uint256) { + function getTotalLifetimeRaisedAmount() external view returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; @@ -264,10 +280,9 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalRefundedAmount() external view returns (uint256) { + function getTotalRefundedAmount() external view returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; @@ -279,10 +294,9 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalAvailableRaisedAmount() external view returns (uint256) { + function getTotalAvailableRaisedAmount() external view returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; @@ -294,15 +308,14 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalCancelledAmount() external view returns (uint256) { + function getTotalCancelledAmount() external view returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; // Only include cancelled treasuries - if (ICampaignTreasury(tempTreasury).cancelled()) { + if (PausableCancellable(tempTreasury).cancelled()) { amount += ICampaignTreasury(tempTreasury).getRaisedAmount(); } } @@ -312,10 +325,9 @@ contract CampaignInfo is /** * @inheritdoc ICampaignInfo */ - function getTotalExpectedAmount() external view returns (uint256) { + function getTotalExpectedAmount() external view returns (uint256 amount) { bytes32[] memory tempPlatforms = s_approvedPlatformHashes; uint256 length = s_approvedPlatformHashes.length; - uint256 amount; address tempTreasury; for (uint256 i = 0; i < length; i++) { tempTreasury = s_platformTreasuryAddress[tempPlatforms[i]]; @@ -336,6 +348,13 @@ contract CampaignInfo is return _getGlobalParams().getPlatformAdminAddress(platformHash); } + /** + * @inheritdoc ICampaignInfo + */ + function getPlatformAdapter(bytes32 platformHash) external view override returns (address) { + return _getGlobalParams().getPlatformAdapter(platformHash); + } + /** * @inheritdoc ICampaignInfo */ @@ -393,13 +412,6 @@ contract CampaignInfo is return super.paused(); } - /** - * @inheritdoc ICampaignInfo - */ - function cancelled() public view override(ICampaignInfo, PausableCancellable) returns (bool) { - return super.cancelled(); - } - /** * @inheritdoc ICampaignInfo */ @@ -420,7 +432,7 @@ contract CampaignInfo is function getPlatformData(bytes32 platformDataKey) external view override returns (bytes32) { bytes32 platformDataValue = s_platformData[platformDataKey]; if (platformDataValue == bytes32(0)) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_DATA_NOT_SET); } return platformDataValue; } @@ -448,6 +460,13 @@ contract CampaignInfo is bufferTime = uint256(valueBytes); } + /** + * @inheritdoc ICampaignInfo + */ + function getPermit2Address() external view override returns (address) { + return _getGlobalParams().getPermit2Address(); + } + /** * @inheritdoc ICampaignInfo */ @@ -487,18 +506,23 @@ contract CampaignInfo is external override onlyOwner + currentTimeIsLess(getLaunchTime()) whenNotPaused whenNotCancelled whenNotLocked { uint256 deadline = getDeadline(); + uint256 campaignLaunchBuffer = + uint256(_getGlobalParams().getFromRegistry(DataRegistryKeys.CAMPAIGN_LAUNCH_BUFFER)); uint256 minimumCampaignDuration = uint256(_getGlobalParams().getFromRegistry(DataRegistryKeys.MINIMUM_CAMPAIGN_DURATION)); - // Ensure launch time is not in the past and deadline still meets minimum duration requirement - // Allow moving launch time closer to current time as long as minimum duration is maintained - if (launchTime < block.timestamp || deadline <= launchTime || deadline < launchTime + minimumCampaignDuration) { - revert CampaignInfoInvalidInput(); + // Keep launch-time updates aligned with the same creation-time buffer and duration checks. + if ( + launchTime < block.timestamp + campaignLaunchBuffer || deadline <= launchTime + || deadline < launchTime + minimumCampaignDuration + ) { + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.INVALID_LAUNCH_TIME); } s_campaignData.launchTime = launchTime; @@ -512,6 +536,7 @@ contract CampaignInfo is external override onlyOwner + currentTimeIsLess(getLaunchTime()) whenNotPaused whenNotCancelled whenNotLocked @@ -520,8 +545,8 @@ contract CampaignInfo is uint256 minimumCampaignDuration = uint256(_getGlobalParams().getFromRegistry(DataRegistryKeys.MINIMUM_CAMPAIGN_DURATION)); - if (deadline <= launchTime || deadline < launchTime + minimumCampaignDuration) { - revert CampaignInfoInvalidInput(); + if (deadline < launchTime + minimumCampaignDuration) { + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.INVALID_DEADLINE); } s_campaignData.deadline = deadline; @@ -535,12 +560,13 @@ contract CampaignInfo is external override onlyOwner + currentTimeIsLess(getLaunchTime()) whenNotPaused whenNotCancelled whenNotLocked { if (goalAmount == 0) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.ZERO_GOAL_AMOUNT); } s_campaignData.goalAmount = goalAmount; emit CampaignInfoGoalAmountUpdated(goalAmount); @@ -556,7 +582,7 @@ contract CampaignInfo is bytes32[] calldata platformDataValue ) external override onlyOwner currentTimeIsLess(getLaunchTime()) whenNotPaused whenNotCancelled { if (checkIfPlatformSelected(platformHash) == selection) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_SELECTION_UNCHANGED); } IGlobalParams globalParams = _getGlobalParams(); @@ -568,7 +594,7 @@ contract CampaignInfo is revert CampaignInfoPlatformAlreadyApproved(platformHash); } if (platformDataKey.length != platformDataValue.length) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_DATA_LENGTH_MISMATCH); } if (selection) { @@ -576,10 +602,13 @@ contract CampaignInfo is for (uint256 i = 0; i < platformDataKey.length; i++) { isValid = globalParams.checkIfPlatformDataKeyValid(platformDataKey[i]); if (!isValid) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.INVALID_PLATFORM_DATA_KEY); + } + if (globalParams.getPlatformDataOwner(platformDataKey[i]) != platformHash) { + revert CampaignInfoPlatformDataKeyNotOwnedByPlatform(platformHash, platformDataKey[i]); } if (platformDataValue[i] == bytes32(0)) { - revert CampaignInfoInvalidInput(); + revert CampaignInfoInvalidInput(ProtocolErrors.CampaignInfoInvalidInput.ZERO_PLATFORM_DATA_VALUE); } s_platformData[platformDataKey[i]] = platformDataValue[i]; @@ -599,22 +628,22 @@ contract CampaignInfo is /** * @dev External function to pause the campaign. */ - function _pauseCampaign(bytes32 message) external onlyProtocolAdmin { + function pauseCampaign(bytes32 message) external onlyProtocolAdmin { _pause(message); } /** * @dev External function to unpause the campaign. */ - function _unpauseCampaign(bytes32 message) external onlyProtocolAdmin { + function unpauseCampaign(bytes32 message) external onlyProtocolAdmin { _unpause(message); } /** * @dev External function to cancel the campaign. */ - function _cancelCampaign(bytes32 message) external { - if (_msgSender() != getProtocolAdminAddress() && _msgSender() != owner()) { + function cancelCampaign(bytes32 message) external { + if (msg.sender != getProtocolAdminAddress() && msg.sender != owner()) { revert CampaignInfoUnauthorized(); } _cancel(message); @@ -631,6 +660,7 @@ contract CampaignInfo is onlyOwner currentTimeIsLess(getLaunchTime()) { + _validateJsonString(newImageURI); s_imageURI = newImageURI; emit ImageURIUpdated(newImageURI); } @@ -661,6 +691,10 @@ contract CampaignInfo is return super.mintNFTForPledge(backer, reward, tokenAddress, amount, shippingFee, tipAmount); } + /** + * @inheritdoc ICampaignInfo + * @dev Override required: ICampaignInfo and PledgeNFT both define burn(); forwards to PledgeNFT implementation. + */ function burn(uint256 tokenId) public override(ICampaignInfo, PledgeNFT) { super.burn(tokenId); } @@ -670,9 +704,14 @@ contract CampaignInfo is * @param platformHash The bytes32 identifier of the platform. * @param platformTreasuryAddress The address of the platform's treasury. */ - function _setPlatformInfo(bytes32 platformHash, address platformTreasuryAddress) external whenNotPaused { + function setPlatformInfo(bytes32 platformHash, address platformTreasuryAddress) + external + whenNotPaused + whenNotCancelled + currentTimeIsLess(getDeadline()) + { Config memory config = getCampaignConfig(); - if (_msgSender() != config.treasuryFactory) { + if (msg.sender != config.treasuryFactory) { revert CampaignInfoUnauthorized(); } bool selected = checkIfPlatformSelected(platformHash); @@ -686,8 +725,8 @@ contract CampaignInfo is s_approvedPlatformHashes.push(platformHash); s_isApprovedPlatform[platformHash] = true; - // Grant MINTER_ROLE to allow treasury to mint pledge NFTs - _grantRole(MINTER_ROLE, platformTreasuryAddress); + // Grant TREASURY_ROLE to allow treasury to mint and burn pledge NFTs + _grantRole(TREASURY_ROLE, platformTreasuryAddress); // Lock the campaign after the first treasury deployment if (!s_isLocked) { s_isLocked = true; diff --git a/src/CampaignInfoFactory.sol b/src/CampaignInfoFactory.sol index c29acd1a..41001954 100644 --- a/src/CampaignInfoFactory.sol +++ b/src/CampaignInfoFactory.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.22; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; @@ -10,17 +9,28 @@ import {IGlobalParams} from "./interfaces/IGlobalParams.sol"; import {ICampaignInfoFactory} from "./interfaces/ICampaignInfoFactory.sol"; import {CampaignInfoFactoryStorage} from "./storage/CampaignInfoFactoryStorage.sol"; import {DataRegistryKeys} from "./constants/DataRegistryKeys.sol"; +import {ProtocolErrors} from "./errors/ProtocolErrors.sol"; /** * @title CampaignInfoFactory * @notice Factory contract for creating campaign information contracts. * @dev UUPS Upgradeable contract with ERC-7201 namespaced storage */ -contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgradeable, UUPSUpgradeable { +contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, UUPSUpgradeable { /** * @dev Emitted when invalid input is provided. + * @param code Which validation failed. */ - error CampaignInfoFactoryInvalidInput(); + error CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput code); + + /// @dev Reverts when the caller is not the protocol admin. + error CampaignInfoFactoryUnauthorized(); + /// @dev Reverts when globalParams is the zero address. + error CampaignInfoFactoryZeroGlobalParams(); + /// @dev Reverts when campaignImplementation is the zero address. + error CampaignInfoFactoryZeroCampaignImplementation(); + /// @dev Reverts when treasuryFactoryAddress is the zero address. + error CampaignInfoFactoryZeroTreasuryFactoryAddress(); /** * @dev Emitted when campaign creation fails. @@ -34,6 +44,14 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr */ error CampaignInfoInvalidTokenList(); + modifier onlyProtocolAdmin() { + CampaignInfoFactoryStorage.Storage storage $ = CampaignInfoFactoryStorage._getCampaignInfoFactoryStorage(); + if (msg.sender != $.globalParams.getProtocolAdminAddress()) { + revert CampaignInfoFactoryUnauthorized(); + } + _; + } + /** * @dev Constructor that disables initializers to prevent implementation contract initialization */ @@ -43,25 +61,19 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr /** * @notice Initializes the CampaignInfoFactory contract. - * @param initialOwner The address that will own the factory * @param globalParams The address of the global parameters contract. * @param campaignImplementation The address of the campaign implementation contract. * @param treasuryFactoryAddress The address of the treasury factory contract. */ function initialize( - address initialOwner, IGlobalParams globalParams, address campaignImplementation, address treasuryFactoryAddress ) public initializer { - if ( - address(globalParams) == address(0) || campaignImplementation == address(0) - || treasuryFactoryAddress == address(0) || initialOwner == address(0) - ) { - revert CampaignInfoFactoryInvalidInput(); - } + if (address(globalParams) == address(0)) revert CampaignInfoFactoryZeroGlobalParams(); + if (campaignImplementation == address(0)) revert CampaignInfoFactoryZeroCampaignImplementation(); + if (treasuryFactoryAddress == address(0)) revert CampaignInfoFactoryZeroTreasuryFactoryAddress(); - __Ownable_init(initialOwner); __UUPSUpgradeable_init(); CampaignInfoFactoryStorage.Storage storage $ = CampaignInfoFactoryStorage._getCampaignInfoFactoryStorage(); @@ -74,7 +86,7 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr * @dev Function that authorizes an upgrade to a new implementation * @param newImplementation Address of the new implementation */ - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function _authorizeUpgrade(address newImplementation) internal override onlyProtocolAdmin {} /** * @inheritdoc ICampaignInfoFactory @@ -103,10 +115,10 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr string calldata contractURI ) external override { if (creator == address(0)) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.ZERO_CREATOR); } if (platformDataKey.length != platformDataValue.length) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.PLATFORM_DATA_LENGTH_MISMATCH); } CampaignInfoFactoryStorage.Storage storage $ = CampaignInfoFactoryStorage._getCampaignInfoFactoryStorage(); @@ -121,20 +133,20 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr // Validate campaign timing constraints if (campaignData.launchTime < block.timestamp + campaignLaunchBuffer) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.LAUNCH_TIME_TOO_SOON); } if (campaignData.deadline < campaignData.launchTime + minimumCampaignDuration) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.DEADLINE_TOO_SOON); } bool isValid; for (uint256 i = 0; i < platformDataKey.length; i++) { isValid = globalParams.checkIfPlatformDataKeyValid(platformDataKey[i]); if (!isValid) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.INVALID_PLATFORM_DATA_KEY); } if (platformDataValue[i] == bytes32(0)) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.ZERO_PLATFORM_DATA_VALUE); } } address cloneExists = $.identifierToCampaignInfo[identifierHash]; @@ -189,12 +201,13 @@ contract CampaignInfoFactory is Initializable, ICampaignInfoFactory, OwnableUpgr /** * @inheritdoc ICampaignInfoFactory */ - function updateImplementation(address newImplementation) external override onlyOwner { + function updateImplementation(address newImplementation) external override onlyProtocolAdmin { if (newImplementation == address(0)) { - revert CampaignInfoFactoryInvalidInput(); + revert CampaignInfoFactoryInvalidInput(ProtocolErrors.CampaignInfoFactoryInvalidInput.ZERO_IMPLEMENTATION); } CampaignInfoFactoryStorage.Storage storage $ = CampaignInfoFactoryStorage._getCampaignInfoFactoryStorage(); $.implementation = newImplementation; + emit CampaignInfoFactoryImplementationUpdated(newImplementation); } /** diff --git a/src/GlobalParams.sol b/src/GlobalParams.sol index 8c6078ab..c8e4ad0d 100644 --- a/src/GlobalParams.sol +++ b/src/GlobalParams.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.22; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {IGlobalParams} from "./interfaces/IGlobalParams.sol"; +import {ProtocolErrors} from "./errors/ProtocolErrors.sol"; import {Counters} from "./utils/Counters.sol"; import {GlobalParamsStorage} from "./storage/GlobalParamsStorage.sol"; @@ -14,11 +14,17 @@ import {GlobalParamsStorage} from "./storage/GlobalParamsStorage.sol"; * @notice Manages global parameters and platform information. * @dev UUPS Upgradeable contract with ERC-7201 namespaced storage */ -contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSUpgradeable { +contract GlobalParams is Initializable, IGlobalParams, UUPSUpgradeable { using Counters for Counters.Counter; bytes32 private constant ZERO_BYTES = 0x0000000000000000000000000000000000000000000000000000000000000000; + /// @dev The canonical Permit2 deployment address (same on all EVM chains). + address private constant PERMIT2_ADDRESS = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + /// @dev 100% in basis points; fee percentages must not exceed this and their sum must be below it. + uint256 private constant PERCENT_DIVIDER = 10000; + /** * @dev Emitted when a platform is enlisted. * @param platformHash The identifier of the enlisted platform. @@ -125,9 +131,10 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU event PlatformLineItemTypeRemoved(bytes32 indexed platformHash, bytes32 indexed typeId); /** - * @dev Throws when the input address is zero. + * @dev Throws when input validation fails. + * @param code Which validation failed. */ - error GlobalParamsInvalidInput(); + error GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput code); /** * @dev Throws when the platform is not listed. @@ -198,6 +205,16 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU */ error GlobalParamsPlatformLineItemTypeNotFound(bytes32 platformHash, bytes32 typeId); + /** + * @dev Throws when a fee percentage exceeds the maximum allowed (PERCENT_DIVIDER / 100%). + */ + error GlobalParamsFeePercentExceedsMax(); + + /** + * @dev Throws when the sum of protocol and platform fee percentages would exceed 100%. + */ + error GlobalParamsCombinedFeesExceedMax(); + /** * @dev Reverts if the input address is zero. */ @@ -223,6 +240,11 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU _; } + modifier onlyProtocolAdmin() { + _onlyProtocolAdmin(); + _; + } + /** * @dev Constructor that disables initializers to prevent implementation contract initialization */ @@ -243,9 +265,14 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU bytes32[] memory currencies, address[][] memory tokensPerCurrency ) public initializer { - __Ownable_init(protocolAdminAddress); __UUPSUpgradeable_init(); + _revertIfAddressZero(protocolAdminAddress); + + if (protocolFeePercent > PERCENT_DIVIDER) { + revert GlobalParamsFeePercentExceedsMax(); + } + GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); $.protocolAdminAddress = protocolAdminAddress; $.protocolFeePercent = protocolFeePercent; @@ -256,19 +283,13 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU revert GlobalParamsCurrencyTokenLengthMismatch(); } - for (uint256 i = 0; i < currencyLength;) { - for (uint256 j = 0; j < tokensPerCurrency[i].length;) { + for (uint256 i = 0; i < currencyLength; i++) { + for (uint256 j = 0; j < tokensPerCurrency[i].length; j++) { address token = tokensPerCurrency[i][j]; if (token == address(0)) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_TOKEN); } $.currencyToTokens[currencies[i]].push(token); - unchecked { - ++j; - } - } - unchecked { - ++i; } } } @@ -277,16 +298,16 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU * @dev Function that authorizes an upgrade to a new implementation * @param newImplementation Address of the new implementation */ - function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + function _authorizeUpgrade(address newImplementation) internal override onlyProtocolAdmin {} /** * @notice Adds a key-value pair to the data registry. * @param key The registry key. * @param value The registry value. */ - function addToRegistry(bytes32 key, bytes32 value) external onlyOwner { + function addToRegistry(bytes32 key, bytes32 value) external onlyProtocolAdmin { if (key == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_REGISTRY_KEY); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); $.dataRegistry[key] = value; @@ -303,6 +324,13 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU value = $.dataRegistry[key]; } + /** + * @inheritdoc IGlobalParams + */ + function getPermit2Address() external pure returns (address) { + return PERMIT2_ADDRESS; + } + /** * @inheritdoc IGlobalParams */ @@ -409,11 +437,17 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU address platformAdminAddress, uint256 platformFeePercent, address platformAdapter - ) external onlyOwner notAddressZero(platformAdminAddress) { + ) external onlyProtocolAdmin notAddressZero(platformAdminAddress) { if (platformHash == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_PLATFORM_HASH); + } + if (platformFeePercent > PERCENT_DIVIDER) { + revert GlobalParamsFeePercentExceedsMax(); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); + if ($.protocolFeePercent + platformFeePercent > PERCENT_DIVIDER) { + revert GlobalParamsCombinedFeesExceedMax(); + } if ($.platformIsListed[platformHash]) { revert GlobalParamsPlatformAlreadyListed(platformHash); } else { @@ -433,11 +467,12 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU * @notice Delists a platform. * @param platformHash The platform's identifier. */ - function delistPlatform(bytes32 platformHash) external onlyOwner platformIsListed(platformHash) { + function delistPlatform(bytes32 platformHash) external onlyProtocolAdmin platformIsListed(platformHash) { GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); $.platformIsListed[platformHash] = false; $.platformAdminAddress[platformHash] = address(0); $.platformFeePercent[platformHash] = 0; + $.platformAdapter[platformHash] = address(0); $.numberOfListedPlatforms.decrement(); emit PlatformDelisted(platformHash); } @@ -453,7 +488,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU onlyPlatformAdmin(platformHash) { if (platformDataKey == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_PLATFORM_DATA_KEY); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); if ($.platformData[platformDataKey]) { @@ -475,12 +510,15 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU onlyPlatformAdmin(platformHash) { if (platformDataKey == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_PLATFORM_DATA_KEY); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); if (!$.platformData[platformDataKey]) { revert GlobalParamsPlatformDataNotSet(); } + if ($.platformDataOwner[platformDataKey] != platformHash) { + revert GlobalParamsUnauthorized(); + } $.platformData[platformDataKey] = false; $.platformDataOwner[platformDataKey] = ZERO_BYTES; emit PlatformDataRemoved(platformHash, platformDataKey); @@ -492,7 +530,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU function updateProtocolAdminAddress(address protocolAdminAddress) external override - onlyOwner + onlyProtocolAdmin notAddressZero(protocolAdminAddress) { GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); @@ -503,7 +541,10 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU /** * @inheritdoc IGlobalParams */ - function updateProtocolFeePercent(uint256 protocolFeePercent) external override onlyOwner { + function updateProtocolFeePercent(uint256 protocolFeePercent) external override onlyProtocolAdmin { + if (protocolFeePercent > PERCENT_DIVIDER) { + revert GlobalParamsFeePercentExceedsMax(); + } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); $.protocolFeePercent = protocolFeePercent; emit ProtocolFeePercentUpdated(protocolFeePercent); @@ -515,7 +556,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU function updatePlatformAdminAddress(bytes32 platformHash, address platformAdminAddress) external override - onlyOwner + onlyProtocolAdmin platformIsListed(platformHash) notAddressZero(platformAdminAddress) { @@ -558,7 +599,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU function setPlatformAdapter(bytes32 platformHash, address adapter) external override - onlyOwner + onlyProtocolAdmin platformIsListed(platformHash) { GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); @@ -569,9 +610,9 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU /** * @inheritdoc IGlobalParams */ - function addTokenToCurrency(bytes32 currency, address token) external override onlyOwner notAddressZero(token) { + function addTokenToCurrency(bytes32 currency, address token) external override onlyProtocolAdmin notAddressZero(token) { if (currency == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_CURRENCY); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); $.currencyToTokens[currency].push(token); @@ -584,7 +625,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU function removeTokenFromCurrency(bytes32 currency, address token) external override - onlyOwner + onlyProtocolAdmin notAddressZero(token) { GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); @@ -592,16 +633,13 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU uint256 length = tokens.length; bool found = false; - for (uint256 i = 0; i < length;) { + for (uint256 i = 0; i < length; i++) { if (tokens[i] == token) { tokens[i] = tokens[length - 1]; tokens.pop(); found = true; break; } - unchecked { - ++i; - } } if (!found) { @@ -643,25 +681,25 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU bool instantTransfer ) external platformIsListed(platformHash) onlyPlatformAdmin(platformHash) { if (typeId == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_LINE_ITEM_TYPE_ID); } // Validation constraint 1: If countsTowardGoal is true, then applyProtocolFee must be false, canRefund must be true, and instantTransfer must be false if (countsTowardGoal) { if (applyProtocolFee) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_GOAL_APPLIES_PROTOCOL_FEE); } if (!canRefund) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_GOAL_NOT_REFUNDABLE); } if (instantTransfer) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_GOAL_INSTANT_TRANSFER); } } // Validation constraint 2: Non-goal instant transfer items cannot be refundable if (!countsTowardGoal && instantTransfer && canRefund) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_NON_GOAL_INSTANT_REFUNDABLE); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); @@ -690,7 +728,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU onlyPlatformAdmin(platformHash) { if (typeId == ZERO_BYTES) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_LINE_ITEM_TYPE_ID); } GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); if (!$.platformLineItemTypes[platformHash][typeId].exists) { @@ -740,7 +778,14 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU */ function _revertIfAddressZero(address account) internal pure { if (account == address(0)) { - revert GlobalParamsInvalidInput(); + revert GlobalParamsInvalidInput(ProtocolErrors.GlobalParamsInvalidInput.ZERO_ADDRESS); + } + } + + function _onlyProtocolAdmin() private view { + GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); + if (msg.sender != $.protocolAdminAddress) { + revert GlobalParamsUnauthorized(); } } @@ -751,7 +796,7 @@ contract GlobalParams is Initializable, IGlobalParams, OwnableUpgradeable, UUPSU */ function _onlyPlatformAdmin(bytes32 platformHash) private view { GlobalParamsStorage.Storage storage $ = GlobalParamsStorage._getGlobalParamsStorage(); - if (_msgSender() != $.platformAdminAddress[platformHash]) { + if (msg.sender != $.platformAdminAddress[platformHash]) { revert GlobalParamsUnauthorized(); } } diff --git a/src/TreasuryFactory.sol b/src/TreasuryFactory.sol index f2afad17..fcd5fb8f 100644 --- a/src/TreasuryFactory.sol +++ b/src/TreasuryFactory.sol @@ -8,6 +8,7 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U import {ITreasuryFactory} from "./interfaces/ITreasuryFactory.sol"; import {IGlobalParams, AdminAccessChecker} from "./utils/AdminAccessChecker.sol"; import {TreasuryFactoryStorage} from "./storage/TreasuryFactoryStorage.sol"; +import {ICampaignInfoFactory} from "./interfaces/ICampaignInfoFactory.sol"; /** * @title TreasuryFactory @@ -23,6 +24,7 @@ contract TreasuryFactory is Initializable, ITreasuryFactory, AdminAccessChecker, error TreasuryFactoryImplementationNotSetOrApproved(); error TreasuryFactoryTreasuryInitializationFailed(); error TreasuryFactorySettingPlatformInfoFailed(); + error TreasuryFactoryInvalidCampaignInfo(); /** * @dev Constructor that disables initializers to prevent implementation contract initialization @@ -46,6 +48,19 @@ contract TreasuryFactory is Initializable, ITreasuryFactory, AdminAccessChecker, */ function _authorizeUpgrade(address newImplementation) internal override onlyProtocolAdmin {} + /** + * @notice Sets the CampaignInfoFactory address used to validate infoAddress inputs in deploy(). + * @dev Callable only by the protocol admin. + * @param campaignInfoFactory The address of the CampaignInfoFactory contract. + */ + function setCampaignInfoFactory(address campaignInfoFactory) external onlyProtocolAdmin { + if (campaignInfoFactory == address(0)) { + revert TreasuryFactoryInvalidAddress(); + } + TreasuryFactoryStorage.Storage storage $ = TreasuryFactoryStorage._getTreasuryFactoryStorage(); + $.campaignInfoFactory = campaignInfoFactory; + } + /** * @inheritdoc ITreasuryFactory */ @@ -116,18 +131,19 @@ contract TreasuryFactory is Initializable, ITreasuryFactory, AdminAccessChecker, revert TreasuryFactoryImplementationNotSetOrApproved(); } - clone = Clones.clone(implementation); + if ($.campaignInfoFactory == address(0) || !ICampaignInfoFactory($.campaignInfoFactory).isValidCampaignInfo(infoAddress)) { + revert TreasuryFactoryInvalidCampaignInfo(); + } - // Fetch the platform adapter (trusted forwarder) from GlobalParams - address platformAdapter = _getGlobalParams().getPlatformAdapter(platformHash); + clone = Clones.clone(implementation); (bool success,) = clone.call( - abi.encodeWithSignature("initialize(bytes32,address,address)", platformHash, infoAddress, platformAdapter) + abi.encodeWithSignature("initialize(bytes32,address)", platformHash, infoAddress) ); if (!success) { revert TreasuryFactoryTreasuryInitializationFailed(); } - (success,) = infoAddress.call(abi.encodeWithSignature("_setPlatformInfo(bytes32,address)", platformHash, clone)); + (success,) = infoAddress.call(abi.encodeWithSignature("setPlatformInfo(bytes32,address)", platformHash, clone)); if (!success) { revert TreasuryFactorySettingPlatformInfoFailed(); } diff --git a/src/errors/ProtocolErrors.sol b/src/errors/ProtocolErrors.sol new file mode 100644 index 00000000..513bfc88 --- /dev/null +++ b/src/errors/ProtocolErrors.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title ProtocolErrors + * @notice Shared error-code enums for GlobalParams, CampaignInfo, and CampaignInfoFactory invalid-input reverts. + */ +library ProtocolErrors { + /// @notice Codes for `GlobalParamsInvalidInput` (input-validation failures). + enum GlobalParamsInvalidInput { + ZERO_TOKEN, + ZERO_REGISTRY_KEY, + ZERO_PLATFORM_HASH, + ZERO_PLATFORM_DATA_KEY, + ZERO_CURRENCY, + ZERO_LINE_ITEM_TYPE_ID, + LINE_ITEM_GOAL_APPLIES_PROTOCOL_FEE, + LINE_ITEM_GOAL_NOT_REFUNDABLE, + LINE_ITEM_GOAL_INSTANT_TRANSFER, + LINE_ITEM_NON_GOAL_INSTANT_REFUNDABLE, + ZERO_ADDRESS + } + + /// @notice Codes for `CampaignInfoInvalidInput` (input-validation failures). + enum CampaignInfoInvalidInput { + DUPLICATE_ACCEPTED_TOKEN, + PLATFORM_DATA_NOT_SET, + INVALID_LAUNCH_TIME, + INVALID_DEADLINE, + ZERO_GOAL_AMOUNT, + PLATFORM_SELECTION_UNCHANGED, + PLATFORM_DATA_LENGTH_MISMATCH, + INVALID_PLATFORM_DATA_KEY, + ZERO_PLATFORM_DATA_VALUE + } + + /// @notice Codes for `CampaignInfoFactoryInvalidInput` (input-validation failures). + enum CampaignInfoFactoryInvalidInput { + ZERO_CREATOR, + PLATFORM_DATA_LENGTH_MISMATCH, + LAUNCH_TIME_TOO_SOON, + DEADLINE_TOO_SOON, + INVALID_PLATFORM_DATA_KEY, + ZERO_PLATFORM_DATA_VALUE, + ZERO_IMPLEMENTATION + } +} diff --git a/src/errors/TreasuryErrors.sol b/src/errors/TreasuryErrors.sol new file mode 100644 index 00000000..67847b63 --- /dev/null +++ b/src/errors/TreasuryErrors.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title TreasuryErrors + * @notice Shared error-code enums for all treasury contracts + */ +library TreasuryErrors { + /// @notice Codes for `InvalidInput` errors (input-validation failures). + enum InvalidInput { + INVALID_LINE_ITEM, + LINE_ITEM_TYPE_NOT_FOUND, + EMPTY_SIGNATURE, + INVALID_BUYER, + INVALID_BACKER, + CONFIRM_BATCH_LENGTH_MISMATCH, + ZERO_REFUND_ADDRESS, + ZERO_CLAIMABLE_AMOUNT, + REWARD_NOT_FOUND, + REWARD_LENGTH_MISMATCH, + INVALID_PLEDGE_INPUT, + ZERO_REWARD_NAME, + FEE_LENGTH_MISMATCH, + INVALID_DEADLINE, + ZERO_GOAL_AMOUNT, + INVALID_REWARD_INPUT, + ZERO_TOKEN_SOURCE, + ZERO_AMOUNT + } + + /// @notice Codes for `NotClaimable` errors (refund / claim-check failures). + enum NotClaimable { + ZERO_REFUND_AMOUNT, + INSUFFICIENT_LIQUIDITY, + ZERO_REFUND_ADDRESS, + NOT_NFT_PAYMENT, + INSUFFICIENT_GOAL_LIQUIDITY, + INSUFFICIENT_NON_GOAL_LIQUIDITY, + INSUFFICIENT_CONTRACT_BALANCE, + CAMPAIGN_SUCCESSFUL, + INVALID_REFUND_PERIOD + } +} diff --git a/src/interfaces/ICampaignInfo.sol b/src/interfaces/ICampaignInfo.sol index 9fc9854a..882ea501 100644 --- a/src/interfaces/ICampaignInfo.sol +++ b/src/interfaces/ICampaignInfo.sol @@ -86,6 +86,13 @@ interface ICampaignInfo is IERC721 { */ function getPlatformAdminAddress(bytes32 platformHash) external view returns (address); + /** + * @notice Retrieves the adapter (trusted forwarder) address for a platform from GlobalParams. + * @param platformHash The bytes32 identifier of the platform. + * @return The adapter address for ERC-2771 meta-transactions, or address(0) if none is set. + */ + function getPlatformAdapter(bytes32 platformHash) external view returns (address); + /** * @notice Retrieves the campaign's launch time. * @return The timestamp when the campaign was launched. @@ -200,11 +207,6 @@ interface ICampaignInfo is IERC721 { */ function paused() external view returns (bool); - /** - * @dev Returns true if the campaign is cancelled, and false otherwise. - */ - function cancelled() external view returns (bool); - /** * @notice Retrieves a value from the GlobalParams data registry. * @param key The registry key. @@ -218,6 +220,12 @@ interface ICampaignInfo is IERC721 { */ function getBufferTime() external view returns (uint256 bufferTime); + /** + * @notice Returns the canonical Permit2 contract address from GlobalParams. + * @return The Permit2 contract address. + */ + function getPermit2Address() external view returns (address); + /** * @notice Retrieves a platform-specific line item type configuration from GlobalParams. * @param platformHash The identifier of the platform. @@ -243,7 +251,7 @@ interface ICampaignInfo is IERC721 { /** * @notice Mints a pledge NFT for a backer - * @dev Can only be called by treasuries with MINTER_ROLE + * @dev Can only be called by treasuries with TREASURY_ROLE * @param backer The backer address * @param reward The reward identifier * @param tokenAddress The address of the token used for the pledge @@ -275,6 +283,7 @@ interface ICampaignInfo is IERC721 { /** * @notice Burns a pledge NFT + * @dev Can only be called by treasuries with TREASURY_ROLE * @param tokenId The token ID to burn */ function burn(uint256 tokenId) external; diff --git a/src/interfaces/ICampaignInfoFactory.sol b/src/interfaces/ICampaignInfoFactory.sol index 6e366b9b..ac605884 100644 --- a/src/interfaces/ICampaignInfoFactory.sol +++ b/src/interfaces/ICampaignInfoFactory.sol @@ -20,6 +20,12 @@ interface ICampaignInfoFactory is ICampaignData { */ event CampaignInfoFactoryCampaignInitialized(); + /** + * @notice Emitted when the campaign implementation address is updated. + * @param newImplementation The new implementation address. + */ + event CampaignInfoFactoryImplementationUpdated(address indexed newImplementation); + /** * @notice Creates a new campaign information contract with NFT. * @dev IMPORTANT: Protocol and platform fees are retrieved at execution time and locked @@ -56,4 +62,11 @@ interface ICampaignInfoFactory is ICampaignData { * @param newImplementation The address of the camapaignInfo implementation contract. */ function updateImplementation(address newImplementation) external; + + /** + * @notice Returns whether the given address is a CampaignInfo contract created by this factory. + * @param campaignInfo The address to check. + * @return True if the address was deployed through this factory, false otherwise. + */ + function isValidCampaignInfo(address campaignInfo) external view returns (bool); } diff --git a/src/interfaces/ICampaignPaymentTreasury.sol b/src/interfaces/ICampaignPaymentTreasury.sol index 48bbde4c..95b77a0a 100644 --- a/src/interfaces/ICampaignPaymentTreasury.sol +++ b/src/interfaces/ICampaignPaymentTreasury.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.22; +import {PermitData} from "./IPermit2.sol"; + /** * @title ICampaignPaymentTreasury * @notice An interface for managing campaign payment treasury contracts. @@ -121,14 +123,18 @@ interface ICampaignPaymentTreasury { /** * @notice Allows a buyer to make a direct crypto payment for an item. - * @dev This function transfers tokens directly from the buyer's wallet and confirms the payment immediately. + * @dev Tokens are transferred from `buyerAddress` via Permit2 `permitWitnessTransferFrom`. + * The permit's witness commits to `paymentId`, `itemId`, `buyerAddress`, `amount`, and + * a hash of `lineItems`, ensuring the caller cannot tamper with any of these values + * after the buyer has signed the permit. * @param paymentId The unique identifier of the payment. * @param itemId The identifier of the item being purchased. - * @param buyerAddress The address of the buyer making the payment. + * @param buyerAddress The address of the buyer making the payment (must be the permit signer). * @param paymentToken The token to use for the payment. - * @param amount The amount to be paid for the item. + * @param amount The amount to be associated with the NFT (in token's native decimals). * @param lineItems Array of line items associated with this payment. * @param externalFees Array of external fee metadata captured for this payment (informational only). + * @param permitData Permit2 permit data (nonce, deadline, signature) signed by `buyerAddress`. */ function processCryptoPayment( bytes32 paymentId, @@ -137,7 +143,8 @@ interface ICampaignPaymentTreasury { address paymentToken, uint256 amount, LineItem[] calldata lineItems, - ExternalFees[] calldata externalFees + ExternalFees[] calldata externalFees, + PermitData calldata permitData ) external; /** @@ -241,9 +248,4 @@ interface ICampaignPaymentTreasury { */ function getExpectedAmount() external view returns (uint256); - /** - * @notice Checks if the treasury has been cancelled. - * @return True if the treasury is cancelled, false otherwise. - */ - function cancelled() external view returns (bool); } diff --git a/src/interfaces/ICampaignTreasury.sol b/src/interfaces/ICampaignTreasury.sol index ea7ef0cc..b5a68a77 100644 --- a/src/interfaces/ICampaignTreasury.sol +++ b/src/interfaces/ICampaignTreasury.sol @@ -52,9 +52,4 @@ interface ICampaignTreasury { */ function getRefundedAmount() external view returns (uint256); - /** - * @notice Checks if the treasury has been cancelled. - * @return True if the treasury is cancelled, false otherwise. - */ - function cancelled() external view returns (bool); } diff --git a/src/interfaces/IGlobalParams.sol b/src/interfaces/IGlobalParams.sol index 0f155ebc..49eb0b1b 100644 --- a/src/interfaces/IGlobalParams.sol +++ b/src/interfaces/IGlobalParams.sol @@ -135,6 +135,12 @@ interface IGlobalParams { */ function getFromRegistry(bytes32 key) external view returns (bytes32 value); + /** + * @notice Returns the canonical Permit2 contract address. + * @return The Permit2 contract address. + */ + function getPermit2Address() external pure returns (address); + /** * @notice Sets or updates a platform-specific line item type configuration. * @param platformHash The identifier of the platform. diff --git a/src/interfaces/IPermit2.sol b/src/interfaces/IPermit2.sol new file mode 100644 index 00000000..5ec07355 --- /dev/null +++ b/src/interfaces/IPermit2.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +// ============================================================================ +// Inlined Permit2 interfaces (originally from Uniswap permit2 package) +// ============================================================================ + +/// @title IEIP712 +interface IEIP712 { + function DOMAIN_SEPARATOR() external view returns (bytes32); +} + +/// @title ISignatureTransfer +/// @notice Handles ERC20 token transfers through signature based actions +/// @dev Requires user's token approval on the Permit2 contract +interface ISignatureTransfer is IEIP712 { + /// @notice Thrown when the requested amount for a transfer is larger than the permissioned amount + /// @param maxAmount The maximum amount a spender can request to transfer + error InvalidAmount(uint256 maxAmount); + + /// @notice Thrown when the number of tokens permissioned to a spender does not match the number of tokens being transferred + error LengthMismatch(); + + /// @notice Emits an event when the owner successfully invalidates an unordered nonce. + event UnorderedNonceInvalidation(address indexed owner, uint256 word, uint256 mask); + + /// @notice The token and amount details for a transfer signed in the permit transfer signature + struct TokenPermissions { + address token; + uint256 amount; + } + + /// @notice The signed permit message for a single token transfer + struct PermitTransferFrom { + TokenPermissions permitted; + uint256 nonce; + uint256 deadline; + } + + /// @notice Specifies the recipient address and amount for batched transfers. + struct SignatureTransferDetails { + address to; + uint256 requestedAmount; + } + + /// @notice Used to reconstruct the signed permit message for multiple token transfers + struct PermitBatchTransferFrom { + TokenPermissions[] permitted; + uint256 nonce; + uint256 deadline; + } + + /// @notice A map from token owner address and a caller specified word index to a bitmap. + function nonceBitmap(address, uint256) external view returns (uint256); + + /// @notice Transfers a token using a signed permit message + function permitTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes calldata signature + ) external; + + /// @notice Transfers a token using a signed permit message with extra witness data + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external; + + /// @notice Transfers multiple tokens using a signed permit message + function permitTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes calldata signature + ) external; + + /// @notice Transfers multiple tokens using a signed permit message with extra witness data + function permitWitnessTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external; + + /// @notice Invalidates the bits specified in mask for the bitmap at the word position + function invalidateUnorderedNonces(uint256 wordPos, uint256 mask) external; +} + +// ============================================================================ +// Application-level types +// ============================================================================ + +/** + * @title IPermit2 + * @notice Re-exports ISignatureTransfer so that existing import paths work unchanged. + * @dev The canonical Permit2 deployment address is + * 0x000000000022D473030F116dDEE9F6B43aC78BA3 across all supported EVM chains. + */ +interface IPermit2 is ISignatureTransfer {} + +/** + * @notice Application-specific struct bundling the Permit2 fields a caller must + * supply alongside each signature-based token transfer. + * @param nonce Unique nonce preventing signature replay (managed by Permit2). + * @param deadline Unix timestamp after which the permit is no longer valid. + * @param signature EIP-712 signature produced by the token owner. + */ +struct PermitData { + uint256 nonce; + uint256 deadline; + bytes signature; +} diff --git a/src/interfaces/IReward.sol b/src/interfaces/IReward.sol index 0c570869..dd632950 100644 --- a/src/interfaces/IReward.sol +++ b/src/interfaces/IReward.sol @@ -9,6 +9,7 @@ interface IReward { struct Reward { uint256 rewardValue; bool isRewardTier; + bool canBeAddOn; bytes32[] itemId; uint256[] itemValue; uint256[] itemQuantity; diff --git a/src/storage/AdminAccessCheckerStorage.sol b/src/storage/AdminAccessCheckerStorage.sol index 455904b2..32473a59 100644 --- a/src/storage/AdminAccessCheckerStorage.sol +++ b/src/storage/AdminAccessCheckerStorage.sol @@ -9,14 +9,14 @@ import {IGlobalParams} from "../interfaces/IGlobalParams.sol"; * @dev This contract contains the storage layout and accessor functions for AdminAccessChecker */ library AdminAccessCheckerStorage { - /// @custom:storage-location erc7201:ccprotocol.storage.AdminAccessChecker + /// @custom:storage-location erc7201:oaknetwork.storage.AdminAccessChecker struct Storage { IGlobalParams globalParams; } - // keccak256(abi.encode(uint256(keccak256("ccprotocol.storage.AdminAccessChecker")) - 1)) & ~bytes32(uint256(0xff)) + // keccak256(abi.encode(uint256(keccak256("oaknetwork.storage.AdminAccessChecker")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant ADMIN_ACCESS_CHECKER_STORAGE_LOCATION = - 0x7c2f08fa04c2c7c7ab255a45dbf913d4c236b91c59858917e818398e997f8800; + 0x7608703513d219ecdd1e84aa0951e3c83cfe601f872259e1340c97792f4b8200; function _getAdminAccessCheckerStorage() internal pure returns (Storage storage $) { assembly { diff --git a/src/storage/CampaignInfoFactoryStorage.sol b/src/storage/CampaignInfoFactoryStorage.sol index d96d66db..5d41680f 100644 --- a/src/storage/CampaignInfoFactoryStorage.sol +++ b/src/storage/CampaignInfoFactoryStorage.sol @@ -9,7 +9,7 @@ import {IGlobalParams} from "../interfaces/IGlobalParams.sol"; * @dev This contract contains the storage layout and accessor functions for CampaignInfoFactory */ library CampaignInfoFactoryStorage { - /// @custom:storage-location erc7201:ccprotocol.storage.CampaignInfoFactory + /// @custom:storage-location erc7201:oaknetwork.storage.CampaignInfoFactory struct Storage { IGlobalParams globalParams; address treasuryFactoryAddress; @@ -18,9 +18,9 @@ library CampaignInfoFactoryStorage { mapping(bytes32 => address) identifierToCampaignInfo; } - // keccak256(abi.encode(uint256(keccak256("ccprotocol.storage.CampaignInfoFactory")) - 1)) & ~bytes32(uint256(0xff)) + // keccak256(abi.encode(uint256(keccak256("oaknetwork.storage.CampaignInfoFactory")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant CAMPAIGN_INFO_FACTORY_STORAGE_LOCATION = - 0x2857858a392b093e1f8b3f368c2276ce911f27cef445605a2932ebe945968d00; + 0x6dcebba7d782f7ff546a8ee2af2a142213ed91f5c14e411be41cf3be65358c00; function _getCampaignInfoFactoryStorage() internal pure returns (Storage storage $) { assembly { diff --git a/src/storage/GlobalParamsStorage.sol b/src/storage/GlobalParamsStorage.sol index 22988f0a..a923f2d3 100644 --- a/src/storage/GlobalParamsStorage.sol +++ b/src/storage/GlobalParamsStorage.sol @@ -29,7 +29,7 @@ library GlobalParamsStorage { bool instantTransfer; } - /// @custom:storage-location erc7201:ccprotocol.storage.GlobalParams + /// @custom:storage-location erc7201:oaknetwork.storage.GlobalParams struct Storage { address protocolAdminAddress; uint256 protocolFeePercent; @@ -48,9 +48,9 @@ library GlobalParamsStorage { Counters.Counter numberOfListedPlatforms; } - // keccak256(abi.encode(uint256(keccak256("ccprotocol.storage.GlobalParams")) - 1)) & ~bytes32(uint256(0xff)) + // keccak256(abi.encode(uint256(keccak256("oaknetwork.storage.GlobalParams")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant GLOBAL_PARAMS_STORAGE_LOCATION = - 0x83d0145f7c1378f10048390769ec94f999b3ba6d94904b8fd7251512962b1c00; + 0xcab368c4291c205bbe63a595130eb08714925d02705f410a55bf1a45b8ddaf00; function _getGlobalParamsStorage() internal pure returns (Storage storage $) { assembly { diff --git a/src/storage/TreasuryFactoryStorage.sol b/src/storage/TreasuryFactoryStorage.sol index 67337c1a..656580f6 100644 --- a/src/storage/TreasuryFactoryStorage.sol +++ b/src/storage/TreasuryFactoryStorage.sol @@ -7,15 +7,16 @@ pragma solidity ^0.8.22; * @dev This contract contains the storage layout and accessor functions for TreasuryFactory */ library TreasuryFactoryStorage { - /// @custom:storage-location erc7201:ccprotocol.storage.TreasuryFactory + /// @custom:storage-location erc7201:oaknetwork.storage.TreasuryFactory struct Storage { mapping(bytes32 => mapping(uint256 => address)) implementationMap; mapping(address => bool) approvedImplementations; + address campaignInfoFactory; } - // keccak256(abi.encode(uint256(keccak256("ccprotocol.storage.TreasuryFactory")) - 1)) & ~bytes32(uint256(0xff)) + // keccak256(abi.encode(uint256(keccak256("oaknetwork.storage.TreasuryFactory")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant TREASURY_FACTORY_STORAGE_LOCATION = - 0x96b7de8c171ef460648aea35787d043e89feb6b6de2623a1e6f17a91b9c9e900; + 0xac5f58af051caf3154d38fdfab53396f7d32e9ef6bb41b866435ed38c5426600; function _getTreasuryFactoryStorage() internal pure returns (Storage storage $) { assembly { diff --git a/src/treasuries/AllOrNothing.sol b/src/treasuries/AllOrNothing.sol index fb9391da..b6d0371d 100644 --- a/src/treasuries/AllOrNothing.sol +++ b/src/treasuries/AllOrNothing.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.22; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Counters} from "../utils/Counters.sol"; import {TimestampChecker} from "../utils/TimestampChecker.sol"; @@ -10,12 +9,14 @@ import {ICampaignTreasury} from "../interfaces/ICampaignTreasury.sol"; import {ICampaignInfo} from "../interfaces/ICampaignInfo.sol"; import {BaseTreasury} from "../utils/BaseTreasury.sol"; import {IReward} from "../interfaces/IReward.sol"; +import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; +import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; /** * @title AllOrNothing - * @notice A contract for handling crowdfunding campaigns with rewards. + * @notice A contract for handling "all-or-nothing" crowdfunding campaigns. Funds are only claimable by the campaign owner if the funding goal is met by the deadline; otherwise, backers can claim refunds. */ -contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuard { +contract AllOrNothing is IReward, BaseTreasury, TimestampChecker { using Counters for Counters.Counter; using SafeERC20 for IERC20; @@ -31,6 +32,23 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar // Counter for reward tiers Counters.Counter private s_rewardCounter; + // --------------------------------------------------------------------------- + // Permit2 witness types for pledge functions + // --------------------------------------------------------------------------- + // pledgeForAReward witness – binds backer, reward array, and shipping fee + bytes32 internal constant AON_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH = keccak256( + "PledgeForRewardWitness(address backer,bytes32 rewardsHash,uint256 shippingFee)" + ); + string internal constant AON_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING = + "PledgeForRewardWitness witness)PledgeForRewardWitness(address backer,bytes32 rewardsHash,uint256 shippingFee)TokenPermissions(address token,uint256 amount)"; + + // pledgeWithoutAReward witness – binds backer and pledge amount + bytes32 internal constant AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH = + keccak256("PledgeWithoutRewardWitness(address backer,uint256 pledgeAmount)"); + string internal constant AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING = + "PledgeWithoutRewardWitness witness)PledgeWithoutRewardWitness(address backer,uint256 pledgeAmount)TokenPermissions(address token,uint256 amount)"; + + /** * @dev Emitted when a backer makes a pledge. * @param backer The address of the backer making the pledge. @@ -78,8 +96,22 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar /** * @dev Emitted when an invalid input is detected. + * @param code Error code defined in {TreasuryErrors.InvalidInput}. */ - error AllOrNothingInvalidInput(); + error AllOrNothingInvalidInput(TreasuryErrors.InvalidInput code); + + /// @dev Reverts when reward name is zero bytes. + error AllOrNothingZeroRewardName(); + /// @dev Reverts when reward value is zero. + error AllOrNothingZeroRewardValue(); + /// @dev Reverts when reward item arrays have mismatched lengths. + error AllOrNothingRewardItemArrayLengthMismatch(); + /// @dev Reverts when backer address is zero. + error AllOrNothingZeroBacker(); + /// @dev Reverts when reward selection length exceeds number of rewards. + error AllOrNothingRewardSelectionLengthMismatch(); + /// @dev Reverts when first reward is not a reward tier. + error AllOrNothingFirstRewardNotTier(); /** * @dev Emitted when a token transfer fails. @@ -96,10 +128,6 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar */ error AllOrNothingFeeNotDisbursed(); - /** - * @dev Emitted when `disburseFees` after fee is disbursed already. - */ - error AllOrNothingFeeAlreadyDisbursed(); /** * @dev Emitted when a `Reward` already exists for given input. */ @@ -113,16 +141,17 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar /** * @dev Emitted when claiming an unclaimable refund. * @param tokenId The ID of the token representing the pledge. + * @param code Error code defined in {TreasuryErrors.NotClaimable}. */ - error AllOrNothingNotClaimable(uint256 tokenId); + error AllOrNothingNotClaimable(uint256 tokenId, TreasuryErrors.NotClaimable code); /** * @dev Constructor for the AllOrNothing contract. */ constructor() {} - function initialize(bytes32 _platformHash, address _infoAddress, address _trustedForwarder) external initializer { - __BaseContract_init(_platformHash, _infoAddress, _trustedForwarder); + function initialize(bytes32 _platformHash, address _infoAddress) external initializer { + __BaseContract_init(_platformHash, _infoAddress); } /** @@ -132,65 +161,63 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar */ function getReward(bytes32 rewardName) external view returns (Reward memory reward) { if (s_reward[rewardName].rewardValue == 0) { - revert AllOrNothingInvalidInput(); + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } return s_reward[rewardName]; } /** * @inheritdoc ICampaignTreasury + * @return amount Total raised amount across all tokens, normalized to 18 decimals. */ - function getRaisedAmount() external view override returns (uint256) { + function getRaisedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_tokenRaisedAmounts[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_tokenRaisedAmounts[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignTreasury + * @return amount Lifetime total raised amount across all tokens, normalized to 18 decimals. */ - function getLifetimeRaisedAmount() external view override returns (uint256) { + function getLifetimeRaisedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_tokenLifetimeRaisedAmounts[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_tokenLifetimeRaisedAmounts[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignTreasury + * @return amount Total refunded amount across all tokens, normalized to 18 decimals. */ - function getRefundedAmount() external view override returns (uint256) { + function getRefundedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 lifetimeAmount = s_tokenLifetimeRaisedAmounts[token]; - uint256 currentAmount = s_tokenRaisedAmounts[token]; - uint256 refundedAmount = lifetimeAmount - currentAmount; + uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token]; if (refundedAmount > 0) { - totalNormalized += _normalizeAmount(token, refundedAmount); + amount += _normalizeAmount(token, refundedAmount); } } - return totalNormalized; + return amount; } /** @@ -211,25 +238,19 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar whenNotCancelled { if (rewardNames.length != rewards.length) { - revert AllOrNothingInvalidInput(); + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.REWARD_LENGTH_MISMATCH); } for (uint256 i = 0; i < rewardNames.length; i++) { bytes32 rewardName = rewardNames[i]; Reward calldata reward = rewards[i]; - // Reward name must not be zero bytes and reward value must be non-zero - if (rewardName == ZERO_BYTES || reward.rewardValue == 0) { - revert AllOrNothingInvalidInput(); - } + if (rewardName == ZERO_BYTES) revert AllOrNothingZeroRewardName(); + if (reward.rewardValue == 0) revert AllOrNothingZeroRewardValue(); // If there are any items, their arrays must match in length - if ( - (reward.itemId.length != reward.itemValue.length) - || (reward.itemId.length != reward.itemQuantity.length) - ) { - revert AllOrNothingInvalidInput(); - } + if (reward.itemId.length != reward.itemValue.length) revert AllOrNothingRewardItemArrayLengthMismatch(); + if (reward.itemId.length != reward.itemQuantity.length) revert AllOrNothingRewardItemArrayLengthMismatch(); // Check for duplicate reward if (s_reward[rewardName].rewardValue != 0) { @@ -249,13 +270,14 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar function removeReward(bytes32 rewardName) external onlyCampaignOwner + currentTimeIsLess(INFO.getLaunchTime()) whenCampaignNotPaused whenNotPaused whenCampaignNotCancelled whenNotCancelled { if (s_reward[rewardName].rewardValue == 0) { - revert AllOrNothingInvalidInput(); + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } delete s_reward[rewardName]; s_rewardCounter.decrement(); @@ -263,15 +285,23 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar } /** - * @notice Allows a backer to pledge for a reward. - * @dev The first element of the `reward` array must be a reward tier and the other elements can be either reward tiers or non-reward tiers. - * The non-reward tiers cannot be pledged for without a reward. - * @param backer The address of the backer making the pledge. + * @notice Allows a backer to pledge for a reward using a Permit2 signature. + * @dev Tokens are transferred from `backer` via Permit2 `permitWitnessTransferFrom`. + * The permit's witness commits to `backer`, the reward array hash, and `shippingFee`, + * so the caller cannot change those values after the backer has signed. + * @param backer The address of the backer making the pledge (must be the permit signer). * @param pledgeToken The token address to use for the pledge. * @param shippingFee The shipping fee amount. * @param reward An array of reward names. + * @param permitData Permit2 permit data (nonce, deadline, signature) signed by `backer`. */ - function pledgeForAReward(address backer, address pledgeToken, uint256 shippingFee, bytes32[] calldata reward) + function pledgeForAReward( + address backer, + address pledgeToken, + uint256 shippingFee, + bytes32[] calldata reward, + PermitData calldata permitData + ) external nonReentrant currentTimeIsWithinRange(INFO.getLaunchTime(), INFO.getDeadline()) @@ -282,33 +312,39 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar { uint256 rewardLen = reward.length; Reward storage tempReward = s_reward[reward[0]]; - if ( - backer == address(0) || rewardLen > s_rewardCounter.current() || reward[0] == ZERO_BYTES - || !tempReward.isRewardTier - ) { - revert AllOrNothingInvalidInput(); - } + if (backer == address(0)) revert AllOrNothingZeroBacker(); + if (rewardLen > s_rewardCounter.current()) revert AllOrNothingRewardSelectionLengthMismatch(); + if (reward[0] == ZERO_BYTES) revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.INVALID_PLEDGE_INPUT); + if (!tempReward.isRewardTier) revert AllOrNothingFirstRewardNotTier(); uint256 pledgeAmount = tempReward.rewardValue; for (uint256 i = 1; i < rewardLen; i++) { if (reward[i] == ZERO_BYTES) { - revert AllOrNothingInvalidInput(); + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.ZERO_REWARD_NAME); } tempReward = s_reward[reward[i]]; - if (tempReward.rewardValue == 0) { - revert AllOrNothingInvalidInput(); + if (tempReward.rewardValue == 0 || !tempReward.canBeAddOn) { + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } pledgeAmount += tempReward.rewardValue; } - _pledge(backer, pledgeToken, reward[0], pledgeAmount, shippingFee, reward); + _pledge(backer, pledgeToken, reward[0], pledgeAmount, shippingFee, reward, permitData); } /** - * @notice Allows a backer to pledge without selecting a reward. - * @param backer The address of the backer making the pledge. + * @notice Allows a backer to pledge without selecting a reward using a Permit2 signature. + * @dev Tokens are transferred from `backer` via Permit2 `permitWitnessTransferFrom`. + * The permit's witness commits to `backer` and `pledgeAmount`. + * @param backer The address of the backer making the pledge (must be the permit signer). * @param pledgeToken The token address to use for the pledge. - * @param pledgeAmount The amount of the pledge. + * @param pledgeAmount The amount of the pledge (in token's native decimals). + * @param permitData Permit2 permit data (nonce, deadline, signature) signed by `backer`. */ - function pledgeWithoutAReward(address backer, address pledgeToken, uint256 pledgeAmount) + function pledgeWithoutAReward( + address backer, + address pledgeToken, + uint256 pledgeAmount, + PermitData calldata permitData + ) external nonReentrant currentTimeIsWithinRange(INFO.getLaunchTime(), INFO.getDeadline()) @@ -319,7 +355,7 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar { bytes32[] memory emptyByteArray = new bytes32[](0); - _pledge(backer, pledgeToken, ZERO_BYTES, pledgeAmount, 0, emptyByteArray); + _pledge(backer, pledgeToken, ZERO_BYTES, pledgeAmount, 0, emptyByteArray, permitData); } /** @@ -333,7 +369,7 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar whenNotPaused { if (block.timestamp >= INFO.getDeadline() && _checkSuccessCondition()) { - revert AllOrNothingNotClaimable(tokenId); + revert AllOrNothingNotClaimable(tokenId, TreasuryErrors.NotClaimable.CAMPAIGN_SUCCESSFUL); } // Get NFT owner before burning @@ -344,7 +380,7 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar address pledgeToken = s_tokenIdToPledgeToken[tokenId]; if (amountToRefund == 0) { - revert AllOrNothingNotClaimable(tokenId); + revert AllOrNothingNotClaimable(tokenId, TreasuryErrors.NotClaimable.ZERO_REFUND_AMOUNT); } s_tokenToTotalCollectedAmount[tokenId] = 0; @@ -363,9 +399,6 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar * @inheritdoc ICampaignTreasury */ function disburseFees() public override currentTimeIsGreater(INFO.getDeadline()) whenNotPaused whenNotCancelled { - if (s_feesDisbursed) { - revert AllOrNothingFeeAlreadyDisbursed(); - } super.disburseFees(); } @@ -394,37 +427,75 @@ contract AllOrNothing is IReward, BaseTreasury, TimestampChecker, ReentrancyGuar return INFO.getTotalRaisedAmount() >= INFO.getGoalAmount(); } + /** + * @dev Processes a pledge: transfers tokens, mints NFT, and updates state. + * @dev Mints a pledge NFT via `_safeMint`; reverts if `backer` is a contract that does not implement `IERC721Receiver`. + * @param backer Recipient of the pledge NFT. + * @param pledgeToken Token used for the pledge. + * @param reward First reward tier (ZERO_BYTES for non-reward pledges). + * @param pledgeAmount Pledge amount in the token's native decimals (must be denormalized by caller). + * @param shippingFee Shipping fee in the token's native decimals (must be denormalized by caller; use 0 for non-reward). + * @param rewards Full reward selection (for event). + */ function _pledge( address backer, address pledgeToken, bytes32 reward, uint256 pledgeAmount, uint256 shippingFee, - bytes32[] memory rewards + bytes32[] memory rewards, + PermitData calldata permitData ) private { - // Validate token is accepted if (!INFO.isTokenAccepted(pledgeToken)) { revert AllOrNothingTokenNotAccepted(pledgeToken); } + if (backer == address(this)) { + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.INVALID_BACKER); + } + if (permitData.signature.length == 0) { + revert AllOrNothingInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + } - // If this is for a reward, pledgeAmount and shippingFee are in 18 decimals - // If not for a reward, amounts are already in token decimals uint256 pledgeAmountInTokenDecimals; uint256 shippingFeeInTokenDecimals; if (reward != ZERO_BYTES) { - // Reward pledge: denormalize from 18 decimals to token decimals pledgeAmountInTokenDecimals = _denormalizeAmount(pledgeToken, pledgeAmount); shippingFeeInTokenDecimals = _denormalizeAmount(pledgeToken, shippingFee); } else { - // Non-reward pledge: already in token decimals pledgeAmountInTokenDecimals = pledgeAmount; shippingFeeInTokenDecimals = shippingFee; } uint256 totalAmount = pledgeAmountInTokenDecimals + shippingFeeInTokenDecimals; - IERC20(pledgeToken).safeTransferFrom(backer, address(this), totalAmount); + bytes32 witness; + string memory witnessTypeString; + + if (reward != ZERO_BYTES) { + bytes32 rewardsHash = keccak256(abi.encodePacked(rewards)); + witness = keccak256( + abi.encode(AON_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, backer, rewardsHash, shippingFee) + ); + witnessTypeString = AON_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING; + } else { + witness = + keccak256(abi.encode(AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, backer, pledgeAmountInTokenDecimals)); + witnessTypeString = AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING; + } + + IPermit2(INFO.getPermit2Address()).permitWitnessTransferFrom( + ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: pledgeToken, amount: totalAmount}), + nonce: permitData.nonce, + deadline: permitData.deadline + }), + ISignatureTransfer.SignatureTransferDetails({to: address(this), requestedAmount: totalAmount}), + backer, + witness, + witnessTypeString, + permitData.signature + ); uint256 tokenId = INFO.mintNFTForPledge( backer, reward, pledgeToken, pledgeAmountInTokenDecimals, shippingFeeInTokenDecimals, 0 diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 673e039d..40daca50 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.22; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Counters} from "../utils/Counters.sol"; import {TimestampChecker} from "../utils/TimestampChecker.sol"; @@ -12,12 +11,14 @@ import {ICampaignTreasury} from "../interfaces/ICampaignTreasury.sol"; import {ICampaignInfo} from "../interfaces/ICampaignInfo.sol"; import {IReward} from "../interfaces/IReward.sol"; import {ICampaignData} from "../interfaces/ICampaignData.sol"; +import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; +import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; /** * @title KeepWhatsRaised * @notice A contract that keeps all the funds raised, regardless of the success condition. */ -contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData, ReentrancyGuard { +contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData { using Counters for Counters.Counter; using SafeERC20 for IERC20; @@ -29,12 +30,15 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa mapping(uint256 => uint256) private s_tokenToPaymentFee; // Mapping to store reward details by name mapping(bytes32 => Reward) private s_reward; - /// Tracks whether a pledge with a specific ID has already been processed + /// Tracks whether an external pledge ID has already been processed. mapping(bytes32 => bool) public s_processedPledges; /// Mapping to store payment gateway fees by unique pledge ID mapping(bytes32 => uint256) public s_paymentGatewayFees; - /// Mapping that stores fee values indexed by their corresponding fee keys. - mapping(bytes32 => uint256) private s_feeValues; + /// Flat fee values (token amounts, 18 decimals). Units are unambiguous. + uint256 private s_flatFeeValue; + uint256 private s_cumulativeFlatFeeValue; + /// Gross percentage fee values (basis points, 0 to PERCENT_DIVIDER - 1). Stored in same order as s_feeKeys.grossPercentageFeeKeys. + uint256[] private s_grossPercentageFeeValues; // Multi-token support mapping(uint256 => address) private s_tokenIdToPledgeToken; // Token used for each pledge @@ -79,7 +83,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa struct Config { /// @dev The minimum withdrawal amount required to qualify for fee exemption. uint256 minimumWithdrawalForFeeExemption; - /// @dev Time delay (in timestamp) enforced before a withdrawal can be completed. + /// @dev Time delay (in timestamp) after the campaign deadline until which the campaign owner may withdraw. + /// Withdrawal is allowed only while current time is less than deadline + withdrawalDelay. + /// After deadline + withdrawalDelay, the withdrawal function is no longer callable. uint256 withdrawalDelay; /// @dev Time delay (in timestamp) before a refund becomes claimable or processed. uint256 refundDelay; @@ -93,10 +99,30 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa bool private s_isWithdrawalApproved; bool private s_tipClaimed; bool private s_fundClaimed; + bool private s_configured; FeeKeys private s_feeKeys; Config private s_config; CampaignData private s_campaignData; + // --------------------------------------------------------------------------- + // Permit2 witness types for direct user pledge functions + // (setFeeAndPledge is admin-only and uses standard ERC20 transferFrom) + // --------------------------------------------------------------------------- + // pledgeForAReward witness – binds pledgeId, backer, reward array, and tip + bytes32 internal constant KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH = keccak256( + "KWRPledgeForRewardWitness(bytes32 pledgeId,address backer,bytes32 rewardsHash,uint256 tip)" + ); + string internal constant KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING = + "KWRPledgeForRewardWitness witness)KWRPledgeForRewardWitness(bytes32 pledgeId,address backer,bytes32 rewardsHash,uint256 tip)TokenPermissions(address token,uint256 amount)"; + + // pledgeWithoutAReward witness – binds pledgeId, backer, pledgeAmount, and tip + bytes32 internal constant KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH = keccak256( + "KWRPledgeWithoutRewardWitness(bytes32 pledgeId,address backer,uint256 pledgeAmount,uint256 tip)" + ); + string internal constant KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING = + "KWRPledgeWithoutRewardWitness witness)KWRPledgeWithoutRewardWitness(bytes32 pledgeId,address backer,uint256 pledgeAmount,uint256 tip)TokenPermissions(address token,uint256 amount)"; + + /** * @dev Emitted when a backer makes a pledge. * @param backer The address of the backer making the pledge. @@ -198,8 +224,41 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /** * @dev Emitted when an invalid input is detected. - */ - error KeepWhatsRaisedInvalidInput(); + * @param code Error code defined in {TreasuryErrors.InvalidInput}. + */ + error KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput code); + + /// @dev Emitted when fee keys are not unique (duplicate or overlap between flat and percentage keys). + error KeepWhatsRaisedDuplicateFeeKey(); + + /// @dev Emitted when a percentage fee value is >= PERCENT_DIVIDER (100%). + error KeepWhatsRaisedPercentageFeeExceedsMax(); + + /// @dev Emitted when the sum of gross percentage fees is >= PERCENT_DIVIDER (100%). + error KeepWhatsRaisedAggregatePercentageExceedsMax(); + + /// @dev Reverts when campaign launch time is in the past. + error KeepWhatsRaisedLaunchTimeInPast(); + /// @dev Reverts when campaign deadline is not after launch time. + error KeepWhatsRaisedDeadlineNotAfterLaunch(); + /// @dev Reverts when reward name is zero bytes. + error KeepWhatsRaisedZeroRewardName(); + /// @dev Reverts when reward value is zero. + error KeepWhatsRaisedZeroRewardValue(); + /// @dev Reverts when reward item arrays have mismatched lengths. + error KeepWhatsRaisedRewardItemArrayLengthMismatch(); + /// @dev Reverts when backer address is zero. + error KeepWhatsRaisedZeroBacker(); + /// @dev Reverts when reward selection length exceeds number of rewards. + error KeepWhatsRaisedRewardSelectionLengthMismatch(); + /// @dev Reverts when first reward is not a reward tier. + error KeepWhatsRaisedFirstRewardNotTier(); + /// @dev Reverts when refund amount is zero. + error KeepWhatsRaisedRefundAmountZero(); + /// @dev Reverts when insufficient available balance for refund. + error KeepWhatsRaisedInsufficientAvailableForRefund(uint256 tokenId); + /// @dev Reverts when claimFund is called before refund delay (cancelled) or withdrawal delay (not cancelled). + error KeepWhatsRaisedClaimFundWindowNotReached(); /** * @dev Emitted when a token is not accepted for the campaign. @@ -249,11 +308,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ error KeepWhatsRaisedAlreadyClaimed(); + /** + * @dev Emitted when an operation is attempted after the platform admin has already claimed the treasury funds. + */ + error KeepWhatsRaisedFundAlreadyClaimed(); + /** * @dev Emitted when a token or pledge is not eligible for claiming (e.g., claim period not reached or not valid). * @param tokenId The ID of the token that was attempted to be claimed. + * @param code Error code defined in {TreasuryErrors.NotClaimable}. */ - error KeepWhatsRaisedNotClaimable(uint256 tokenId); + error KeepWhatsRaisedNotClaimable(uint256 tokenId, TreasuryErrors.NotClaimable code); /** * @dev Emitted when an admin attempts to claim funds that are not yet claimable according to the rules. @@ -264,6 +329,18 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * @dev Emitted when a configuration change is attempted during the lock period. */ error KeepWhatsRaisedConfigLocked(); + /** + * @dev Thrown when configureTreasury is called after the treasury has already been configured. + */ + error KeepWhatsRaisedAlreadyConfigured(); + + /** + * @dev Reverts when withdrawalDelay is less than refundDelay, which would allow claimFund + * to be callable before the refund window ends (refund window: (deadline, deadline + refundDelay]). + * @param withdrawalDelay The configured withdrawal delay. + * @param refundDelay The configured refund delay. + */ + error KeepWhatsRaisedWithdrawalBeforeRefundEnd(uint256 withdrawalDelay, uint256 refundDelay); /** * @dev Emitted when a disbursement is attempted before the refund period has ended. @@ -314,8 +391,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ constructor() {} - function initialize(bytes32 _platformHash, address _infoAddress, address _trustedForwarder) external initializer { - __BaseContract_init(_platformHash, _infoAddress, _trustedForwarder); + function initialize(bytes32 _platformHash, address _infoAddress) external initializer { + __BaseContract_init(_platformHash, _infoAddress); } /** @@ -332,84 +409,81 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ function getReward(bytes32 rewardName) external view returns (Reward memory reward) { if (s_reward[rewardName].rewardValue == 0) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } return s_reward[rewardName]; } /** * @inheritdoc ICampaignTreasury + * @return amount Total raised amount across all tokens, normalized to 18 decimals. */ - function getRaisedAmount() external view override returns (uint256) { + function getRaisedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_tokenRaisedAmounts[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_tokenRaisedAmounts[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignTreasury + * @return amount Lifetime total raised amount across all tokens, normalized to 18 decimals. */ - function getLifetimeRaisedAmount() external view override returns (uint256) { + function getLifetimeRaisedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_tokenLifetimeRaisedAmounts[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_tokenLifetimeRaisedAmounts[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignTreasury + * @return amount Total refunded amount across all tokens, normalized to 18 decimals. */ - function getRefundedAmount() external view override returns (uint256) { + function getRefundedAmount() external view override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 lifetimeAmount = s_tokenLifetimeRaisedAmounts[token]; - uint256 currentAmount = s_tokenRaisedAmounts[token]; - uint256 refundedAmount = lifetimeAmount - currentAmount; + uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token]; if (refundedAmount > 0) { - totalNormalized += _normalizeAmount(token, refundedAmount); + amount += _normalizeAmount(token, refundedAmount); } } - return totalNormalized; + return amount; } /** * @notice Retrieves the currently available raised amount in the treasury. - * @return The current available raised amount as a uint256 value. + * @return amount Available raised amount across all tokens, normalized to 18 decimals. */ - function getAvailableRaisedAmount() external view returns (uint256) { + function getAvailableRaisedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_availablePerToken[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_availablePerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** @@ -447,12 +521,19 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /** * @dev Retrieves the fee value associated with a specific fee key from storage. - * @param {bytes32} feeKey - The unique identifier key used to reference a specific fee type. - * - * @return {uint256} The fee value corresponding to the provided fee key. + * Flat fee keys return token amounts (18 decimals); percentage keys return basis points. + * @param feeKey The unique identifier key used to reference a specific fee type. + * @return The fee value corresponding to the provided fee key (0 if key is unknown). */ function getFeeValue(bytes32 feeKey) public view returns (uint256) { - return s_feeValues[feeKey]; + if (feeKey == s_feeKeys.flatFeeKey) return s_flatFeeValue; + if (feeKey == s_feeKeys.cumulativeFlatFeeKey) return s_cumulativeFlatFeeValue; + for (uint256 i = 0; i < s_feeKeys.grossPercentageFeeKeys.length; i++) { + if (s_feeKeys.grossPercentageFeeKeys[i] == feeKey) { + return s_grossPercentageFeeValues[i]; + } + } + return 0; } /** @@ -499,6 +580,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * * @param config The configuration settings including withdrawal delay, refund delay, * fee exemption threshold, and configuration lock period. + * Must satisfy withdrawalDelay >= refundDelay so claimFund is only callable after the refund window ends. * @param campaignData The campaign-related metadata such as deadlines and funding goals. * @param feeKeys The set of keys used to reference applicable flat and percentage-based fees. * @param feeValues The fee values corresponding to the fee keys. @@ -516,23 +598,55 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { - if (campaignData.launchTime < block.timestamp || campaignData.deadline <= campaignData.launchTime) { - revert KeepWhatsRaisedInvalidInput(); + if (campaignData.launchTime < block.timestamp) revert KeepWhatsRaisedLaunchTimeInPast(); + if (campaignData.deadline <= campaignData.launchTime) revert KeepWhatsRaisedDeadlineNotAfterLaunch(); + if (s_configured) { + revert KeepWhatsRaisedAlreadyConfigured(); } if (feeKeys.grossPercentageFeeKeys.length != feeValues.grossPercentageFeeValues.length) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.FEE_LENGTH_MISMATCH); + } + if (config.withdrawalDelay < config.refundDelay) { + revert KeepWhatsRaisedWithdrawalBeforeRefundEnd(config.withdrawalDelay, config.refundDelay); + } + + // Enforce key uniqueness: flat keys must differ and must not appear in percentage keys + if (feeKeys.flatFeeKey == feeKeys.cumulativeFlatFeeKey) { + revert KeepWhatsRaisedDuplicateFeeKey(); + } + for (uint256 i = 0; i < feeKeys.grossPercentageFeeKeys.length; i++) { + bytes32 k = feeKeys.grossPercentageFeeKeys[i]; + if (k == feeKeys.flatFeeKey || k == feeKeys.cumulativeFlatFeeKey) { + revert KeepWhatsRaisedDuplicateFeeKey(); + } + for (uint256 j = i + 1; j < feeKeys.grossPercentageFeeKeys.length; j++) { + if (feeKeys.grossPercentageFeeKeys[j] == k) { + revert KeepWhatsRaisedDuplicateFeeKey(); + } + } } + // Per-fee and aggregate percentage bounds (each and total must be < PERCENT_DIVIDER) + uint256 aggregatePercent = 0; + for (uint256 i = 0; i < feeValues.grossPercentageFeeValues.length; i++) { + uint256 v = feeValues.grossPercentageFeeValues[i]; + if (v >= PERCENT_DIVIDER) { + revert KeepWhatsRaisedPercentageFeeExceedsMax(); + } + aggregatePercent += v; + } + if (aggregatePercent >= PERCENT_DIVIDER) { + revert KeepWhatsRaisedAggregatePercentageExceedsMax(); + } + + s_configured = true; s_config = config; s_feeKeys = feeKeys; s_campaignData = campaignData; - s_feeValues[feeKeys.flatFeeKey] = feeValues.flatFeeValue; - s_feeValues[feeKeys.cumulativeFlatFeeKey] = feeValues.cumulativeFlatFeeValue; - - for (uint256 i = 0; i < feeKeys.grossPercentageFeeKeys.length; i++) { - s_feeValues[feeKeys.grossPercentageFeeKeys[i]] = feeValues.grossPercentageFeeValues[i]; - } + s_flatFeeValue = feeValues.flatFeeValue; + s_cumulativeFlatFeeValue = feeValues.cumulativeFlatFeeValue; + s_grossPercentageFeeValues = feeValues.grossPercentageFeeValues; emit TreasuryConfigured(config, campaignData, feeKeys, feeValues); } @@ -554,7 +668,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenNotCancelled { if (deadline <= getLaunchTime() || deadline <= block.timestamp) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.INVALID_DEADLINE); } s_campaignData.deadline = deadline; @@ -577,7 +691,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenNotCancelled { if (goalAmount == 0) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.ZERO_GOAL_AMOUNT); } s_campaignData.goalAmount = goalAmount; emit KeepWhatsRaisedGoalAmountUpdated(goalAmount); @@ -601,7 +715,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenNotCancelled { if (rewardNames.length != rewards.length) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.REWARD_LENGTH_MISMATCH); } for (uint256 i = 0; i < rewardNames.length; i++) { @@ -609,17 +723,12 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa Reward calldata reward = rewards[i]; // Reward name must not be zero bytes and reward value must be non-zero - if (rewardName == ZERO_BYTES || reward.rewardValue == 0) { - revert KeepWhatsRaisedInvalidInput(); - } + if (rewardName == ZERO_BYTES) revert KeepWhatsRaisedZeroRewardName(); + if (reward.rewardValue == 0) revert KeepWhatsRaisedZeroRewardValue(); // If there are any items, their arrays must match in length - if ( - (reward.itemId.length != reward.itemValue.length) - || (reward.itemId.length != reward.itemQuantity.length) - ) { - revert KeepWhatsRaisedInvalidInput(); - } + if (reward.itemId.length != reward.itemValue.length) revert KeepWhatsRaisedRewardItemArrayLengthMismatch(); + if (reward.itemId.length != reward.itemQuantity.length) revert KeepWhatsRaisedRewardItemArrayLengthMismatch(); // Check for duplicate reward if (s_reward[rewardName].rewardValue != 0) { @@ -639,13 +748,14 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa function removeReward(bytes32 rewardName) external onlyCampaignOwner + currentTimeIsLess(getLaunchTime()) whenCampaignNotPaused whenNotPaused whenCampaignNotCancelled whenNotCancelled { if (s_reward[rewardName].rewardValue == 0) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } delete s_reward[rewardName]; s_rewardCounter.decrement(); @@ -675,6 +785,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa external nonReentrant onlyPlatformAdmin(PLATFORM_HASH) + currentTimeIsWithinRange(getLaunchTime(), getDeadline()) whenCampaignNotPaused whenNotPaused whenCampaignNotCancelled @@ -683,29 +794,34 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa //Set Payment Gateway Fee setPaymentGatewayFee(pledgeId, fee); + PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""}); + if (isPledgeForAReward) { - _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender()); // Pass admin as token source + _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender(), false, emptyPermitData); } else { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender()); // Pass admin as token source + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); } } /** - * @notice Allows a backer to pledge for a reward. - * @dev The first element of the `reward` array must be a reward tier and the other elements can be either reward tiers or non-reward tiers. - * The non-reward tiers cannot be pledged for without a reward. + * @notice Allows a backer to pledge for a reward using a Permit2 signature. + * @dev Tokens are transferred from `backer` via Permit2 `permitWitnessTransferFrom`. + * The permit's witness commits to `pledgeId`, `backer`, the reward array hash, and + * `tip`, so the caller cannot tamper with those parameters after the backer has signed. * @param pledgeId The unique identifier of the pledge. - * @param backer The address of the backer making the pledge. + * @param backer The address of the backer making the pledge (must be the permit signer). * @param pledgeToken The token to use for the pledge. * @param tip An optional tip can be added during the process. * @param reward An array of reward names. + * @param permitData Permit2 permit data (nonce, deadline, signature) signed by `backer`. */ function pledgeForAReward( bytes32 pledgeId, address backer, address pledgeToken, uint256 tip, - bytes32[] calldata reward + bytes32[] calldata reward, + PermitData calldata permitData ) public nonReentrant @@ -715,31 +831,37 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { - _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, backer); // Pass backer as token source for direct calls + if (permitData.signature.length == 0) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + } + + _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, address(0), true, permitData); } /** - * @notice Internal function that allows a backer to pledge for a reward with tokens transferred from a specified source. - * @dev The first element of the `reward` array must be a reward tier and the other elements can be either reward tiers or non-reward tiers. - * The non-reward tiers cannot be pledged for without a reward. - * This function is called internally by both public pledgeForAReward (with backer as token source) and - * setFeeAndPledge (with admin as token source). + * @notice Internal function that allows a backer to pledge for a reward. + * @dev Called by both the public `pledgeForAReward` (Permit2 transfer) and + * `setFeeAndPledge` (admin ERC20 transfer). * @param pledgeId The unique identifier of the pledge. * @param backer The address of the backer making the pledge (receives the NFT). * @param pledgeToken The token to use for the pledge. * @param tip An optional tip can be added during the process. * @param reward An array of reward names. - * @param tokenSource The address from which tokens will be transferred (either backer for direct calls or admin for setFeeAndPledge calls). + * @param tokenSource Token source address for the admin (ERC20) path. + * @param usePermit2 Whether to transfer tokens via Permit2 or direct ERC20 transfer. + * @param permitData Permit2 data for the direct user path. */ function _pledgeForAReward( bytes32 pledgeId, address backer, address pledgeToken, uint256 tip, - bytes32[] calldata reward, - address tokenSource + bytes32[] memory reward, + address tokenSource, + bool usePermit2, + PermitData memory permitData ) internal { - bytes32 internalPledgeId = keccak256(abi.encodePacked(pledgeId, _msgSender())); + bytes32 internalPledgeId = pledgeId; if (s_processedPledges[internalPledgeId]) { revert KeepWhatsRaisedPledgeAlreadyProcessed(internalPledgeId); @@ -748,40 +870,54 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 rewardLen = reward.length; Reward memory tempReward = s_reward[reward[0]]; - if ( - backer == address(0) || rewardLen > s_rewardCounter.current() || reward[0] == ZERO_BYTES - || !tempReward.isRewardTier - ) { - revert KeepWhatsRaisedInvalidInput(); - } + if (backer == address(0)) revert KeepWhatsRaisedZeroBacker(); + if (rewardLen > s_rewardCounter.current()) revert KeepWhatsRaisedRewardSelectionLengthMismatch(); + if (reward[0] == ZERO_BYTES) revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.INVALID_REWARD_INPUT); + if (!tempReward.isRewardTier) revert KeepWhatsRaisedFirstRewardNotTier(); + uint256 pledgeAmount = tempReward.rewardValue; for (uint256 i = 1; i < rewardLen; i++) { if (reward[i] == ZERO_BYTES) { - revert KeepWhatsRaisedInvalidInput(); + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.ZERO_REWARD_NAME); } tempReward = s_reward[reward[i]]; - if (tempReward.rewardValue == 0) { - revert KeepWhatsRaisedInvalidInput(); + if (tempReward.rewardValue == 0 || !tempReward.canBeAddOn) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.REWARD_NOT_FOUND); } pledgeAmount += tempReward.rewardValue; } - _pledge(pledgeId, backer, pledgeToken, reward[0], pledgeAmount, tip, reward, tokenSource); + _pledge( + pledgeId, + backer, + pledgeToken, + reward[0], + pledgeAmount, + tip, + reward, + tokenSource, + usePermit2, + permitData + ); } /** - * @notice Allows a backer to pledge without selecting a reward. + * @notice Allows a backer to pledge without selecting a reward using a Permit2 signature. + * @dev Tokens are transferred from `backer` via Permit2 `permitWitnessTransferFrom`. + * The permit's witness commits to `pledgeId`, `backer`, `pledgeAmount`, and `tip`. * @param pledgeId The unique identifier of the pledge. - * @param backer The address of the backer making the pledge. + * @param backer The address of the backer making the pledge (must be the permit signer). * @param pledgeToken The token to use for the pledge. - * @param pledgeAmount The amount of the pledge. - * @param tip An optional tip can be added during the process. + * @param pledgeAmount The amount of the pledge (in token's native decimals). + * @param tip An optional tip (in token's native decimals). + * @param permitData Permit2 permit data (nonce, deadline, signature) signed by `backer`. */ function pledgeWithoutAReward( bytes32 pledgeId, address backer, address pledgeToken, uint256 pledgeAmount, - uint256 tip + uint256 tip, + PermitData calldata permitData ) public nonReentrant @@ -791,19 +927,25 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, backer); // Pass backer as token source for direct calls + if (permitData.signature.length == 0) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + } + + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, address(0), true, permitData); } /** - * @notice Internal function that allows a backer to pledge without selecting a reward with tokens transferred from a specified source. - * @dev This function is called internally by both public pledgeWithoutAReward (with backer as token source) and - * setFeeAndPledge (with admin as token source). + * @notice Internal function that allows a backer to pledge without a reward. + * @dev Called by both the public `pledgeWithoutAReward` (Permit2 transfer) and + * `setFeeAndPledge` (admin ERC20 transfer). * @param pledgeId The unique identifier of the pledge. * @param backer The address of the backer making the pledge (receives the NFT). * @param pledgeToken The token to use for the pledge. * @param pledgeAmount The amount of the pledge. - * @param tip An optional tip can be added during the process. - * @param tokenSource The address from which tokens will be transferred (either backer for direct calls or admin for setFeeAndPledge calls). + * @param tip An optional tip. + * @param tokenSource Token source address for the admin (ERC20) path. + * @param usePermit2 Whether to transfer tokens via Permit2 or direct ERC20 transfer. + * @param permitData Permit2 data for the direct user path. */ function _pledgeWithoutAReward( bytes32 pledgeId, @@ -811,9 +953,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa address pledgeToken, uint256 pledgeAmount, uint256 tip, - address tokenSource + address tokenSource, + bool usePermit2, + PermitData memory permitData ) internal { - bytes32 internalPledgeId = keccak256(abi.encodePacked(pledgeId, _msgSender())); + bytes32 internalPledgeId = pledgeId; if (s_processedPledges[internalPledgeId]) { revert KeepWhatsRaisedPledgeAlreadyProcessed(internalPledgeId); @@ -822,25 +966,64 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa bytes32[] memory emptyByteArray = new bytes32[](0); - _pledge(pledgeId, backer, pledgeToken, ZERO_BYTES, pledgeAmount, tip, emptyByteArray, tokenSource); + _pledge( + pledgeId, + backer, + pledgeToken, + ZERO_BYTES, + pledgeAmount, + tip, + emptyByteArray, + tokenSource, + usePermit2, + permitData + ); } /** * @inheritdoc ICampaignTreasury */ - function withdraw() public view override whenNotPaused whenNotCancelled { + function withdraw() public view override whenCampaignNotPaused whenCampaignNotCancelled whenNotPaused whenNotCancelled { revert KeepWhatsRaisedDisabled(); } + /** + * @dev Computes Colombian creator tax with a single accounting model to avoid double-counting. + * - Partial withdrawal: `amount` is NET (what the creator receives). Tax is additive (fee on top). + * Formula: tax = ceil(net * 40 / 10000). Rounded up per Colombian Peso precision requirements. + * - Final withdrawal: `amount` is GROSS (full remaining balance). Tax is deducted from it. + * Formula: tax = ceil(gross * 40 / 10040) (tax-inclusive rate). Rounded up per Colombian Peso. + * @param amount The net amount (partial) or gross amount (final) in token units. + * @param isFromGross True for final withdrawal (amount = full balance), false for partial (amount = net to creator). + * @return Tax amount in token units (rounded up). + */ + function _colombianCreatorTax(uint256 amount, bool isFromGross) internal pure returns (uint256) { + if (amount == 0) return 0; + if (isFromGross) { + // Gross-including-tax: tax = ceil(gross * 40 / 10040) + return (amount * 40 + 10040 - 1) / 10040; + } else { + // Net amount (additive tax): tax = ceil(net * 40 / 10000) + return (amount * 40 + 10000 - 1) / 10000; + } + } + /** * @dev Allows the campaign owner or platform admin to withdraw funds, applying required fees and taxes. * + * Accounting model (per product requirement): + * - Partial withdrawal: Creator receives the full requested amount; fees (including Colombian tax) are additive + * (deducted from the pool in addition). So: pool -= amount + totalFee, creator gets amount (net). + * - Final withdrawal: Fees (including Colombian tax) are cut from the remaining balance; creator receives + * the remainder. So: pool -= withdrawalAmount, creator gets withdrawalAmount - totalFee (net). + * * @param token The token to withdraw. - * @param amount The withdrawal amount (ignored for final withdrawals). + * @param amount The withdrawal amount (ignored for final withdrawals). For partial, this is the NET amount + * to transfer to the creator; fees are additive. * * Requirements: * - Caller must be authorized. - * - Withdrawals must be enabled, not paused, and within the allowed time. + * - Withdrawals must be enabled, not paused, and within the withdrawal window (current time < deadline + withdrawalDelay). * - Token must be accepted for the campaign. * - For partial withdrawals: * - `amount` > 0 and `amount + fees` ≤ available balance. @@ -862,10 +1045,15 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa public onlyPlatformAdminOrCampaignOwner currentTimeIsLess(getDeadline() + s_config.withdrawalDelay) + whenCampaignNotPaused + whenCampaignNotCancelled whenNotPaused whenNotCancelled withdrawalEnabled { + if (s_fundClaimed) { + revert KeepWhatsRaisedFundAlreadyClaimed(); + } if (!INFO.isTokenAccepted(token)) { revert KeepWhatsRaisedTokenNotAccepted(token); } @@ -876,30 +1064,32 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 minimumWithdrawalForFeeExemption = _denormalizeAmount(token, s_config.minimumWithdrawalForFeeExemption); uint256 currentTime = block.timestamp; - uint256 withdrawalAmount = s_availablePerToken[token]; + uint256 available = s_availablePerToken[token]; + uint256 withdrawalAmount; uint256 totalFee = 0; address recipient = INFO.owner(); bool isFinalWithdrawal = (currentTime > getDeadline()); //Main Fees if (isFinalWithdrawal) { - if (withdrawalAmount == 0) { + if (available == 0) { revert KeepWhatsRaisedAlreadyWithdrawn(); } + withdrawalAmount = available; if (withdrawalAmount < minimumWithdrawalForFeeExemption) { s_platformFeePerToken[token] += flatFee; totalFee += flatFee; } } else { - withdrawalAmount = amount; - if (withdrawalAmount == 0) { - revert KeepWhatsRaisedInvalidInput(); + if (amount == 0) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.ZERO_AMOUNT); } - if (withdrawalAmount > s_availablePerToken[token]) { + if (amount > available) { revert KeepWhatsRaisedInsufficientFundsForWithdrawalAndFee( - s_availablePerToken[token], withdrawalAmount, totalFee + available, amount, totalFee ); } + withdrawalAmount = amount; if (withdrawalAmount < minimumWithdrawalForFeeExemption) { s_platformFeePerToken[token] += cumulativeFee; @@ -910,16 +1100,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa } } - uint256 availableBeforeTax = withdrawalAmount; //The tax implemented is on the withdrawal amount - - // Colombian creator tax + // Colombian creator tax: single accounting model to avoid double-counting. + // Partial: withdrawalAmount = NET (amount to creator); tax is additive (fee on top), formula from net. + // Final: withdrawalAmount = GROSS (full balance); tax is deducted from it, formula from gross. Rounded up to next unit (e.g. Peso). if (s_config.isColombianCreator) { - // Formula: (availableBeforeTax * 0.004) / 1.004 ≈ ((availableBeforeTax * 40) / 10040) - uint256 scaled = availableBeforeTax * PERCENT_DIVIDER; - uint256 numerator = scaled * 40; - uint256 denominator = 10040; - uint256 columbianCreatorTax = numerator / (denominator * PERCENT_DIVIDER); - + uint256 columbianCreatorTax = _colombianCreatorTax(withdrawalAmount, isFinalWithdrawal); s_platformFeePerToken[token] += columbianCreatorTax; totalFee += columbianCreatorTax; } @@ -932,9 +1117,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_availablePerToken[token] = 0; IERC20(token).safeTransfer(recipient, withdrawalAmount - totalFee); } else { - if (s_availablePerToken[token] < (withdrawalAmount + totalFee)) { + if (available < (withdrawalAmount + totalFee)) { revert KeepWhatsRaisedInsufficientFundsForWithdrawalAndFee( - s_availablePerToken[token], withdrawalAmount, totalFee + available, withdrawalAmount, totalFee ); } @@ -962,8 +1147,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotPaused whenNotPaused { + if (s_fundClaimed) { + revert KeepWhatsRaisedFundAlreadyClaimed(); + } if (!_checkRefundPeriodStatus(false)) { - revert KeepWhatsRaisedNotClaimable(tokenId); + revert KeepWhatsRaisedNotClaimable(tokenId, TreasuryErrors.NotClaimable.INVALID_REFUND_PERIOD); } // Get NFT owner before burning @@ -974,9 +1162,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 paymentFee = s_tokenToPaymentFee[tokenId]; uint256 netRefundAmount = amountToRefund - paymentFee; - if (netRefundAmount == 0 || s_availablePerToken[pledgeToken] < netRefundAmount) { - revert KeepWhatsRaisedNotClaimable(tokenId); - } + if (netRefundAmount == 0) revert KeepWhatsRaisedRefundAmountZero(); + if (s_availablePerToken[pledgeToken] < netRefundAmount) revert KeepWhatsRaisedInsufficientAvailableForRefund(tokenId); s_tokenToPledgedAmount[tokenId] = 0; s_tokenRaisedAmounts[pledgeToken] -= amountToRefund; @@ -992,11 +1179,12 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /** * @dev Disburses all accumulated fees to the appropriate fee collector or treasury. + * Callable before or after cancellation so that accrued fees are never trapped. * * Requirements: * - Only callable when fees are available. */ - function disburseFees() public override whenNotPaused whenNotCancelled { + function disburseFees() public override whenCampaignNotPaused whenNotPaused { address[] memory acceptedTokens = INFO.getAcceptedTokens(); address protocolAdmin = INFO.getProtocolAdminAddress(); address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); @@ -1067,9 +1255,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 cancelLimit = s_cancellationTime + s_config.refundDelay; uint256 deadlineLimit = getDeadline() + s_config.withdrawalDelay; - if ((isCancelled && block.timestamp <= cancelLimit) || (!isCancelled && block.timestamp <= deadlineLimit)) { - revert KeepWhatsRaisedNotClaimableAdmin(); - } + if (isCancelled && block.timestamp <= cancelLimit) revert KeepWhatsRaisedClaimFundWindowNotReached(); + if (!isCancelled && block.timestamp <= deadlineLimit) revert KeepWhatsRaisedClaimFundWindowNotReached(); if (s_fundClaimed) { revert KeepWhatsRaisedAlreadyClaimed(); @@ -1107,6 +1294,18 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa return true; } + /** + * @dev Processes a pledge: transfers tokens, mints NFT, and updates state. + * @dev Mints a pledge NFT via `_safeMint`; reverts if `backer` is a contract that does not implement `IERC721Receiver`. + * @param pledgeId Unique identifier for the pledge. + * @param backer Recipient of the pledge NFT. + * @param pledgeToken Token used for the pledge. + * @param reward First reward tier (ZERO_BYTES for non-reward pledges). + * @param pledgeAmount Pledge amount in the token's native decimals (must be denormalized by caller). + * @param tip Tip amount in the token's native decimals. + * @param rewards Full reward selection (for event). + * @param tokenSource Address from which tokens are transferred. + */ function _pledge( bytes32 pledgeId, address backer, @@ -1115,39 +1314,84 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 pledgeAmount, uint256 tip, bytes32[] memory rewards, - address tokenSource + address tokenSource, + bool usePermit2, + PermitData memory permitData ) private { - // Validate token is accepted if (!INFO.isTokenAccepted(pledgeToken)) { revert KeepWhatsRaisedTokenNotAccepted(pledgeToken); } + if (tokenSource == address(this) || backer == address(this)) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.INVALID_BACKER); + } + if (usePermit2 && permitData.signature.length == 0) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + } + if (!usePermit2 && tokenSource == address(0)) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.ZERO_TOKEN_SOURCE); + } - // If this is for a reward, pledgeAmount is in 18 decimals and needs to be denormalized - // If not for a reward (pledgeWithoutAReward), pledgeAmount is already in token decimals - // Tip is always in the pledgeToken's decimals (same token used for payment) uint256 pledgeAmountInTokenDecimals; if (reward != ZERO_BYTES) { - // Reward pledge: denormalize from 18 decimals to token decimals pledgeAmountInTokenDecimals = _denormalizeAmount(pledgeToken, pledgeAmount); } else { - // Non-reward pledge: already in token decimals pledgeAmountInTokenDecimals = pledgeAmount; } uint256 totalAmount = pledgeAmountInTokenDecimals + tip; + uint256 actualPledgeAmount; + + if (usePermit2) { + bytes32 witness; + string memory witnessTypeString; + + if (reward != ZERO_BYTES) { + bytes32 rewardsHash = keccak256(abi.encodePacked(rewards)); + witness = keccak256( + abi.encode(KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, pledgeId, backer, rewardsHash, tip) + ); + witnessTypeString = KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING; + } else { + witness = keccak256( + abi.encode( + KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, + pledgeId, + backer, + pledgeAmountInTokenDecimals, + tip + ) + ); + witnessTypeString = KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING; + } - IERC20(pledgeToken).safeTransferFrom(tokenSource, address(this), totalAmount); + IPermit2(INFO.getPermit2Address()).permitWitnessTransferFrom( + ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: pledgeToken, amount: totalAmount}), + nonce: permitData.nonce, + deadline: permitData.deadline + }), + ISignatureTransfer.SignatureTransferDetails({to: address(this), requestedAmount: totalAmount}), + backer, + witness, + witnessTypeString, + permitData.signature + ); + actualPledgeAmount = pledgeAmountInTokenDecimals; + } else { + IERC20(pledgeToken).safeTransferFrom(tokenSource, address(this), totalAmount); + actualPledgeAmount = pledgeAmountInTokenDecimals; + } - uint256 tokenId = INFO.mintNFTForPledge(backer, reward, pledgeToken, pledgeAmountInTokenDecimals, 0, tip); + uint256 tokenId = INFO.mintNFTForPledge(backer, reward, pledgeToken, actualPledgeAmount, 0, tip); - s_tokenToPledgedAmount[tokenId] = pledgeAmountInTokenDecimals; + s_tokenToPledgedAmount[tokenId] = actualPledgeAmount; s_tokenToTippedAmount[tokenId] = tip; s_tokenIdToPledgeToken[tokenId] = pledgeToken; s_tipPerToken[pledgeToken] += tip; - s_tokenRaisedAmounts[pledgeToken] += pledgeAmountInTokenDecimals; - s_tokenLifetimeRaisedAmounts[pledgeToken] += pledgeAmountInTokenDecimals; + s_tokenRaisedAmounts[pledgeToken] += actualPledgeAmount; + s_tokenLifetimeRaisedAmounts[pledgeToken] += actualPledgeAmount; - uint256 netAvailable = _calculateNetAvailable(pledgeId, pledgeToken, tokenId, pledgeAmountInTokenDecimals); + uint256 netAvailable = _calculateNetAvailable(pledgeId, pledgeToken, tokenId, actualPledgeAmount); s_availablePerToken[pledgeToken] += netAvailable; emit Receipt(backer, pledgeToken, reward, pledgeAmount, tip, tokenId, rewards); @@ -1177,10 +1421,10 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa { uint256 totalFee = 0; - // Gross Percentage Fee Calculation (correct as-is) + // Gross Percentage Fee Calculation uint256 len = s_feeKeys.grossPercentageFeeKeys.length; for (uint256 i = 0; i < len; i++) { - uint256 fee = (pledgeAmount * getFeeValue(s_feeKeys.grossPercentageFeeKeys[i])) / PERCENT_DIVIDER; + uint256 fee = (pledgeAmount * s_grossPercentageFeeValues[i]) / PERCENT_DIVIDER; s_platformFeePerToken[pledgeToken] += fee; totalFee += fee; } diff --git a/src/treasuries/PaymentTreasury.sol b/src/treasuries/PaymentTreasury.sol index 4063fced..328f7719 100644 --- a/src/treasuries/PaymentTreasury.sol +++ b/src/treasuries/PaymentTreasury.sol @@ -5,6 +5,7 @@ import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeE import {BasePaymentTreasury} from "../utils/BasePaymentTreasury.sol"; import {ICampaignPaymentTreasury} from "../interfaces/ICampaignPaymentTreasury.sol"; +import {PermitData} from "../interfaces/IPermit2.sol"; contract PaymentTreasury is BasePaymentTreasury { using SafeERC20 for IERC20; @@ -19,8 +20,8 @@ contract PaymentTreasury is BasePaymentTreasury { */ constructor() {} - function initialize(bytes32 _platformHash, address _infoAddress, address _trustedForwarder) external initializer { - __BaseContract_init(_platformHash, _infoAddress, _trustedForwarder); + function initialize(bytes32 _platformHash, address _infoAddress) external initializer { + __BaseContract_init(_platformHash, _infoAddress); } /** @@ -67,9 +68,19 @@ contract PaymentTreasury is BasePaymentTreasury { address paymentToken, uint256 amount, ICampaignPaymentTreasury.LineItem[] calldata lineItems, - ICampaignPaymentTreasury.ExternalFees[] calldata externalFees + ICampaignPaymentTreasury.ExternalFees[] calldata externalFees, + PermitData calldata permitData ) public override whenNotPaused whenNotCancelled { - super.processCryptoPayment(paymentId, itemId, buyerAddress, paymentToken, amount, lineItems, externalFees); + super.processCryptoPayment( + paymentId, + itemId, + buyerAddress, + paymentToken, + amount, + lineItems, + externalFees, + permitData + ); } /** @@ -101,14 +112,14 @@ contract PaymentTreasury is BasePaymentTreasury { /** * @inheritdoc ICampaignPaymentTreasury */ - function claimRefund(bytes32 paymentId, address refundAddress) public override whenNotPaused whenNotCancelled { + function claimRefund(bytes32 paymentId, address refundAddress) public override whenNotPaused { super.claimRefund(paymentId, refundAddress); } /** * @inheritdoc ICampaignPaymentTreasury */ - function claimRefund(bytes32 paymentId) public override whenNotPaused whenNotCancelled { + function claimRefund(bytes32 paymentId) public override whenNotPaused { super.claimRefund(paymentId); } diff --git a/src/treasuries/TimeConstrainedPaymentTreasury.sol b/src/treasuries/TimeConstrainedPaymentTreasury.sol index cf2f1820..7cd91cd3 100644 --- a/src/treasuries/TimeConstrainedPaymentTreasury.sol +++ b/src/treasuries/TimeConstrainedPaymentTreasury.sol @@ -5,6 +5,7 @@ import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeE import {BasePaymentTreasury} from "../utils/BasePaymentTreasury.sol"; import {ICampaignPaymentTreasury} from "../interfaces/ICampaignPaymentTreasury.sol"; +import {PermitData} from "../interfaces/IPermit2.sol"; import {TimestampChecker} from "../utils/TimestampChecker.sol"; contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker { @@ -20,14 +21,14 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker */ constructor() {} - function initialize(bytes32 _platformHash, address _infoAddress, address _trustedForwarder) external initializer { - __BaseContract_init(_platformHash, _infoAddress, _trustedForwarder); + function initialize(bytes32 _platformHash, address _infoAddress) external initializer { + __BaseContract_init(_platformHash, _infoAddress); } /** - * @dev Internal function to check if current time is within the allowed range. + * @dev Internal function to check if current time is within the campaign window (launchTime to deadline + bufferTime). */ - function _checkTimeWithinRange() internal view { + function _checkTimeWithinCampaignWindow() internal view { uint256 launchTime = INFO.getLaunchTime(); uint256 deadline = INFO.getDeadline(); uint256 bufferTime = INFO.getBufferTime(); @@ -35,9 +36,9 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker } /** - * @dev Internal function to check if current time is greater than launch time. + * @dev Internal function to check if current time is after launch time. */ - function _checkTimeIsGreater() internal view { + function _checkTimeIsAfterLaunch() internal view { uint256 launchTime = INFO.getLaunchTime(); _revertIfCurrentTimeIsNotGreater(launchTime); } @@ -55,7 +56,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker ICampaignPaymentTreasury.LineItem[] calldata lineItems, ICampaignPaymentTreasury.ExternalFees[] calldata externalFees ) public override whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); + _checkTimeWithinCampaignWindow(); super.createPayment(paymentId, buyerId, itemId, paymentToken, amount, expiration, lineItems, externalFees); } @@ -72,7 +73,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker ICampaignPaymentTreasury.LineItem[][] calldata lineItemsArray, ICampaignPaymentTreasury.ExternalFees[][] calldata externalFeesArray ) public override whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); + _checkTimeWithinCampaignWindow(); super.createPaymentBatch( paymentIds, buyerIds, itemIds, paymentTokens, amounts, expirations, lineItemsArray, externalFeesArray ); @@ -88,17 +89,27 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker address paymentToken, uint256 amount, ICampaignPaymentTreasury.LineItem[] calldata lineItems, - ICampaignPaymentTreasury.ExternalFees[] calldata externalFees + ICampaignPaymentTreasury.ExternalFees[] calldata externalFees, + PermitData calldata permitData ) public override whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); - super.processCryptoPayment(paymentId, itemId, buyerAddress, paymentToken, amount, lineItems, externalFees); + _checkTimeWithinCampaignWindow(); + super.processCryptoPayment( + paymentId, + itemId, + buyerAddress, + paymentToken, + amount, + lineItems, + externalFees, + permitData + ); } /** * @inheritdoc ICampaignPaymentTreasury */ function cancelPayment(bytes32 paymentId) public override whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); + _checkTimeWithinCampaignWindow(); super.cancelPayment(paymentId); } @@ -106,7 +117,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker * @inheritdoc ICampaignPaymentTreasury */ function confirmPayment(bytes32 paymentId, address buyerAddress) public override whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); + _checkTimeWithinCampaignWindow(); super.confirmPayment(paymentId, buyerAddress); } @@ -119,23 +130,23 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker whenNotPaused whenNotCancelled { - _checkTimeWithinRange(); + _checkTimeWithinCampaignWindow(); super.confirmPaymentBatch(paymentIds, buyerAddresses); } /** * @inheritdoc ICampaignPaymentTreasury */ - function claimRefund(bytes32 paymentId, address refundAddress) public override whenNotPaused whenNotCancelled { - _checkTimeIsGreater(); + function claimRefund(bytes32 paymentId, address refundAddress) public override whenNotPaused { + _checkTimeIsAfterLaunch(); super.claimRefund(paymentId, refundAddress); } /** * @inheritdoc ICampaignPaymentTreasury */ - function claimRefund(bytes32 paymentId) public override whenNotPaused whenNotCancelled { - _checkTimeIsGreater(); + function claimRefund(bytes32 paymentId) public override whenNotPaused { + _checkTimeIsAfterLaunch(); super.claimRefund(paymentId); } @@ -143,7 +154,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker * @inheritdoc ICampaignPaymentTreasury */ function claimExpiredFunds() public override whenNotPaused { - _checkTimeIsGreater(); + _checkTimeIsAfterLaunch(); super.claimExpiredFunds(); } @@ -151,7 +162,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker * @inheritdoc ICampaignPaymentTreasury */ function disburseFees() public override whenNotPaused { - _checkTimeIsGreater(); + _checkTimeIsAfterLaunch(); super.disburseFees(); } @@ -159,7 +170,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker * @inheritdoc BasePaymentTreasury */ function claimNonGoalLineItems(address token) public override whenNotPaused { - _checkTimeIsGreater(); + _checkTimeIsAfterLaunch(); super.claimNonGoalLineItems(token); } @@ -167,7 +178,7 @@ contract TimeConstrainedPaymentTreasury is BasePaymentTreasury, TimestampChecker * @inheritdoc ICampaignPaymentTreasury */ function withdraw() public override whenNotPaused whenNotCancelled { - _checkTimeIsGreater(); + _checkTimeIsAfterLaunch(); super.withdraw(); } diff --git a/src/utils/BasePaymentTreasury.sol b/src/utils/BasePaymentTreasury.sol index f7e45f44..af714459 100644 --- a/src/utils/BasePaymentTreasury.sol +++ b/src/utils/BasePaymentTreasury.sol @@ -7,9 +7,11 @@ import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.s import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {ICampaignPaymentTreasury} from "../interfaces/ICampaignPaymentTreasury.sol"; +import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; import {CampaignAccessChecker} from "./CampaignAccessChecker.sol"; import {PausableCancellable} from "./PausableCancellable.sol"; import {DataRegistryKeys} from "../constants/DataRegistryKeys.sol"; +import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; /** * @title BasePaymentTreasury @@ -30,14 +32,41 @@ abstract contract BasePaymentTreasury is uint256 internal constant STANDARD_DECIMALS = 18; address internal constant ZERO_ADDRESS = address(0); + // --------------------------------------------------------------------------- + // Permit2 witness type for processCryptoPayment + // --------------------------------------------------------------------------- + bytes32 internal constant CRYPTO_PAYMENT_WITNESS_TYPEHASH = keccak256( + "CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)" + ); + + string internal constant CRYPTO_PAYMENT_WITNESS_TYPE_STRING = + "CryptoPaymentWitness witness)CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)TokenPermissions(address token,uint256 amount)"; + + /// @dev Maximum number of line items per payment. Ensures confirmPayment can always succeed if createPayment did. + uint256 internal constant MAX_LINE_ITEMS = 50; + /// @dev Maximum number of external fee entries per payment. + uint256 internal constant MAX_EXTERNAL_FEES = 50; + /// @dev Maximum number of payments in a single batch call. + uint256 internal constant MAX_BATCH_SIZE = 50; + bytes32 internal PLATFORM_HASH; + /** + * @dev Snapshot of the platform fee percent captured at treasury initialization via + * INFO.getPlatformFeePercent(platformHash). This value is fixed for the lifetime of the + * treasury and will not reflect any subsequent changes to the platform fee in GlobalParams. + * + * The protocol fee accessed during fee calculations via INFO.getProtocolFeePercent() is also + * a snapshot — it is stored in the campaign's CampaignInfo clone at creation time and is + * likewise immutable for the campaign's lifecycle. Despite the asymmetry in how they are + * accessed (cached field vs. getter call), both fees are effectively campaign-level snapshots. + */ uint256 internal PLATFORM_FEE_PERCENT; // Multi-token support mapping(bytes32 => address) internal s_paymentIdToToken; // Track token used for each payment mapping(address => uint256) internal s_platformFeePerToken; // Platform fees per token mapping(address => uint256) internal s_protocolFeePerToken; // Protocol fees per token - mapping(bytes32 => uint256) internal s_paymentIdToTokenId; // Track NFT token ID for each payment (0 means no NFT) + mapping(bytes32 => uint256) internal s_paymentIdToNFTId; // Track NFT token ID for each payment (0 means no NFT) mapping(bytes32 => address) internal s_paymentIdToCreator; // Track creator address for on-chain payments (for getPaymentData lookup) /** @@ -62,6 +91,18 @@ abstract contract BasePaymentTreasury is uint256 lineItemCount; } + /** + * @dev Struct to hold line item calculation totals to reduce stack depth. + */ + struct LineItemTotals { + uint256 totalGoalLineItemAmount; + uint256 totalProtocolFeeFromLineItems; + uint256 totalNonGoalClaimableAmount; + uint256 totalNonGoalRefundableAmount; + uint256 totalInstantTransferAmountForCheck; + uint256 totalInstantTransferAmount; + } + mapping(bytes32 => PaymentInfo) internal s_payment; // Combined line items with their configuration snapshots per payment ID @@ -80,7 +121,7 @@ abstract contract BasePaymentTreasury is mapping(address => uint256) internal s_nonGoalLineItemPendingPerToken; // Pending non-goal line items per token mapping(address => uint256) internal s_nonGoalLineItemConfirmedPerToken; // Confirmed non-goal line items per token mapping(address => uint256) internal s_nonGoalLineItemClaimablePerToken; // Claimable non-goal line items per token (after fees) - mapping(address => uint256) internal s_refundableNonGoalLineItemPerToken; // Refundable non-goal line items per token (after fees) + mapping(address => uint256) internal s_nonGoalRefundableLineItemPerToken; // Refundable non-goal line items per token (after fees) /** * @dev Emitted when a new payment is created. @@ -171,8 +212,26 @@ abstract contract BasePaymentTreasury is /** * @dev Reverts when one or more provided inputs to the payment treasury are invalid. - */ - error PaymentTreasuryInvalidInput(); + * @param code Error code defined in {TreasuryErrors.InvalidInput}. + */ + error PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput code); + + /// @dev Reverts when paymentId is zero. + error PaymentTreasuryZeroPaymentId(); + /// @dev Reverts when buyerId is zero. + error PaymentTreasuryZeroBuyerId(); + /// @dev Reverts when amount is zero. + error PaymentTreasuryZeroAmount(); + /// @dev Reverts when expiration is not in the future. + error PaymentTreasuryExpirationNotInFuture(); + /// @dev Reverts when itemId is zero. + error PaymentTreasuryZeroItemId(); + /// @dev Reverts when paymentToken is the zero address. + error PaymentTreasuryZeroPaymentToken(); + /// @dev Reverts when buyerAddress is the zero address. + error PaymentTreasuryZeroBuyerAddress(); + /// @dev Reverts when batch array lengths are inconsistent. + error PaymentTreasuryBatchArrayLengthMismatch(); /** * @dev Throws an error indicating that the payment id already exists. @@ -222,8 +281,9 @@ abstract contract BasePaymentTreasury is /** * @dev Emitted when claiming an unclaimable refund. * @param paymentId The unique identifier of the refundable payment. + * @param code Error code defined in {TreasuryErrors.NotClaimable}. */ - error PaymentTreasuryPaymentNotClaimable(bytes32 paymentId); + error PaymentTreasuryPaymentNotClaimable(bytes32 paymentId, TreasuryErrors.NotClaimable code); /** * @dev Emitted when an attempt is made to withdraw funds from the treasury but the payment has already been withdrawn. @@ -267,22 +327,39 @@ abstract contract BasePaymentTreasury is */ error PaymentTreasuryNoFundsToClaim(); + constructor() { + _disableInitializers(); + } + + /** + * @dev Throws when the forwarder appends address(0) as the sender. + */ + error PaymentTreasuryInvalidSender(); + + /** + * @dev Throws when an input array exceeds the maximum allowed length. + */ + error PaymentTreasuryArrayTooLong(); + /** * @dev Scopes a payment ID for off-chain payments (createPayment/createPaymentBatch). * @param paymentId The external payment ID. * @return The scoped internal payment ID. */ - function _scopePaymentIdForOffChain(bytes32 paymentId) internal pure returns (bytes32) { + function _getInternalPaymentIdForOffChain(bytes32 paymentId) internal pure returns (bytes32) { return keccak256(abi.encodePacked(paymentId, ZERO_ADDRESS)); } /** * @dev Scopes a payment ID for on-chain crypto payments (processCryptoPayment). + * @dev Scoped by the buyer address (the Permit2 signer) rather than the tx sender, + * so the payment can be looked up by anyone using the stored creator address. * @param paymentId The external payment ID. + * @param owner The buyer/signer address. * @return The scoped internal payment ID. */ - function _scopePaymentIdForOnChain(bytes32 paymentId) internal view returns (bytes32) { - return keccak256(abi.encodePacked(paymentId, _msgSender())); + function _scopePaymentIdForOnChain(bytes32 paymentId, address owner) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(paymentId, owner)); } /** @@ -294,7 +371,7 @@ abstract contract BasePaymentTreasury is */ function _findPaymentId(bytes32 paymentId) internal view returns (bytes32 internalPaymentId) { // Try off-chain scope first (for createPayment) - anyone can look these up - internalPaymentId = _scopePaymentIdForOffChain(paymentId); + internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); if ( s_payment[internalPaymentId].buyerId != ZERO_BYTES || s_payment[internalPaymentId].buyerAddress != address(0) @@ -325,11 +402,11 @@ abstract contract BasePaymentTreasury is * @return duration The max expiration duration in seconds. */ function _getMaxExpirationDuration() internal view returns (bool hasLimit, uint256 duration) { - bytes32 platformScopedKey = + bytes32 expirationDurationKey = DataRegistryKeys.scopedToPlatform(DataRegistryKeys.MAX_PAYMENT_EXPIRATION, PLATFORM_HASH); // Prefer platform-specific value stored in GlobalParams via registry. - bytes32 maxExpirationBytes = INFO.getDataFromRegistry(platformScopedKey); + bytes32 maxExpirationBytes = INFO.getDataFromRegistry(expirationDurationKey); if (maxExpirationBytes == ZERO_BYTES) { maxExpirationBytes = INFO.getDataFromRegistry(DataRegistryKeys.MAX_PAYMENT_EXPIRATION); @@ -340,30 +417,35 @@ abstract contract BasePaymentTreasury is } duration = uint256(maxExpirationBytes); - - if (duration == 0) { - return (false, 0); - } - hasLimit = true; + return (hasLimit, duration); } - function __BaseContract_init(bytes32 platformHash, address infoAddress, address trustedForwarder_) internal { + /** + * @dev Initializes the base payment treasury with platform and campaign context. + * @param platformHash The platform identifier used for fee lookup and access control. + * @param infoAddress The CampaignInfo contract address for campaign data and admin lookups. + */ + function __BaseContract_init(bytes32 platformHash, address infoAddress) internal { __CampaignAccessChecker_init(infoAddress); PLATFORM_HASH = platformHash; PLATFORM_FEE_PERCENT = INFO.getPlatformFeePercent(platformHash); - _trustedForwarder = trustedForwarder_; } /** * @dev Override _msgSender to support ERC-2771 meta-transactions. * When called by the trusted forwarder (adapter), extracts the actual sender from calldata. + * The adapter address is read dynamically from GlobalParams via CampaignInfo so that + * adapter rotations take effect immediately for all deployed treasuries. */ function _msgSender() internal view virtual override returns (address sender) { - if (msg.sender == _trustedForwarder && msg.data.length >= 20) { + if (msg.sender == INFO.getPlatformAdapter(PLATFORM_HASH) && msg.data.length >= 20) { assembly { sender := shr(96, calldataload(sub(calldatasize(), 20))) } + if (sender == address(0)) { + revert PaymentTreasuryInvalidSender(); + } } else { sender = msg.sender; } @@ -410,94 +492,92 @@ abstract contract BasePaymentTreasury is /** * @inheritdoc ICampaignPaymentTreasury + * @return amount Total confirmed payment amount across all tokens, normalized to 18 decimals. */ - function getRaisedAmount() public view virtual override returns (uint256) { + function getRaisedAmount() public view virtual override returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_confirmedPaymentPerToken[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_confirmedPaymentPerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignPaymentTreasury + * @return amount Available confirmed amount across all tokens, normalized to 18 decimals. */ - function getAvailableRaisedAmount() external view returns (uint256) { + function getAvailableRaisedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_availableConfirmedPerToken[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_availableConfirmedPerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignPaymentTreasury + * @return amount Lifetime total confirmed payments across all tokens, normalized to 18 decimals. */ - function getLifetimeRaisedAmount() external view returns (uint256) { + function getLifetimeRaisedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_lifetimeConfirmedPaymentPerToken[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_lifetimeConfirmedPaymentPerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignPaymentTreasury + * @return amount Total refunded amount across all tokens, normalized to 18 decimals. */ - function getRefundedAmount() external view returns (uint256) { + function getRefundedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 lifetimeAmount = s_lifetimeConfirmedPaymentPerToken[token]; - uint256 currentAmount = s_confirmedPaymentPerToken[token]; - uint256 refundedAmount = lifetimeAmount - currentAmount; + uint256 refundedAmount = s_lifetimeConfirmedPaymentPerToken[token] - s_confirmedPaymentPerToken[token]; if (refundedAmount > 0) { - totalNormalized += _normalizeAmount(token, refundedAmount); + amount += _normalizeAmount(token, refundedAmount); } } - return totalNormalized; + return amount; } /** * @inheritdoc ICampaignPaymentTreasury + * @return amount Total pending payment amount across all tokens, normalized to 18 decimals. */ - function getExpectedAmount() external view returns (uint256) { + function getExpectedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); - uint256 totalNormalized = 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 amount = s_pendingPaymentPerToken[token]; - if (amount > 0) { - totalNormalized += _normalizeAmount(token, amount); + uint256 tokenAmount = s_pendingPaymentPerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); } } - return totalNormalized; + return amount; } /** @@ -518,18 +598,6 @@ abstract contract BasePaymentTreasury is } } - /** - * @dev Struct to hold line item calculation totals to reduce stack depth. - */ - struct LineItemTotals { - uint256 totalGoalLineItemAmount; - uint256 totalProtocolFeeFromLineItems; - uint256 totalNonGoalClaimableAmount; - uint256 totalNonGoalRefundableAmount; - uint256 totalInstantTransferAmountForCheck; - uint256 totalInstantTransferAmount; - } - /** * @dev Validates, stores, and tracks line items in a single loop for gas efficiency. * @param paymentId The payment ID to store line items for. @@ -546,7 +614,7 @@ abstract contract BasePaymentTreasury is // Validate line item if (item.typeId == ZERO_BYTES || item.amount == 0) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.INVALID_LINE_ITEM); } // Get line item type configuration (single call per item) @@ -560,7 +628,7 @@ abstract contract BasePaymentTreasury is ) = INFO.getLineItemType(PLATFORM_HASH, item.typeId); if (!exists) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.LINE_ITEM_TYPE_NOT_FOUND); } // Store line item with configuration snapshot @@ -598,11 +666,15 @@ abstract contract BasePaymentTreasury is ICampaignPaymentTreasury.LineItem[] calldata lineItems, ICampaignPaymentTreasury.ExternalFees[] calldata externalFees ) public virtual override onlyPlatformAdmin(PLATFORM_HASH) whenCampaignNotPaused whenCampaignNotCancelled { - if ( - buyerId == ZERO_BYTES || amount == 0 || expiration <= block.timestamp || paymentId == ZERO_BYTES - || itemId == ZERO_BYTES || paymentToken == address(0) - ) { - revert PaymentTreasuryInvalidInput(); + if (paymentId == ZERO_BYTES) revert PaymentTreasuryZeroPaymentId(); + if (buyerId == ZERO_BYTES) revert PaymentTreasuryZeroBuyerId(); + if (itemId == ZERO_BYTES) revert PaymentTreasuryZeroItemId(); + if (paymentToken == address(0)) revert PaymentTreasuryZeroPaymentToken(); + if (amount == 0) revert PaymentTreasuryZeroAmount(); + if (expiration <= block.timestamp) revert PaymentTreasuryExpirationNotInFuture(); + + if (lineItems.length > MAX_LINE_ITEMS || externalFees.length > MAX_EXTERNAL_FEES) { + revert PaymentTreasuryArrayTooLong(); } // Validate expiration does not exceed maximum allowed expiration time (platform-specific or global) @@ -626,7 +698,7 @@ abstract contract BasePaymentTreasury is revert PaymentTreasuryPaymentAlreadyExist(onChainPaymentId); } - bytes32 internalPaymentId = _scopePaymentIdForOffChain(paymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); if ( s_payment[internalPaymentId].buyerId != ZERO_BYTES || s_payment[internalPaymentId].buyerAddress != address(0) @@ -651,11 +723,8 @@ abstract contract BasePaymentTreasury is // Store external fee metadata for informational purposes only ICampaignPaymentTreasury.ExternalFees[] storage externalFeeMetadata = s_paymentExternalFeeMetadata[internalPaymentId]; - for (uint256 i = 0; i < externalFees.length;) { + for (uint256 i = 0; i < externalFees.length; i++) { externalFeeMetadata.push(externalFees[i]); - unchecked { - ++i; - } } s_paymentIdToToken[internalPaymentId] = paymentToken; @@ -679,12 +748,17 @@ abstract contract BasePaymentTreasury is ) public virtual override onlyPlatformAdmin(PLATFORM_HASH) whenCampaignNotPaused whenCampaignNotCancelled { // Validate array lengths are consistent uint256 length = paymentIds.length; - if ( - length == 0 || length != buyerIds.length || length != itemIds.length || length != paymentTokens.length - || length != amounts.length || length != expirations.length || length != lineItemsArray.length - || length != externalFeesArray.length - ) { - revert PaymentTreasuryInvalidInput(); + if (length == 0) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != buyerIds.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != itemIds.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != paymentTokens.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != amounts.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != expirations.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != lineItemsArray.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + if (length != externalFeesArray.length) revert PaymentTreasuryBatchArrayLengthMismatch(); + + if (length > MAX_BATCH_SIZE) { + revert PaymentTreasuryArrayTooLong(); } // Get max expiration duration once outside the loop for efficiency (platform-specific or global) @@ -695,7 +769,7 @@ abstract contract BasePaymentTreasury is } // Process each payment in the batch - for (uint256 i = 0; i < length;) { + for (uint256 i = 0; i < length; i++) { bytes32 paymentId = paymentIds[i]; bytes32 buyerId = buyerIds[i]; bytes32 itemId = itemIds[i]; @@ -705,11 +779,15 @@ abstract contract BasePaymentTreasury is ICampaignPaymentTreasury.LineItem[] calldata lineItems = lineItemsArray[i]; // Validate individual payment parameters - if ( - buyerId == ZERO_BYTES || amount == 0 || expiration <= block.timestamp || paymentId == ZERO_BYTES - || itemId == ZERO_BYTES || paymentToken == address(0) - ) { - revert PaymentTreasuryInvalidInput(); + if (paymentId == ZERO_BYTES) revert PaymentTreasuryZeroPaymentId(); + if (buyerId == ZERO_BYTES) revert PaymentTreasuryZeroBuyerId(); + if (itemId == ZERO_BYTES) revert PaymentTreasuryZeroItemId(); + if (paymentToken == address(0)) revert PaymentTreasuryZeroPaymentToken(); + if (amount == 0) revert PaymentTreasuryZeroAmount(); + if (expiration <= block.timestamp) revert PaymentTreasuryExpirationNotInFuture(); + + if (lineItems.length > MAX_LINE_ITEMS || externalFeesArray[i].length > MAX_EXTERNAL_FEES) { + revert PaymentTreasuryArrayTooLong(); } // Validate expiration does not exceed maximum allowed expiration time @@ -729,7 +807,7 @@ abstract contract BasePaymentTreasury is revert PaymentTreasuryPaymentAlreadyExist(onChainPaymentId); } - bytes32 internalPaymentId = _scopePaymentIdForOffChain(paymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); // Check if payment already exists if ( s_payment[internalPaymentId].buyerId != ZERO_BYTES @@ -757,21 +835,14 @@ abstract contract BasePaymentTreasury is ICampaignPaymentTreasury.ExternalFees[] calldata externalFees = externalFeesArray[i]; ICampaignPaymentTreasury.ExternalFees[] storage externalFeeMetadata = s_paymentExternalFeeMetadata[internalPaymentId]; - for (uint256 j = 0; j < externalFees.length;) { + for (uint256 j = 0; j < externalFees.length; j++) { externalFeeMetadata.push(externalFees[j]); - unchecked { - ++j; - } } s_paymentIdToToken[internalPaymentId] = paymentToken; s_pendingPaymentPerToken[paymentToken] += amount; emit PaymentCreated(address(0), paymentId, buyerId, itemId, paymentToken, amount, expiration, false); - - unchecked { - ++i; - } } emit PaymentBatchCreated(paymentIds); @@ -779,6 +850,8 @@ abstract contract BasePaymentTreasury is /** * @inheritdoc ICampaignPaymentTreasury + * @dev Mints a pledge NFT to `buyerAddress` via `_safeMint`. Reverts if `buyerAddress` is + * a contract that does not implement `IERC721Receiver`. */ function processCryptoPayment( bytes32 paymentId, @@ -787,13 +860,22 @@ abstract contract BasePaymentTreasury is address paymentToken, uint256 amount, ICampaignPaymentTreasury.LineItem[] calldata lineItems, - ICampaignPaymentTreasury.ExternalFees[] calldata externalFees + ICampaignPaymentTreasury.ExternalFees[] calldata externalFees, + PermitData calldata permitData ) public virtual override nonReentrant whenCampaignNotPaused whenCampaignNotCancelled { - if ( - buyerAddress == address(0) || amount == 0 || paymentId == ZERO_BYTES || itemId == ZERO_BYTES - || paymentToken == address(0) - ) { - revert PaymentTreasuryInvalidInput(); + if (paymentId == ZERO_BYTES) revert PaymentTreasuryZeroPaymentId(); + if (buyerAddress == address(0)) revert PaymentTreasuryZeroBuyerAddress(); + if (itemId == ZERO_BYTES) revert PaymentTreasuryZeroItemId(); + if (paymentToken == address(0)) revert PaymentTreasuryZeroPaymentToken(); + if (amount == 0) revert PaymentTreasuryZeroAmount(); + if (permitData.signature.length == 0) revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + + if (buyerAddress == address(this)) { + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.INVALID_BUYER); + } + + if (lineItems.length > MAX_LINE_ITEMS || externalFees.length > MAX_EXTERNAL_FEES) { + revert PaymentTreasuryArrayTooLong(); } // Validate token is accepted @@ -802,7 +884,7 @@ abstract contract BasePaymentTreasury is } // Check if an off-chain payment with the same paymentId already exists - bytes32 offChainPaymentId = _scopePaymentIdForOffChain(paymentId); + bytes32 offChainPaymentId = _getInternalPaymentIdForOffChain(paymentId); if ( s_payment[offChainPaymentId].buyerId != ZERO_BYTES || s_payment[offChainPaymentId].buyerAddress != address(0) @@ -817,8 +899,8 @@ abstract contract BasePaymentTreasury is revert PaymentTreasuryPaymentAlreadyExist(existingPaymentId); } - // Check if an on-chain payment with the same paymentId already exists for this caller - bytes32 internalPaymentId = _scopePaymentIdForOnChain(paymentId); + // Scope by buyerAddress so any relayer can call on behalf of the same buyer + bytes32 internalPaymentId = _scopePaymentIdForOnChain(paymentId, buyerAddress); if ( s_payment[internalPaymentId].buyerAddress != address(0) || s_payment[internalPaymentId].buyerId != ZERO_BYTES @@ -826,6 +908,9 @@ abstract contract BasePaymentTreasury is revert PaymentTreasuryPaymentAlreadyExist(internalPaymentId); } + // Compute lineItemsHash to bind line items in the Permit2 witness + bytes32 lineItemsHash = keccak256(abi.encode(lineItems)); + // Validate, calculate total, store, and process line items uint256 totalAmount = amount; address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); @@ -837,7 +922,7 @@ abstract contract BasePaymentTreasury is // Validate line item if (item.typeId == ZERO_BYTES || item.amount == 0) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.INVALID_LINE_ITEM); } // Get line item type configuration (single call per item) @@ -851,7 +936,7 @@ abstract contract BasePaymentTreasury is ) = INFO.getLineItemType(PLATFORM_HASH, item.typeId); if (!exists) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.LINE_ITEM_TYPE_NOT_FOUND); } // Accumulate total amount @@ -877,14 +962,14 @@ abstract contract BasePaymentTreasury is s_lifetimeConfirmedPaymentPerToken[paymentToken] += item.amount; s_availableConfirmedPerToken[paymentToken] += item.amount; } else { - // Apply protocol fee if applicable - uint256 feeAmount = 0; + uint256 netAmount; if (applyProtocolFee) { uint256 protocolFee = (item.amount * protocolFeePercent) / PERCENT_DIVIDER; - feeAmount += protocolFee; s_protocolFeePerToken[paymentToken] += protocolFee; + netAmount = item.amount - protocolFee; + } else { + netAmount = item.amount; } - uint256 netAmount = item.amount - feeAmount; if (instantTransfer) { // Accumulate for batch transfer after loop @@ -894,7 +979,7 @@ abstract contract BasePaymentTreasury is s_nonGoalLineItemConfirmedPerToken[paymentToken] += netAmount; if (canRefund) { - s_refundableNonGoalLineItemPerToken[paymentToken] += netAmount; + s_nonGoalRefundableLineItemPerToken[paymentToken] += netAmount; } else { s_nonGoalLineItemClaimablePerToken[paymentToken] += netAmount; } @@ -905,14 +990,26 @@ abstract contract BasePaymentTreasury is // Store external fee metadata for informational purposes only ICampaignPaymentTreasury.ExternalFees[] storage externalFeeMetadata = s_paymentExternalFeeMetadata[internalPaymentId]; - for (uint256 i = 0; i < externalFees.length;) { + for (uint256 i = 0; i < externalFees.length; i++) { externalFeeMetadata.push(externalFees[i]); - unchecked { - ++i; - } } - IERC20(paymentToken).safeTransferFrom(buyerAddress, address(this), totalAmount); + bytes32 witness = keccak256( + abi.encode(CRYPTO_PAYMENT_WITNESS_TYPEHASH, paymentId, itemId, buyerAddress, amount, lineItemsHash) + ); + + IPermit2(INFO.getPermit2Address()).permitWitnessTransferFrom( + ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: paymentToken, amount: totalAmount}), + nonce: permitData.nonce, + deadline: permitData.deadline + }), + ISignatureTransfer.SignatureTransferDetails({to: address(this), requestedAmount: totalAmount}), + buyerAddress, + witness, + CRYPTO_PAYMENT_WITNESS_TYPE_STRING, + permitData.signature + ); s_payment[internalPaymentId] = PaymentInfo({ buyerId: ZERO_BYTES, @@ -926,7 +1023,7 @@ abstract contract BasePaymentTreasury is }); s_paymentIdToToken[internalPaymentId] = paymentToken; - s_paymentIdToCreator[paymentId] = _msgSender(); // Store creator address for getPaymentData lookup + s_paymentIdToCreator[paymentId] = buyerAddress; // Scoped by buyer for getPaymentData lookup s_confirmedPaymentPerToken[paymentToken] += amount; s_lifetimeConfirmedPaymentPerToken[paymentToken] += amount; s_availableConfirmedPerToken[paymentToken] += amount; @@ -944,7 +1041,7 @@ abstract contract BasePaymentTreasury is 0, // shippingFee (0 for payment treasuries) 0 // tipAmount (0 for payment treasuries) ); - s_paymentIdToTokenId[internalPaymentId] = tokenId; + s_paymentIdToNFTId[internalPaymentId] = tokenId; emit PaymentCreated(buyerAddress, paymentId, ZERO_BYTES, itemId, paymentToken, amount, 0, true); } @@ -960,8 +1057,8 @@ abstract contract BasePaymentTreasury is whenCampaignNotPaused whenCampaignNotCancelled { - bytes32 internalPaymentId = _scopePaymentIdForOffChain(paymentId); - _validatePaymentForAction(internalPaymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); + _validatePaymentForAction(internalPaymentId, paymentId); address paymentToken = s_paymentIdToToken[internalPaymentId]; uint256 amount = s_payment[internalPaymentId].amount; @@ -1001,23 +1098,19 @@ abstract contract BasePaymentTreasury is for (uint256 i = 0; i < lineItems.length; i++) { ICampaignPaymentTreasury.PaymentLineItem memory item = lineItems[i]; - bool countsTowardGoal = item.countsTowardGoal; - bool applyProtocolFee = item.applyProtocolFee; - bool instantTransfer = item.instantTransfer; - - if (countsTowardGoal) { + if (item.countsTowardGoal) { totals.totalGoalLineItemAmount += item.amount; } else { - uint256 feeAmount = 0; - if (applyProtocolFee) { + uint256 netAmount; + if (item.applyProtocolFee) { uint256 protocolFee = (item.amount * protocolFeePercent) / PERCENT_DIVIDER; totals.totalProtocolFeeFromLineItems += protocolFee; - feeAmount += protocolFee; + netAmount = item.amount - protocolFee; + } else { + netAmount = item.amount; } - uint256 netAmount = item.amount - feeAmount; - - if (instantTransfer) { + if (item.instantTransfer) { totals.totalInstantTransferAmountForCheck += netAmount; } else if (item.canRefund) { totals.totalNonGoalRefundableAmount += netAmount; @@ -1029,7 +1122,7 @@ abstract contract BasePaymentTreasury is } /** - * @dev Checks if there's sufficient balance for payment confirmation. + * @dev Checks if the treasury's actual token balance is sufficient to cover the total amount of the payment being confirmed, plus all previously committed funds (available for withdrawal, fees, and refundable items). * @param paymentToken The token address. * @param paymentAmount The base payment amount. * @param totals Line item totals struct. @@ -1041,7 +1134,7 @@ abstract contract BasePaymentTreasury is uint256 actualBalance = IERC20(paymentToken).balanceOf(address(this)); uint256 currentlyCommitted = s_availableConfirmedPerToken[paymentToken] + s_protocolFeePerToken[paymentToken] + s_platformFeePerToken[paymentToken] + s_nonGoalLineItemClaimablePerToken[paymentToken] - + s_refundableNonGoalLineItemPerToken[paymentToken]; + + s_nonGoalRefundableLineItemPerToken[paymentToken]; uint256 newCommitted = currentlyCommitted + paymentAmount + totals.totalGoalLineItemAmount + totals.totalProtocolFeeFromLineItems + totals.totalNonGoalClaimableAmount @@ -1069,12 +1162,7 @@ abstract contract BasePaymentTreasury is for (uint256 i = 0; i < lineItems.length; i++) { ICampaignPaymentTreasury.PaymentLineItem memory item = lineItems[i]; - bool countsTowardGoal = item.countsTowardGoal; - bool applyProtocolFee = item.applyProtocolFee; - bool canRefund = item.canRefund; - bool instantTransfer = item.instantTransfer; - - if (countsTowardGoal) { + if (item.countsTowardGoal) { s_pendingPaymentPerToken[paymentToken] -= item.amount; s_confirmedPaymentPerToken[paymentToken] += item.amount; s_lifetimeConfirmedPaymentPerToken[paymentToken] += item.amount; @@ -1082,24 +1170,24 @@ abstract contract BasePaymentTreasury is } else { s_nonGoalLineItemPendingPerToken[paymentToken] -= item.amount; - uint256 feeAmount = 0; - if (applyProtocolFee) { + uint256 netAmount; + if (item.applyProtocolFee) { uint256 protocolFee = (item.amount * protocolFeePercent) / PERCENT_DIVIDER; - feeAmount += protocolFee; s_protocolFeePerToken[paymentToken] += protocolFee; + netAmount = item.amount - protocolFee; + } else { + netAmount = item.amount; } - uint256 netAmount = item.amount - feeAmount; - - if (instantTransfer) { + if (item.instantTransfer) { totalInstantTransferAmount += netAmount; // Instant transfer items are not tracked in s_nonGoalLineItemConfirmedPerToken } else { // Track outstanding non-goal balances using net amounts (after fees) s_nonGoalLineItemConfirmedPerToken[paymentToken] += netAmount; - if (canRefund) { - s_refundableNonGoalLineItemPerToken[paymentToken] += netAmount; + if (item.canRefund) { + s_nonGoalRefundableLineItemPerToken[paymentToken] += netAmount; } else { s_nonGoalLineItemClaimablePerToken[paymentToken] += netAmount; } @@ -1110,6 +1198,8 @@ abstract contract BasePaymentTreasury is /** * @inheritdoc ICampaignPaymentTreasury + * @dev If `buyerAddress` is non-zero, mints a pledge NFT via `_safeMint`. Reverts if + * `buyerAddress` is a contract that does not implement `IERC721Receiver`. */ function confirmPayment(bytes32 paymentId, address buyerAddress) public @@ -1120,8 +1210,8 @@ abstract contract BasePaymentTreasury is whenCampaignNotPaused whenCampaignNotCancelled { - bytes32 internalPaymentId = _scopePaymentIdForOffChain(paymentId); - _validatePaymentForAction(internalPaymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); + _validatePaymentForAction(internalPaymentId, paymentId); address paymentToken = s_paymentIdToToken[internalPaymentId]; uint256 paymentAmount = s_payment[internalPaymentId].amount; @@ -1150,7 +1240,7 @@ abstract contract BasePaymentTreasury is s_payment[internalPaymentId].buyerAddress = buyerAddress; bytes32 itemId = s_payment[internalPaymentId].itemId; uint256 tokenId = INFO.mintNFTForPledge(buyerAddress, itemId, paymentToken, paymentAmount, 0, 0); - s_paymentIdToTokenId[internalPaymentId] = tokenId; + s_paymentIdToNFTId[internalPaymentId] = tokenId; } emit PaymentConfirmed(paymentId); @@ -1158,6 +1248,8 @@ abstract contract BasePaymentTreasury is /** * @inheritdoc ICampaignPaymentTreasury + * @dev For each non-zero `buyerAddress`, mints a pledge NFT via `_safeMint`. Reverts if + * any such address is a contract that does not implement `IERC721Receiver`. */ function confirmPaymentBatch(bytes32[] calldata paymentIds, address[] calldata buyerAddresses) public @@ -1170,7 +1262,11 @@ abstract contract BasePaymentTreasury is { // Validate array lengths must match if (buyerAddresses.length != paymentIds.length) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.CONFIRM_BATCH_LENGTH_MISMATCH); + } + + if (paymentIds.length > MAX_BATCH_SIZE) { + revert PaymentTreasuryArrayTooLong(); } bytes32 currentPaymentId; @@ -1179,11 +1275,11 @@ abstract contract BasePaymentTreasury is uint256 protocolFeePercent = INFO.getProtocolFeePercent(); address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); - for (uint256 i = 0; i < paymentIds.length;) { + for (uint256 i = 0; i < paymentIds.length; i++) { currentPaymentId = paymentIds[i]; - bytes32 internalPaymentId = _scopePaymentIdForOffChain(currentPaymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(currentPaymentId); - _validatePaymentForAction(internalPaymentId); + _validatePaymentForAction(internalPaymentId, currentPaymentId); currentToken = s_paymentIdToToken[internalPaymentId]; uint256 amount = s_payment[internalPaymentId].amount; @@ -1211,11 +1307,7 @@ abstract contract BasePaymentTreasury is s_payment[internalPaymentId].buyerAddress = buyerAddress; bytes32 itemId = s_payment[internalPaymentId].itemId; uint256 tokenId = INFO.mintNFTForPledge(buyerAddress, itemId, currentToken, amount, 0, 0); - s_paymentIdToTokenId[internalPaymentId] = tokenId; - } - - unchecked { - ++i; + s_paymentIdToNFTId[internalPaymentId] = tokenId; } } @@ -1235,138 +1327,35 @@ abstract contract BasePaymentTreasury is whenCampaignNotCancelled { if (refundAddress == address(0)) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.ZERO_REFUND_ADDRESS); } - bytes32 internalPaymentId = _scopePaymentIdForOffChain(paymentId); + bytes32 internalPaymentId = _getInternalPaymentIdForOffChain(paymentId); PaymentInfo memory payment = s_payment[internalPaymentId]; address paymentToken = s_paymentIdToToken[internalPaymentId]; uint256 amountToRefund = payment.amount; uint256 availablePaymentAmount = s_availableConfirmedPerToken[paymentToken]; - uint256 tokenId = s_paymentIdToTokenId[internalPaymentId]; + uint256 tokenId = s_paymentIdToNFTId[internalPaymentId]; if (payment.buyerId == ZERO_BYTES) { - revert PaymentTreasuryPaymentNotExist(internalPaymentId); + revert PaymentTreasuryPaymentNotExist(paymentId); } if (!payment.isConfirmed) { - revert PaymentTreasuryPaymentNotConfirmed(internalPaymentId); + revert PaymentTreasuryPaymentNotConfirmed(paymentId); } - if (amountToRefund == 0 || availablePaymentAmount < amountToRefund) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + if (amountToRefund == 0) { + revert PaymentTreasuryPaymentNotClaimable(paymentId, TreasuryErrors.NotClaimable.ZERO_REFUND_AMOUNT); } // This function is for non-NFT payments only if (tokenId != 0) { - revert PaymentTreasuryCryptoPayment(internalPaymentId); - } - - // Use snapshots of line item type configuration from payment creation time - // This prevents issues if line item type configuration changed after payment creation/confirmation - ICampaignPaymentTreasury.PaymentLineItem[] storage lineItems = s_paymentLineItems[internalPaymentId]; - uint256 protocolFeePercent = INFO.getProtocolFeePercent(); - - // Calculate total line item refund amount using snapshots - uint256 totalGoalLineItemRefundAmount = 0; - uint256 totalNonGoalLineItemRefundAmount = 0; - - for (uint256 i = 0; i < lineItems.length; i++) { - ICampaignPaymentTreasury.PaymentLineItem memory item = lineItems[i]; - - // Use snapshot flags instead of current configuration - if (!item.canRefund) { - continue; // Skip non-refundable line items (based on snapshot at creation time) - } - - if (item.countsTowardGoal) { - // Goal line items: full amount is refundable from goal tracking - totalGoalLineItemRefundAmount += item.amount; - } else { - // Non-goal line items: handle fees and instant transfers - // For instant transfer items, the net amount was already sent to platform admin - don't refund - // For non-instant items, only refund the net amount (after fees), not the fees themselves - if (item.instantTransfer) { - // Skip instant transfer items - they were already sent to platform admin - continue; - } - - uint256 feeAmount = 0; - if (item.applyProtocolFee) { - feeAmount = (item.amount * protocolFeePercent) / PERCENT_DIVIDER; - } - uint256 netAmount = item.amount - feeAmount; - - // Only refund the net amount (fees are not refundable) - totalNonGoalLineItemRefundAmount += netAmount; - } - } - - // Check that we have enough available balance for the total refund (BEFORE modifying state) - // Goal line items are in availableConfirmedPerToken, non-goal items need separate check - uint256 totalRefundAmount = amountToRefund + totalGoalLineItemRefundAmount + totalNonGoalLineItemRefundAmount; - - // For goal line items and base payment, check availableConfirmedPerToken - if (availablePaymentAmount < (amountToRefund + totalGoalLineItemRefundAmount)) { - revert PaymentTreasuryPaymentNotClaimable(paymentId); + revert PaymentTreasuryCryptoPayment(paymentId); } - - // For non-goal line items, check that we have enough claimable balance - // (only non-instant transfer items are refundable, and only their net amounts after fees) - if (totalNonGoalLineItemRefundAmount > 0) { - uint256 availableRefundable = s_refundableNonGoalLineItemPerToken[paymentToken]; - if (availableRefundable < totalNonGoalLineItemRefundAmount) { - revert PaymentTreasuryPaymentNotClaimable(paymentId); - } + if (availablePaymentAmount < amountToRefund) { + revert PaymentTreasuryPaymentNotClaimable(paymentId, TreasuryErrors.NotClaimable.INSUFFICIENT_LIQUIDITY); } - // Check that contract has enough actual balance to perform the transfer - uint256 contractBalance = IERC20(paymentToken).balanceOf(address(this)); - if (contractBalance < totalRefundAmount) { - revert PaymentTreasuryPaymentNotClaimable(paymentId); - } - - // Update state: remove tracking for refundable line items using snapshots - for (uint256 i = 0; i < lineItems.length; i++) { - ICampaignPaymentTreasury.PaymentLineItem memory item = lineItems[i]; - - // Use snapshot flags instead of current configuration - if (!item.canRefund) { - continue; // Skip non-refundable line items (based on snapshot at creation time) - } - - if (item.countsTowardGoal) { - // Goal line items: remove from goal tracking - s_confirmedPaymentPerToken[paymentToken] -= item.amount; - s_availableConfirmedPerToken[paymentToken] -= item.amount; - } else { - // Non-goal line items: remove from non-goal tracking - // Note: instantTransfer items are skipped in the refund calculation above - if (item.instantTransfer) { - // Instant transfer items were already sent to platform admin; nothing tracked - continue; - } - - // Calculate fees and net amount using snapshot - uint256 feeAmount = 0; - if (item.applyProtocolFee) { - feeAmount = (item.amount * protocolFeePercent) / PERCENT_DIVIDER; - // Fees are NOT refunded - they remain in the protocol fee pool - } - - uint256 netAmount = item.amount - feeAmount; - - // Remove net amount from outstanding non-goal tracking - s_nonGoalLineItemConfirmedPerToken[paymentToken] -= netAmount; - - // Remove from refundable tracking (only net amount is refundable) - s_refundableNonGoalLineItemPerToken[paymentToken] -= netAmount; - } - } - - delete s_payment[internalPaymentId]; - delete s_paymentIdToToken[internalPaymentId]; - delete s_paymentLineItems[internalPaymentId]; - delete s_paymentExternalFeeMetadata[internalPaymentId]; - - s_confirmedPaymentPerToken[paymentToken] -= amountToRefund; - s_availableConfirmedPerToken[paymentToken] -= amountToRefund; + uint256 totalRefundAmount = _executeRefund( + internalPaymentId, paymentToken, amountToRefund, availablePaymentAmount, paymentId + ); IERC20(paymentToken).safeTransfer(refundAddress, totalRefundAmount); emit RefundClaimed(paymentId, totalRefundAmount, refundAddress); @@ -1387,22 +1376,53 @@ abstract contract BasePaymentTreasury is address buyerAddress = payment.buyerAddress; uint256 amountToRefund = payment.amount; uint256 availablePaymentAmount = s_availableConfirmedPerToken[paymentToken]; - uint256 tokenId = s_paymentIdToTokenId[internalPaymentId]; + uint256 tokenId = s_paymentIdToNFTId[internalPaymentId]; if (buyerAddress == address(0)) { - revert PaymentTreasuryPaymentNotExist(internalPaymentId); + revert PaymentTreasuryPaymentNotClaimable(paymentId, TreasuryErrors.NotClaimable.ZERO_REFUND_ADDRESS); } - if (amountToRefund == 0 || availablePaymentAmount < amountToRefund) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + if (amountToRefund == 0) { + revert PaymentTreasuryPaymentNotClaimable(paymentId, TreasuryErrors.NotClaimable.INSUFFICIENT_LIQUIDITY); } // NFT must exist for crypto payments if (tokenId == 0) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + revert PaymentTreasuryPaymentNotClaimable(paymentId, TreasuryErrors.NotClaimable.NOT_NFT_PAYMENT); } // Get NFT owner before burning address nftOwner = INFO.ownerOf(tokenId); + uint256 totalRefundAmount = _executeRefund( + internalPaymentId, paymentToken, amountToRefund, availablePaymentAmount, paymentId + ); + + delete s_paymentIdToCreator[paymentId]; + + // Burn NFT (requires treasury approval from owner) + INFO.burn(tokenId); + + IERC20(paymentToken).safeTransfer(nftOwner, totalRefundAmount); + emit RefundClaimed(paymentId, totalRefundAmount, nftOwner); + } + + /** + * @dev Shared refund logic for both claimRefund overloads. + * Calculates refund amounts from line item snapshots, validates balances, + * updates state, removes common storage entries, and returns the total refund amount. + * @param internalPaymentId The scoped internal payment ID. + * @param paymentToken The token used for the payment. + * @param amountToRefund The base payment amount to refund. + * @param availablePaymentAmount The available confirmed amount for this token. + * @param revertId The payment ID to use in revert messages (preserves original error context). + * @return totalRefundAmount The total amount to transfer to the refund recipient. + */ + function _executeRefund( + bytes32 internalPaymentId, + address paymentToken, + uint256 amountToRefund, + uint256 availablePaymentAmount, + bytes32 revertId + ) private returns (uint256 totalRefundAmount) { // Use snapshots of line item type configuration from payment creation time // This prevents issues if line item type configuration changed after payment creation/confirmation ICampaignPaymentTreasury.PaymentLineItem[] storage lineItems = s_paymentLineItems[internalPaymentId]; @@ -1445,26 +1465,26 @@ abstract contract BasePaymentTreasury is // Check that we have enough available balance for the total refund (BEFORE modifying state) // Goal line items are in availableConfirmedPerToken, non-goal items need separate check - uint256 totalRefundAmount = amountToRefund + totalGoalLineItemRefundAmount + totalNonGoalLineItemRefundAmount; + totalRefundAmount = amountToRefund + totalGoalLineItemRefundAmount + totalNonGoalLineItemRefundAmount; // For goal line items and base payment, check availableConfirmedPerToken if (availablePaymentAmount < (amountToRefund + totalGoalLineItemRefundAmount)) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + revert PaymentTreasuryPaymentNotClaimable(revertId, TreasuryErrors.NotClaimable.INSUFFICIENT_GOAL_LIQUIDITY); } // For non-goal line items, check that we have enough claimable balance // (only non-instant transfer items are refundable, and only their net amounts after fees) if (totalNonGoalLineItemRefundAmount > 0) { - uint256 availableRefundable = s_refundableNonGoalLineItemPerToken[paymentToken]; + uint256 availableRefundable = s_nonGoalRefundableLineItemPerToken[paymentToken]; if (availableRefundable < totalNonGoalLineItemRefundAmount) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + revert PaymentTreasuryPaymentNotClaimable(revertId, TreasuryErrors.NotClaimable.INSUFFICIENT_NON_GOAL_LIQUIDITY); } } // Check that contract has enough actual balance to perform the transfer uint256 contractBalance = IERC20(paymentToken).balanceOf(address(this)); if (contractBalance < totalRefundAmount) { - revert PaymentTreasuryPaymentNotClaimable(internalPaymentId); + revert PaymentTreasuryPaymentNotClaimable(revertId, TreasuryErrors.NotClaimable.INSUFFICIENT_CONTRACT_BALANCE); } // Update state: remove tracking for refundable line items using snapshots @@ -1501,25 +1521,19 @@ abstract contract BasePaymentTreasury is s_nonGoalLineItemConfirmedPerToken[paymentToken] -= netAmount; // Remove from refundable tracking (only net amount is refundable) - s_refundableNonGoalLineItemPerToken[paymentToken] -= netAmount; + s_nonGoalRefundableLineItemPerToken[paymentToken] -= netAmount; } } + // Clean up common storage entries delete s_payment[internalPaymentId]; delete s_paymentIdToToken[internalPaymentId]; delete s_paymentLineItems[internalPaymentId]; delete s_paymentExternalFeeMetadata[internalPaymentId]; - delete s_paymentIdToTokenId[internalPaymentId]; - delete s_paymentIdToCreator[paymentId]; // Clean up creator mapping for on-chain payments + delete s_paymentIdToNFTId[internalPaymentId]; s_confirmedPaymentPerToken[paymentToken] -= amountToRefund; s_availableConfirmedPerToken[paymentToken] -= amountToRefund; - - // Burn NFT (requires treasury approval from owner) - INFO.burn(tokenId); - - IERC20(paymentToken).safeTransfer(nftOwner, totalRefundAmount); - emit RefundClaimed(paymentId, totalRefundAmount, nftOwner); } /** @@ -1568,7 +1582,7 @@ abstract contract BasePaymentTreasury is uint256 claimableAmount = s_nonGoalLineItemClaimablePerToken[token]; if (claimableAmount == 0) { - revert PaymentTreasuryInvalidInput(); + revert PaymentTreasuryInvalidInput(TreasuryErrors.InvalidInput.ZERO_CLAIMABLE_AMOUNT); } s_nonGoalLineItemClaimablePerToken[token] = 0; @@ -1605,7 +1619,7 @@ abstract contract BasePaymentTreasury is uint256 availableConfirmed = s_availableConfirmedPerToken[token]; uint256 claimableAmount = s_nonGoalLineItemClaimablePerToken[token]; - uint256 refundableAmount = s_refundableNonGoalLineItemPerToken[token]; + uint256 refundableAmount = s_nonGoalRefundableLineItemPerToken[token]; uint256 platformFeeAmount = s_platformFeePerToken[token]; uint256 protocolFeeAmount = s_protocolFeePerToken[token]; @@ -1629,7 +1643,7 @@ abstract contract BasePaymentTreasury is s_nonGoalLineItemConfirmedPerToken[token] = currentNonGoalConfirmed > reduction ? currentNonGoalConfirmed - reduction : 0; s_nonGoalLineItemClaimablePerToken[token] = 0; - s_refundableNonGoalLineItemPerToken[token] = 0; + s_nonGoalRefundableLineItemPerToken[token] = 0; } if (platformFeeAmount > 0) { @@ -1737,14 +1751,6 @@ abstract contract BasePaymentTreasury is _cancel(message); } - /** - * @notice Returns true if the treasury has been cancelled. - * @return True if cancelled, false otherwise. - */ - function cancelled() public view virtual override(ICampaignPaymentTreasury, PausableCancellable) returns (bool) { - return super.cancelled(); - } - /** * @dev Internal function to check if the campaign is paused. * If the campaign is paused, it reverts with PaymentTreasuryCampaignInfoIsPaused error. @@ -1756,7 +1762,7 @@ abstract contract BasePaymentTreasury is } function _revertIfCampaignCancelled() internal view { - if (INFO.cancelled()) { + if (PausableCancellable(address(INFO)).cancelled()) { revert PaymentTreasuryCampaignInfoIsPaused(); } } @@ -1768,10 +1774,11 @@ abstract contract BasePaymentTreasury is * - The payment has already been confirmed. * - The payment has already expired. * - The payment is a crypto payment - * @param paymentId The unique identifier of the payment to validate. + * @param internalPaymentId The scoped internal payment ID used for storage lookup. + * @param paymentId The external payment ID used in revert messages for caller clarity. */ - function _validatePaymentForAction(bytes32 paymentId) internal view { - PaymentInfo memory payment = s_payment[paymentId]; + function _validatePaymentForAction(bytes32 internalPaymentId, bytes32 paymentId) internal view { + PaymentInfo memory payment = s_payment[internalPaymentId]; if (payment.buyerId == ZERO_BYTES) { revert PaymentTreasuryPaymentNotExist(paymentId); diff --git a/src/utils/BaseTreasury.sol b/src/utils/BaseTreasury.sol index dc224685..fbfcc32d 100644 --- a/src/utils/BaseTreasury.sol +++ b/src/utils/BaseTreasury.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.22; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {ICampaignTreasury} from "../interfaces/ICampaignTreasury.sol"; import {CampaignAccessChecker} from "./CampaignAccessChecker.sol"; @@ -16,7 +17,7 @@ import {PausableCancellable} from "./PausableCancellable.sol"; * @dev Supports ERC-2771 meta-transactions via adapter contracts for platform admin operations. * @dev Contracts implementing this base contract should provide specific success conditions. */ -abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAccessChecker, PausableCancellable { +abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAccessChecker, PausableCancellable, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 internal constant ZERO_BYTES = 0x0000000000000000000000000000000000000000000000000000000000000000; @@ -24,6 +25,16 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce uint256 internal constant STANDARD_DECIMALS = 18; bytes32 internal PLATFORM_HASH; + /** + * @dev Snapshot of the platform fee percent captured at treasury initialization via + * INFO.getPlatformFeePercent(platformHash). This value is fixed for the lifetime of the + * treasury and will not reflect any subsequent changes to the platform fee in GlobalParams. + * + * The protocol fee accessed during disburseFees() via INFO.getProtocolFeePercent() is also + * a snapshot — it is stored in the campaign's CampaignInfo clone at creation time and is + * likewise immutable for the campaign's lifecycle. Despite the asymmetry in how they are + * accessed (cached field vs. getter call), both fees are effectively campaign-level snapshots. + */ uint256 internal PLATFORM_FEE_PERCENT; bool internal s_feesDisbursed; @@ -68,27 +79,50 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce */ error TreasuryFeeNotDisbursed(); + /** + * @dev Throws an error indicating that fees have already been disbursed. + */ + error TreasuryFeeAlreadyDisbursed(); + /** * @dev Throws an error indicating that the campaign is paused. */ error TreasuryCampaignInfoIsPaused(); + + /** + * @dev Throws when the forwarder appends address(0) as the sender. + */ + error TreasuryInvalidSender(); + + constructor() { + _disableInitializers(); + } - function __BaseContract_init(bytes32 platformHash, address infoAddress, address trustedForwarder_) internal { + /** + * @dev Initializes the base treasury with platform and campaign context. + * @param platformHash The platform identifier used for fee lookup and access control. + * @param infoAddress The CampaignInfo contract address for campaign data and admin lookups. + */ + function __BaseContract_init(bytes32 platformHash, address infoAddress) internal { __CampaignAccessChecker_init(infoAddress); PLATFORM_HASH = platformHash; PLATFORM_FEE_PERCENT = INFO.getPlatformFeePercent(platformHash); - _trustedForwarder = trustedForwarder_; } /** * @dev Override _msgSender to support ERC-2771 meta-transactions. * When called by the trusted forwarder (adapter), extracts the actual sender from calldata. + * The adapter address is read dynamically from GlobalParams via CampaignInfo so that + * adapter rotations take effect immediately for all deployed treasuries. */ function _msgSender() internal view virtual override returns (address sender) { - if (msg.sender == _trustedForwarder && msg.data.length >= 20) { + if (msg.sender == INFO.getPlatformAdapter(PLATFORM_HASH) && msg.data.length >= 20) { assembly { sender := shr(96, calldataload(sub(calldatasize(), 20))) } + if (sender == address(0)) { + revert TreasuryInvalidSender(); + } } else { sender = msg.sender; } @@ -165,11 +199,16 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce /** * @inheritdoc ICampaignTreasury */ - function disburseFees() public virtual override whenCampaignNotPaused whenCampaignNotCancelled { + function disburseFees() public virtual override nonReentrant whenCampaignNotPaused whenCampaignNotCancelled { + if (s_feesDisbursed) { + revert TreasuryFeeAlreadyDisbursed(); + } if (!_checkSuccessCondition()) { revert TreasurySuccessConditionNotFulfilled(); } + s_feesDisbursed = true; + address[] memory acceptedTokens = INFO.getAcceptedTokens(); // Disburse fees for each token @@ -178,6 +217,10 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce uint256 balance = s_tokenRaisedAmounts[token]; if (balance > 0) { + // Both fees are campaign-level snapshots: PLATFORM_FEE_PERCENT is cached + // in treasury storage at init; INFO.getProtocolFeePercent() reads the value + // stored in the CampaignInfo clone at campaign creation — neither reflects + // live GlobalParams state at the time of disbursement. uint256 protocolShare = (balance * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; uint256 platformShare = (balance * PLATFORM_FEE_PERCENT) / PERCENT_DIVIDER; @@ -192,8 +235,6 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce emit FeesDisbursed(token, protocolShare, platformShare); } } - - s_feesDisbursed = true; } /** @@ -240,14 +281,6 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce _cancel(message); } - /** - * @notice Returns true if the treasury has been cancelled. - * @return True if cancelled, false otherwise. - */ - function cancelled() public view virtual override(ICampaignTreasury, PausableCancellable) returns (bool) { - return super.cancelled(); - } - /** * @dev Internal function to check if the campaign is paused. * If the campaign is paused, it reverts with TreasuryCampaignInfoIsPaused error. @@ -259,7 +292,7 @@ abstract contract BaseTreasury is Initializable, ICampaignTreasury, CampaignAcce } function _revertIfCampaignCancelled() internal view { - if (INFO.cancelled()) { + if (PausableCancellable(address(INFO)).cancelled()) { revert TreasuryCampaignInfoIsPaused(); } } diff --git a/src/utils/CampaignAccessChecker.sol b/src/utils/CampaignAccessChecker.sol index dd0869ed..437fd4b3 100644 --- a/src/utils/CampaignAccessChecker.sol +++ b/src/utils/CampaignAccessChecker.sol @@ -13,9 +13,6 @@ abstract contract CampaignAccessChecker is Context { // Immutable reference to the ICampaignInfo contract, which provides campaign-related information and admin addresses. ICampaignInfo internal INFO; - /// @dev Trusted forwarder address for ERC-2771 meta-transactions (set by derived contracts) - address internal _trustedForwarder; - /** * @dev Throws when the caller is not authorized. */ diff --git a/src/utils/FiatEnabled.sol b/src/utils/FiatEnabled.sol index 378f7d1a..b64b34ea 100644 --- a/src/utils/FiatEnabled.sol +++ b/src/utils/FiatEnabled.sol @@ -39,6 +39,12 @@ abstract contract FiatEnabled { */ error FiatEnabledInvalidTransaction(); + /** + * @dev Throws when a fiat transaction ID has already been recorded. + * @param fiatTransactionId The duplicate fiat transaction identifier. + */ + error FiatEnabledTransactionAlreadyRecorded(bytes32 fiatTransactionId); + /** * @notice Get the total amount of fiat raised. * @return The total fiat raised amount. @@ -73,6 +79,9 @@ abstract contract FiatEnabled { * @param fiatTransactionAmount The amount of the fiat transaction. */ function _updateFiatTransaction(bytes32 fiatTransactionId, uint256 fiatTransactionAmount) internal { + if (s_fiatAmountById[fiatTransactionId] != 0) { + revert FiatEnabledTransactionAlreadyRecorded(fiatTransactionId); + } s_fiatAmountById[fiatTransactionId] = fiatTransactionAmount; s_fiatRaisedAmount += fiatTransactionAmount; emit FiatTransactionUpdated(fiatTransactionId, fiatTransactionAmount); diff --git a/src/utils/ItemRegistry.sol b/src/utils/ItemRegistry.sol index e1931739..c3a33f35 100644 --- a/src/utils/ItemRegistry.sol +++ b/src/utils/ItemRegistry.sol @@ -11,6 +11,7 @@ import {IItem} from "../interfaces/IItem.sol"; */ contract ItemRegistry is IItem, Context { mapping(address => mapping(bytes32 => Item)) private Items; + mapping(address => mapping(bytes32 => bool)) private s_itemExists; /** * @dev Emitted when a new item is added to the registry. @@ -20,11 +21,36 @@ contract ItemRegistry is IItem, Context { */ event ItemAdded(address indexed owner, bytes32 indexed itemId, Item item); + /** + * @dev Emitted when an item is removed from the registry. + * @param owner The address of the item owner. + * @param itemId The unique identifier of the item. + */ + event ItemRemoved(address indexed owner, bytes32 indexed itemId); + /** * @dev Thrown when the input arrays have mismatched lengths. */ error ItemRegistryMismatchedArraysLength(); + /** + * @dev Thrown when attempting to add an item that already exists (overwrite not allowed). + * @param itemId The item identifier that already exists. + */ + error ItemRegistryItemAlreadyExists(bytes32 itemId); + + /** + * @dev Thrown when the batch contains duplicate itemIds. + * @param itemId The duplicate item identifier. + */ + error ItemRegistryDuplicateItemId(bytes32 itemId); + + /** + * @dev Thrown when attempting to remove an item that does not exist. + * @param itemId The item identifier. + */ + error ItemRegistryItemDoesNotExist(bytes32 itemId); + /** * @inheritdoc IItem */ @@ -36,7 +62,9 @@ contract ItemRegistry is IItem, Context { * @inheritdoc IItem */ function addItem(bytes32 itemId, Item calldata item) external override { + if (s_itemExists[_msgSender()][itemId]) revert ItemRegistryItemAlreadyExists(itemId); Items[_msgSender()][itemId] = item; + s_itemExists[_msgSender()][itemId] = true; emit ItemAdded(_msgSender(), itemId, item); } @@ -50,12 +78,31 @@ contract ItemRegistry is IItem, Context { revert ItemRegistryMismatchedArraysLength(); } + address owner = _msgSender(); for (uint256 i = 0; i < itemIds.length; i++) { bytes32 itemId = itemIds[i]; - Item calldata item = items[i]; - Items[_msgSender()][itemId] = item; - emit ItemAdded(_msgSender(), itemId, item); + for (uint256 j = 0; j < i; j++) { + if (itemIds[j] == itemId) revert ItemRegistryDuplicateItemId(itemId); + } + + if (s_itemExists[owner][itemId]) revert ItemRegistryItemAlreadyExists(itemId); + + Item calldata item = items[i]; + Items[owner][itemId] = item; + s_itemExists[owner][itemId] = true; + emit ItemAdded(owner, itemId, item); } } + + /** + * @notice Removes an item from the caller's registry. + * @param itemId The unique identifier of the item to remove. + */ + function removeItem(bytes32 itemId) external { + if (!s_itemExists[_msgSender()][itemId]) revert ItemRegistryItemDoesNotExist(itemId); + delete Items[_msgSender()][itemId]; + s_itemExists[_msgSender()][itemId] = false; + emit ItemRemoved(_msgSender(), itemId); + } } diff --git a/src/utils/PledgeNFT.sol b/src/utils/PledgeNFT.sol index 7d854806..828dd5c1 100644 --- a/src/utils/PledgeNFT.sol +++ b/src/utils/PledgeNFT.sol @@ -18,7 +18,8 @@ abstract contract PledgeNFT is ERC721Burnable, AccessControl { using Strings for address; using Counters for Counters.Counter; - bytes32 public constant MINTER_ROLE = 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6; + /// @dev keccak256(bytes("TREASURY_ROLE")) + bytes32 public constant TREASURY_ROLE = 0xe1dcbdb91df27212a29bc27177c840cf2f819ecf2187432e1fac86c2dd5dfca9; /** * @dev Struct to store pledge data for each token @@ -91,6 +92,7 @@ abstract contract PledgeNFT is ERC721Burnable, AccessControl { string calldata _contractURI ) internal { _validateJsonString(_nftName); + _validateJsonString(_imageURI); s_nftName = _nftName; s_nftSymbol = _nftSymbol; s_imageURI = _imageURI; @@ -115,7 +117,9 @@ abstract contract PledgeNFT is ERC721Burnable, AccessControl { /** * @notice Mints a pledge NFT (auto-increments counter) - * @dev Called by treasuries - returns the new token ID to use as pledge ID + * @dev Called by treasuries - returns the new token ID to use as pledge ID. + * Uses `_safeMint`, so `backer` must be an EOA or a contract that implements + * `IERC721Receiver`; otherwise the transaction will revert. * @param backer The backer address * @param reward The reward identifier * @param tokenAddress The address of the token used for the pledge @@ -131,7 +135,7 @@ abstract contract PledgeNFT is ERC721Burnable, AccessControl { uint256 amount, uint256 shippingFee, uint256 tipAmount - ) public virtual onlyRole(MINTER_ROLE) returns (uint256 tokenId) { + ) public virtual onlyRole(TREASURY_ROLE) returns (uint256 tokenId) { // Increment counter and get new token ID s_tokenIdCounter.increment(); tokenId = s_tokenIdCounter.current(); @@ -159,7 +163,7 @@ abstract contract PledgeNFT is ERC721Burnable, AccessControl { * @notice Burns a pledge NFT * @param tokenId The token ID to burn */ - function burn(uint256 tokenId) public virtual override { + function burn(uint256 tokenId) public virtual override onlyRole(TREASURY_ROLE) { delete s_pledgeData[tokenId]; super.burn(tokenId); } diff --git a/test/foundry/Base.t.sol b/test/foundry/Base.t.sol index 55b79115..e4e78bd7 100644 --- a/test/foundry/Base.t.sol +++ b/test/foundry/Base.t.sol @@ -12,13 +12,21 @@ import {TreasuryFactory} from "src/TreasuryFactory.sol"; import {AllOrNothing} from "src/treasuries/AllOrNothing.sol"; import {KeepWhatsRaised} from "src/treasuries/KeepWhatsRaised.sol"; import {IGlobalParams} from "src/interfaces/IGlobalParams.sol"; +import {IPermit2, PermitData} from "src/interfaces/IPermit2.sol"; +import {MockPermit2} from "../mocks/MockPermit2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {DataRegistryKeys} from "src/constants/DataRegistryKeys.sol"; /// @notice Base test contract with common logic needed by all tests. abstract contract Base_Test is Test, Defaults { + bytes32 internal constant PERMIT2_TOKEN_PERMISSIONS_TYPEHASH = + keccak256("TokenPermissions(address token,uint256 amount)"); + string internal constant PERMIT2_WITNESS_TYPE_STRING_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + //Variables Users internal users; + mapping(address => uint256) internal userPrivateKeys; //Test Contracts - Multiple tokens for multi-token testing TestToken internal usdtToken; // 6 decimals - Tether @@ -35,6 +43,36 @@ abstract contract Base_Test is Test, Defaults { KeepWhatsRaised internal keepWhatsRaisedImplementation; CampaignInfo internal campaignInfo; + /// @dev Canonical Permit2 address used by the contracts under test. + address internal constant CANONICAL_PERMIT2_ADDRESS = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + function _buildSignedPermitData( + address owner, + address spender, + address token, + uint256 amount, + bytes32 witness, + string memory witnessTypeString, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 ownerPrivateKey = userPrivateKeys[owner]; + require(ownerPrivateKey != 0, "missing test private key"); + + bytes32 witnessTypeHash = + keccak256(bytes(string(abi.encodePacked(PERMIT2_WITNESS_TYPE_STRING_STUB, witnessTypeString)))); + bytes32 tokenPermissions = + keccak256(abi.encode(PERMIT2_TOKEN_PERMISSIONS_TYPEHASH, token, amount)); + bytes32 structHash = keccak256(abi.encode(witnessTypeHash, tokenPermissions, spender, nonce, deadline, witness)); + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", IPermit2(CANONICAL_PERMIT2_ADDRESS).DOMAIN_SEPARATOR(), structHash) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest); + + return PermitData({nonce: nonce, deadline: deadline, signature: abi.encodePacked(r, s, v)}); + } + function setUp() public virtual { // Create users for testing. users = Users({ @@ -50,6 +88,10 @@ abstract contract Base_Test is Test, Defaults { vm.startPrank(users.contractOwner); + // Deploy our MockPermit2 (solc 0.8.22 compatible) at the canonical address + // so that treasury contracts (which use that address via GlobalParams) work in tests. + deployCodeTo("MockPermit2.sol:MockPermit2", CANONICAL_PERMIT2_ADDRESS); + // Deploy multiple test tokens with different decimals usdtToken = new TestToken("Tether USD", "USDT", 6); usdcToken = new TestToken("USD Coin", "USDC", 6); @@ -95,7 +137,6 @@ abstract contract Base_Test is Test, Defaults { CampaignInfoFactory campaignFactoryImpl = new CampaignInfoFactory(); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - users.contractOwner, IGlobalParams(address(globalParams)), address(campaignInfo), address(treasuryFactory) @@ -110,6 +151,7 @@ abstract contract Base_Test is Test, Defaults { // Set time constraints in dataRegistry (requires protocol admin) vm.startPrank(users.protocolAdminAddress); + treasuryFactory.setCampaignInfoFactory(address(campaignInfoFactory)); globalParams.addToRegistry( DataRegistryKeys.CAMPAIGN_LAUNCH_BUFFER, bytes32(uint256(0)) // No buffer for most tests @@ -157,7 +199,10 @@ abstract contract Base_Test is Test, Defaults { /// @dev Generates a user, labels its address, and funds it with test assets. function createUser(string memory name) internal returns (address payable) { - address payable user = payable(makeAddr(name)); + uint256 privateKey = uint256(keccak256(abi.encodePacked("oak-network-test-user", name))); + address payable user = payable(vm.addr(privateKey)); + userPrivateKeys[user] = privateKey; + vm.label(user, name); vm.deal({account: user, newBalance: 100 ether}); return user; } diff --git a/test/foundry/integration/AllOrNothing/AllOrNothing.t.sol b/test/foundry/integration/AllOrNothing/AllOrNothing.t.sol index e1e7a909..367471c1 100644 --- a/test/foundry/integration/AllOrNothing/AllOrNothing.t.sol +++ b/test/foundry/integration/AllOrNothing/AllOrNothing.t.sol @@ -9,12 +9,26 @@ import {AllOrNothing} from "src/treasuries/AllOrNothing.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {IReward} from "src/interfaces/IReward.sol"; import {LogDecoder} from "../../utils/LogDecoder.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; /// @notice Common testing logic needed by all AllOrNothing integration tests. abstract contract AllOrNothing_Integration_Shared_Test is IReward, LogDecoder, Base_Test { + bytes32 internal constant AON_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH = keccak256( + "PledgeForRewardWitness(address backer,bytes32 rewardsHash,uint256 shippingFee)" + ); + string internal constant AON_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING = + "PledgeForRewardWitness witness)PledgeForRewardWitness(address backer,bytes32 rewardsHash,uint256 shippingFee)TokenPermissions(address token,uint256 amount)"; + bytes32 internal constant AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH = + keccak256("PledgeWithoutRewardWitness(address backer,uint256 pledgeAmount)"); + string internal constant AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING = + "PledgeWithoutRewardWitness witness)PledgeWithoutRewardWitness(address backer,uint256 pledgeAmount)TokenPermissions(address token,uint256 amount)"; + address campaignAddress; address treasuryAddress; AllOrNothing internal allOrNothing; + mapping(address => uint256) internal aonNonceCounter; uint256 pledgeForARewardTokenId; @@ -157,13 +171,17 @@ abstract contract AllOrNothing_Integration_Shared_Test is IReward, LogDecoder, B vm.startPrank(caller); vm.recordLogs(); - testToken.approve(allOrNothingAddress, pledgeAmount + shippingFee); + // Approve MockPermit2 (at canonical address) instead of the treasury directly. + IERC20(token).approve(CANONICAL_PERMIT2_ADDRESS, type(uint256).max); vm.warp(launchTime); bytes32[] memory reward = new bytes32[](1); reward[0] = rewardName; - AllOrNothing(allOrNothingAddress).pledgeForAReward(caller, address(token), shippingFee, reward); + uint256 nonce = aonNonceCounter[caller]++; + PermitData memory permitData = _buildSignedAllOrNothingRewardPermitData(caller, address(token), shippingFee, reward, nonce, block.timestamp + 1 hours); + + AllOrNothing(allOrNothingAddress).pledgeForAReward(caller, address(token), shippingFee, reward, permitData); logs = vm.getRecordedLogs(); @@ -191,10 +209,14 @@ abstract contract AllOrNothing_Integration_Shared_Test is IReward, LogDecoder, B vm.startPrank(caller); vm.recordLogs(); - testToken.approve(allOrNothingAddress, pledgeAmount); + // Approve MockPermit2 (at canonical address) instead of the treasury directly. + IERC20(token).approve(CANONICAL_PERMIT2_ADDRESS, type(uint256).max); vm.warp(launchTime); - AllOrNothing(allOrNothingAddress).pledgeWithoutAReward(caller, address(token), pledgeAmount); + uint256 nonce = aonNonceCounter[caller]++; + PermitData memory permitData = _buildSignedAllOrNothingNoRewardPermitData(caller, address(token), pledgeAmount, nonce, block.timestamp + 1 hours); + + AllOrNothing(allOrNothingAddress).pledgeWithoutAReward(caller, address(token), pledgeAmount, permitData); logs = vm.getRecordedLogs(); @@ -283,4 +305,64 @@ abstract contract AllOrNothing_Integration_Shared_Test is IReward, LogDecoder, B return (logs, to, amount); } + + function _buildSignedAllOrNothingRewardPermitData( + address backer, + address token, + uint256 shippingFee, + bytes32[] memory rewardSelection, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 pledgeAmount; + for (uint256 i = 0; i < rewardSelection.length; i++) { + pledgeAmount += allOrNothing.getReward(rewardSelection[i]).rewardValue; + } + + uint256 totalAmount = _denormalizeForToken(token, pledgeAmount) + _denormalizeForToken(token, shippingFee); + bytes32 rewardsHash = keccak256(abi.encodePacked(rewardSelection)); + bytes32 witness = keccak256( + abi.encode(AON_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, backer, rewardsHash, shippingFee) + ); + + return _buildSignedPermitData( + backer, treasuryAddress, token, totalAmount, witness, AON_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING, nonce, deadline + ); + } + + function _buildSignedAllOrNothingNoRewardPermitData( + address backer, + address token, + uint256 pledgeAmount, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + bytes32 witness = + keccak256(abi.encode(AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, backer, pledgeAmount)); + + return _buildSignedPermitData( + backer, + treasuryAddress, + token, + pledgeAmount, + witness, + AON_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING, + nonce, + deadline + ); + } + + function _denormalizeForToken(address token, uint256 amount) internal view returns (uint256) { + uint8 decimals = IERC20Metadata(token).decimals(); + + if (decimals == 18) { + return amount; + } + + if (decimals < 18) { + return amount / (10 ** (18 - decimals)); + } + + return amount * (10 ** (decimals - 18)); + } } diff --git a/test/foundry/integration/AllOrNothing/AllOrNothingFunction.t.sol b/test/foundry/integration/AllOrNothing/AllOrNothingFunction.t.sol index 6037e124..2234d168 100644 --- a/test/foundry/integration/AllOrNothing/AllOrNothingFunction.t.sol +++ b/test/foundry/integration/AllOrNothing/AllOrNothingFunction.t.sol @@ -156,18 +156,20 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio uint256 usdcShippingFee = getTokenAmount(address(usdcToken), SHIPPING_FEE); vm.startPrank(users.backer1Address); - usdcToken.approve(address(allOrNothing), usdcPledgeAmount + usdcShippingFee); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcPledgeAmount + usdcShippingFee); vm.warp(LAUNCH_TIME); bytes32[] memory reward1 = new bytes32[](1); reward1[0] = REWARD_NAME_1_HASH; - allOrNothing.pledgeForAReward(users.backer1Address, address(usdcToken), usdcShippingFee, reward1); + PermitData memory permitData1 = _buildSignedAllOrNothingRewardPermitData(users.backer1Address, address(usdcToken), usdcShippingFee, reward1, 0, block.timestamp + 1 hours); + allOrNothing.pledgeForAReward(users.backer1Address, address(usdcToken), usdcShippingFee, reward1, permitData1); vm.stopPrank(); // Pledge with cUSD (18 decimals) - no conversion needed vm.startPrank(users.backer2Address); - cUSDToken.approve(address(allOrNothing), PLEDGE_AMOUNT); - allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); + PermitData memory permitData2 = _buildSignedAllOrNothingNoRewardPermitData(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT, permitData2); vm.stopPrank(); // Verify balances @@ -188,9 +190,10 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio // USDC pledge (6 decimals) uint256 usdcAmount = baseAmount / 1e12; vm.startPrank(users.backer1Address); - usdcToken.approve(address(allOrNothing), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); vm.warp(LAUNCH_TIME); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount); + PermitData memory permitData3 = _buildSignedAllOrNothingNoRewardPermitData(users.backer1Address, address(usdcToken), usdcAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount, permitData3); vm.stopPrank(); uint256 raisedAfterUSDC = allOrNothing.getRaisedAmount(); @@ -198,8 +201,9 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio // cUSD pledge (18 decimals) vm.startPrank(users.backer2Address); - cUSDToken.approve(address(allOrNothing), baseAmount); - allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), baseAmount); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, baseAmount); + PermitData memory permitData4 = _buildSignedAllOrNothingNoRewardPermitData(users.backer2Address, address(cUSDToken), baseAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), baseAmount, permitData4); vm.stopPrank(); uint256 raisedAfterCUSD = allOrNothing.getRaisedAmount(); @@ -212,15 +216,17 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio // Pledge with USDC uint256 usdcAmount = getTokenAmount(address(usdcToken), PLEDGE_AMOUNT); vm.startPrank(users.backer1Address); - usdcToken.approve(address(allOrNothing), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); vm.warp(LAUNCH_TIME); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount); + PermitData memory permitData5 = _buildSignedAllOrNothingNoRewardPermitData(users.backer1Address, address(usdcToken), usdcAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount, permitData5); vm.stopPrank(); // Pledge with cUSD to meet goal vm.startPrank(users.backer2Address); - cUSDToken.approve(address(allOrNothing), GOAL_AMOUNT); - allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), GOAL_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, GOAL_AMOUNT); + PermitData memory permitData6 = _buildSignedAllOrNothingNoRewardPermitData(users.backer2Address, address(cUSDToken), GOAL_AMOUNT, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), GOAL_AMOUNT, permitData6); vm.stopPrank(); uint256 protocolBalanceUSDCBefore = usdcToken.balanceOf(users.protocolAdminAddress); @@ -271,20 +277,23 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio uint256 usdtAmount = getTokenAmount(address(usdtToken), PLEDGE_AMOUNT); vm.startPrank(users.backer1Address); - usdcToken.approve(address(allOrNothing), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); vm.warp(LAUNCH_TIME); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount); + PermitData memory permitData7 = _buildSignedAllOrNothingNoRewardPermitData(users.backer1Address, address(usdcToken), usdcAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount, permitData7); vm.stopPrank(); vm.startPrank(users.backer2Address); - usdtToken.approve(address(allOrNothing), usdtAmount); - allOrNothing.pledgeWithoutAReward(users.backer2Address, address(usdtToken), usdtAmount); + usdtToken.approve(CANONICAL_PERMIT2_ADDRESS, usdtAmount); + PermitData memory permitData8 = _buildSignedAllOrNothingNoRewardPermitData(users.backer2Address, address(usdtToken), usdtAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer2Address, address(usdtToken), usdtAmount, permitData8); vm.stopPrank(); // Need cUSD pledge to meet goal vm.startPrank(users.backer1Address); - cUSDToken.approve(address(allOrNothing), GOAL_AMOUNT); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(cUSDToken), GOAL_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, GOAL_AMOUNT); + PermitData memory permitData9 = _buildSignedAllOrNothingNoRewardPermitData(users.backer1Address, address(cUSDToken), GOAL_AMOUNT, 1, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(cUSDToken), GOAL_AMOUNT, permitData9); vm.stopPrank(); // Disburse fees and withdraw @@ -314,16 +323,18 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio // Backer1 pledges with USDC uint256 usdcAmount = getTokenAmount(address(usdcToken), PLEDGE_AMOUNT); vm.startPrank(users.backer1Address); - usdcToken.approve(address(allOrNothing), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); vm.warp(LAUNCH_TIME); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount); + PermitData memory permitData10 = _buildSignedAllOrNothingNoRewardPermitData(users.backer1Address, address(usdcToken), usdcAmount, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(usdcToken), usdcAmount, permitData10); uint256 usdcTokenId = 1; // First pledge vm.stopPrank(); // Backer2 pledges with cUSD vm.startPrank(users.backer2Address); - cUSDToken.approve(address(allOrNothing), PLEDGE_AMOUNT); - allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); + PermitData memory permitData11 = _buildSignedAllOrNothingNoRewardPermitData(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT, 0, block.timestamp + 1 hours); + allOrNothing.pledgeWithoutAReward(users.backer2Address, address(cUSDToken), PLEDGE_AMOUNT, permitData11); uint256 cUSDTokenId = 2; // Second pledge vm.stopPrank(); @@ -363,13 +374,14 @@ contract AllOrNothingFunction_Integration_Shared_Test is AllOrNothing_Integratio addRewards(users.creator1Address, address(allOrNothing), REWARD_NAMES, REWARDS); vm.startPrank(users.backer1Address); - unacceptedToken.approve(address(allOrNothing), PLEDGE_AMOUNT); + unacceptedToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); vm.warp(LAUNCH_TIME); + PermitData memory emptyPermit; vm.expectRevert( abi.encodeWithSelector(AllOrNothing.AllOrNothingTokenNotAccepted.selector, address(unacceptedToken)) ); - allOrNothing.pledgeWithoutAReward(users.backer1Address, address(unacceptedToken), PLEDGE_AMOUNT); + allOrNothing.pledgeWithoutAReward(users.backer1Address, address(unacceptedToken), PLEDGE_AMOUNT, emptyPermit); vm.stopPrank(); } } diff --git a/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaised.t.sol b/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaised.t.sol index 02bdcd9b..121fa0e4 100644 --- a/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaised.t.sol +++ b/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaised.t.sol @@ -9,14 +9,29 @@ import {IReward} from "src/interfaces/IReward.sol"; import {ICampaignData} from "src/interfaces/ICampaignData.sol"; import {LogDecoder} from "../../utils/LogDecoder.sol"; import {Base_Test} from "../../Base.t.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; /// @notice Common testing logic needed by all KeepWhatsRaised integration tests. abstract contract KeepWhatsRaised_Integration_Shared_Test is IReward, LogDecoder, Base_Test { + bytes32 internal constant KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH = keccak256( + "KWRPledgeForRewardWitness(bytes32 pledgeId,address backer,bytes32 rewardsHash,uint256 tip)" + ); + string internal constant KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING = + "KWRPledgeForRewardWitness witness)KWRPledgeForRewardWitness(bytes32 pledgeId,address backer,bytes32 rewardsHash,uint256 tip)TokenPermissions(address token,uint256 amount)"; + bytes32 internal constant KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH = keccak256( + "KWRPledgeWithoutRewardWitness(bytes32 pledgeId,address backer,uint256 pledgeAmount,uint256 tip)" + ); + string internal constant KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING = + "KWRPledgeWithoutRewardWitness witness)KWRPledgeWithoutRewardWitness(bytes32 pledgeId,address backer,uint256 pledgeAmount,uint256 tip)TokenPermissions(address token,uint256 amount)"; + address campaignAddress; address treasuryAddress; KeepWhatsRaised internal keepWhatsRaised; uint256 pledgeForARewardTokenId; + uint256 private _resetCounter; /// @dev Initial dependent functions setup included for KeepWhatsRaised Integration Tests. function setUp() public virtual override { @@ -308,13 +323,16 @@ abstract contract KeepWhatsRaised_Integration_Shared_Test is IReward, LogDecoder vm.startPrank(caller); vm.recordLogs(); - testToken.approve(keepWhatsRaisedAddress, pledgeAmount + tip); + // Approve MockPermit2 (at canonical address) instead of the treasury directly. + IERC20(token).approve(CANONICAL_PERMIT2_ADDRESS, type(uint256).max); vm.warp(launchTime); bytes32[] memory reward = new bytes32[](1); reward[0] = rewardName; - KeepWhatsRaised(keepWhatsRaisedAddress).pledgeForAReward(pledgeId, caller, token, tip, reward); + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData(caller, address(token), pledgeId, tip, reward, 0, block.timestamp + 1 hours); + + KeepWhatsRaised(keepWhatsRaisedAddress).pledgeForAReward(pledgeId, caller, token, tip, reward, permitData); logs = vm.getRecordedLogs(); @@ -344,10 +362,13 @@ abstract contract KeepWhatsRaised_Integration_Shared_Test is IReward, LogDecoder vm.startPrank(caller); vm.recordLogs(); - testToken.approve(keepWhatsRaisedAddress, pledgeAmount + tip); + // Approve MockPermit2 (at canonical address) instead of the treasury directly. + IERC20(token).approve(CANONICAL_PERMIT2_ADDRESS, type(uint256).max); vm.warp(launchTime); - KeepWhatsRaised(keepWhatsRaisedAddress).pledgeWithoutAReward(pledgeId, caller, token, pledgeAmount, tip); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(caller, address(token), pledgeId, pledgeAmount, tip, 0, block.timestamp + 1 hours); + + KeepWhatsRaised(keepWhatsRaisedAddress).pledgeWithoutAReward(pledgeId, caller, token, pledgeAmount, tip, permitData); logs = vm.getRecordedLogs(); @@ -493,4 +514,103 @@ abstract contract KeepWhatsRaised_Integration_Shared_Test is IReward, LogDecoder KeepWhatsRaised(treasury).cancelTreasury(message); vm.stopPrank(); } + + function _buildSignedKeepWhatsRaisedRewardPermitData( + address backer, + address token, + bytes32 pledgeId, + uint256 tip, + bytes32[] memory rewardSelection, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 pledgeAmount; + for (uint256 i = 0; i < rewardSelection.length; i++) { + pledgeAmount += keepWhatsRaised.getReward(rewardSelection[i]).rewardValue; + } + + uint256 totalAmount = _denormalizeForToken(token, pledgeAmount) + tip; + bytes32 rewardsHash = keccak256(abi.encodePacked(rewardSelection)); + bytes32 witness = keccak256( + abi.encode(KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, pledgeId, backer, rewardsHash, tip) + ); + + return _buildSignedPermitData( + backer, treasuryAddress, token, totalAmount, witness, KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING, nonce, deadline + ); + } + + function _buildSignedKeepWhatsRaisedNoRewardPermitData( + address backer, + address token, + bytes32 pledgeId, + uint256 pledgeAmount, + uint256 tip, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + bytes32 witness = + keccak256(abi.encode(KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, pledgeId, backer, pledgeAmount, tip)); + + return _buildSignedPermitData( + backer, + treasuryAddress, + token, + pledgeAmount + tip, + witness, + KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING, + nonce, + deadline + ); + } + + function _denormalizeForToken(address token, uint256 amount) internal view returns (uint256) { + uint8 decimals = IERC20Metadata(token).decimals(); + + if (decimals == 18) { + return amount; + } + + if (decimals < 18) { + return amount / (10 ** (18 - decimals)); + } + + return amount * (10 ** (decimals - 18)); + } + + /** + * @notice Deploys a fresh campaign and unconfigured KeepWhatsRaised treasury, + * reassigning campaignAddress, treasuryAddress, and keepWhatsRaised. + * Use this in tests that need to call configureTreasury for the first time. + */ + function _resetTreasury() internal { + _resetCounter++; + bytes32 newIdentifierHash = keccak256(abi.encodePacked("resetTreasury", _resetCounter)); + bytes32[] memory selectedPlatformHash = new bytes32[](1); + selectedPlatformHash[0] = PLATFORM_2_HASH; + bytes32[] memory emptyArray = new bytes32[](0); + + vm.prank(users.creator1Address); + campaignInfoFactory.createCampaign( + users.creator1Address, + newIdentifierHash, + selectedPlatformHash, + emptyArray, + emptyArray, + CAMPAIGN_DATA, + "Fresh Treasury NFT", + "FRESH", + "ipfs://QmFresh", + "ipfs://QmFreshContract" + ); + + address newCampaignAddress = campaignInfoFactory.identifierToCampaignInfo(newIdentifierHash); + + vm.prank(users.platform2AdminAddress); + address newTreasury = treasuryFactory.deploy(PLATFORM_2_HASH, newCampaignAddress, 1); + + campaignAddress = newCampaignAddress; + treasuryAddress = newTreasury; + keepWhatsRaised = KeepWhatsRaised(newTreasury); + } } diff --git a/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaisedFunction.t.sol b/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaisedFunction.t.sol index 35cd7b7f..aa02a3a6 100644 --- a/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaisedFunction.t.sol +++ b/test/foundry/integration/KeepWhatsRaised/KeepWhatsRaisedFunction.t.sol @@ -10,6 +10,8 @@ import {Users} from "../../utils/Types.sol"; import {IReward} from "src/interfaces/IReward.sol"; import {KeepWhatsRaised} from "src/treasuries/KeepWhatsRaised.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; +import {TreasuryErrors} from "src/errors/TreasuryErrors.sol"; contract KeepWhatsRaisedFunction_Integration_Shared_Test is KeepWhatsRaised_Integration_Shared_Test { function setUp() public virtual override { @@ -179,7 +181,8 @@ contract KeepWhatsRaisedFunction_Integration_Shared_Test is KeepWhatsRaised_Inte } function test_withdrawWithColombianCreatorTax() external { - // Configure with Colombian creator + // Deploy a fresh treasury so configureTreasury can be called for the first time. + _resetTreasury(); KeepWhatsRaised.FeeValues memory feeValues = createFeeValues(); configureTreasury( users.platform2AdminAddress, address(keepWhatsRaised), CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues @@ -558,7 +561,7 @@ contract KeepWhatsRaisedFunction_Integration_Shared_Test is KeepWhatsRaised_Inte removeReward(users.creator1Address, address(keepWhatsRaised), REWARD_NAMES[1]); // Verify reward is removed - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.REWARD_NOT_FOUND)); keepWhatsRaised.getReward(REWARD_NAMES[1]); } @@ -587,10 +590,11 @@ contract KeepWhatsRaisedFunction_Integration_Shared_Test is KeepWhatsRaised_Inte // Verify campaign is cancelled vm.startPrank(users.backer2Address); - testToken.approve(address(keepWhatsRaised), PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); + PermitData memory emptyPermit1; vm.expectRevert(); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID_2, users.backer2Address, address(testToken), PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID_2, users.backer2Address, address(testToken), PLEDGE_AMOUNT, 0, emptyPermit1 ); vm.stopPrank(); } @@ -621,10 +625,11 @@ contract KeepWhatsRaisedFunction_Integration_Shared_Test is KeepWhatsRaised_Inte // Verify campaign is cancelled vm.startPrank(users.backer2Address); - testToken.approve(address(keepWhatsRaised), PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); + PermitData memory emptyPermit2; vm.expectRevert(); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID_2, users.backer2Address, address(testToken), PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID_2, users.backer2Address, address(testToken), PLEDGE_AMOUNT, 0, emptyPermit2 ); vm.stopPrank(); } diff --git a/test/foundry/integration/PaymentTreasury/PaymentTreasury.t.sol b/test/foundry/integration/PaymentTreasury/PaymentTreasury.t.sol index 71004aac..c2624133 100644 --- a/test/foundry/integration/PaymentTreasury/PaymentTreasury.t.sol +++ b/test/foundry/integration/PaymentTreasury/PaymentTreasury.t.sol @@ -9,9 +9,17 @@ import {CampaignInfo} from "src/CampaignInfo.sol"; import {ICampaignPaymentTreasury} from "src/interfaces/ICampaignPaymentTreasury.sol"; import {LogDecoder} from "../../utils/LogDecoder.sol"; import {TestToken} from "../../../mocks/TestToken.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; /// @notice Common testing logic needed by all PaymentTreasury integration tests. abstract contract PaymentTreasury_Integration_Shared_Test is LogDecoder, Base_Test { + bytes32 internal constant CRYPTO_PAYMENT_WITNESS_TYPEHASH = keccak256( + "CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)" + ); + string internal constant CRYPTO_PAYMENT_WITNESS_TYPE_STRING = + "CryptoPaymentWitness witness)CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)TokenPermissions(address token,uint256 amount)"; + address campaignAddress; address treasuryAddress; PaymentTreasury internal paymentTreasury; @@ -161,6 +169,8 @@ abstract contract PaymentTreasury_Integration_Shared_Test is LogDecoder, Base_Te /** * @notice Processes a crypto payment + * @dev `caller` must have pre-approved MockPermit2 for the token. + * The helper sets up the approval and builds a dummy PermitData automatically. */ function processCryptoPayment( address caller, @@ -172,9 +182,45 @@ abstract contract PaymentTreasury_Integration_Shared_Test is LogDecoder, Base_Te ICampaignPaymentTreasury.LineItem[] memory lineItems, ICampaignPaymentTreasury.ExternalFees[] memory externalFees ) internal { - vm.prank(caller); + // Compute total transfer amount to approve + uint256 totalAmount = amount; + for (uint256 i = 0; i < lineItems.length; i++) { + totalAmount += lineItems[i].amount; + } + + vm.startPrank(caller); + // Approve MockPermit2 (at canonical address) for the token. + IERC20(paymentToken).approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); + + PermitData memory permitData = _buildSignedCryptoPaymentPermitData(buyerAddress, paymentToken, paymentId, itemId, amount, lineItems, 0, block.timestamp + 1 hours); + paymentTreasury.processCryptoPayment( - paymentId, itemId, buyerAddress, paymentToken, amount, lineItems, externalFees + paymentId, itemId, buyerAddress, paymentToken, amount, lineItems, externalFees, permitData + ); + vm.stopPrank(); + } + + function _buildSignedCryptoPaymentPermitData( + address buyer, + address paymentToken, + bytes32 paymentId, + bytes32 itemId, + uint256 amount, + ICampaignPaymentTreasury.LineItem[] memory lineItems, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 totalAmount = amount; + for (uint256 i = 0; i < lineItems.length; i++) { + totalAmount += lineItems[i].amount; + } + + bytes32 lineItemsHash = keccak256(abi.encode(lineItems)); + bytes32 witness = + keccak256(abi.encode(CRYPTO_PAYMENT_WITNESS_TYPEHASH, paymentId, itemId, buyer, amount, lineItemsHash)); + + return _buildSignedPermitData( + buyer, treasuryAddress, paymentToken, totalAmount, witness, CRYPTO_PAYMENT_WITNESS_TYPE_STRING, nonce, deadline ); } diff --git a/test/foundry/integration/PaymentTreasury/PaymentTreasuryFunction.t.sol b/test/foundry/integration/PaymentTreasury/PaymentTreasuryFunction.t.sol index 7a72f3af..d2b0d83b 100644 --- a/test/foundry/integration/PaymentTreasury/PaymentTreasuryFunction.t.sol +++ b/test/foundry/integration/PaymentTreasury/PaymentTreasuryFunction.t.sol @@ -8,6 +8,7 @@ import "forge-std/Test.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {PaymentTreasury} from "src/treasuries/PaymentTreasury.sol"; import {TestToken} from "../../../mocks/TestToken.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; /** * @title PaymentTreasuryFunction_Integration_Test @@ -512,22 +513,25 @@ contract PaymentTreasuryFunction_Integration_Test is PaymentTreasury_Integration uint256 amount = 1000e18; rejectedToken.mint(users.backer1Address, amount); - vm.prank(users.backer1Address); - rejectedToken.approve(treasuryAddress, amount); + vm.startPrank(users.backer1Address); + rejectedToken.approve(CANONICAL_PERMIT2_ADDRESS, amount); - // Try to process crypto payment with unaccepted token - vm.expectRevert(); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); - processCryptoPayment( - users.backer1Address, + PermitData memory emptyPermit; + + // Place vm.expectRevert right before the external call + vm.expectRevert(); + paymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, users.backer1Address, address(rejectedToken), amount, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + emptyPermit ); + vm.stopPrank(); } function test_balanceTrackingAcrossMultipleTokens() public { diff --git a/test/foundry/integration/PaymentTreasury/PaymentTreasuryLineItems.t.sol b/test/foundry/integration/PaymentTreasury/PaymentTreasuryLineItems.t.sol index dc7dfcb7..883811a7 100644 --- a/test/foundry/integration/PaymentTreasury/PaymentTreasuryLineItems.t.sol +++ b/test/foundry/integration/PaymentTreasury/PaymentTreasuryLineItems.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import "./PaymentTreasury.t.sol"; import {ICampaignPaymentTreasury} from "src/interfaces/ICampaignPaymentTreasury.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; /// @notice Tests for PaymentTreasury with line items and expiration contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Test { @@ -228,11 +229,12 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes deal(address(testToken), users.backer1Address, totalAmount); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, totalAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); ICampaignPaymentTreasury.LineItem[] memory lineItems = new ICampaignPaymentTreasury.LineItem[](1); lineItems[0] = ICampaignPaymentTreasury.LineItem({typeId: SHIPPING_FEE_TYPE_ID, amount: shippingFeeAmount}); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData(users.backer1Address, address(testToken), PAYMENT_ID_1, ITEM_ID_1, PAYMENT_AMOUNT_1, lineItems, 0, block.timestamp + 1 hours); vm.prank(users.backer1Address); paymentTreasury.processCryptoPayment( PAYMENT_ID_1, @@ -241,7 +243,8 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes address(testToken), PAYMENT_AMOUNT_1, lineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Payment should be confirmed immediately for crypto payments @@ -255,13 +258,14 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes deal(address(testToken), users.backer1Address, totalAmount); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, totalAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); uint256 platformAdminBalanceBefore = testToken.balanceOf(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory lineItems = new ICampaignPaymentTreasury.LineItem[](1); lineItems[0] = ICampaignPaymentTreasury.LineItem({typeId: TIP_TYPE_ID, amount: tipAmount}); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData(users.backer1Address, address(testToken), PAYMENT_ID_1, ITEM_ID_1, PAYMENT_AMOUNT_1, lineItems, 0, block.timestamp + 1 hours); vm.prank(users.backer1Address); paymentTreasury.processCryptoPayment( PAYMENT_ID_1, @@ -270,7 +274,8 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes address(testToken), PAYMENT_AMOUNT_1, lineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Tip doesn't count toward goal, but payment amount does @@ -289,7 +294,7 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes deal(address(testToken), users.backer1Address, totalAmount); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, totalAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); uint256 platformAdminBalanceBefore = testToken.balanceOf(users.platform1AdminAddress); uint256 tipNetAmount = tipAmount; // No protocol fee on tip @@ -299,6 +304,7 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes lineItems[1] = ICampaignPaymentTreasury.LineItem({typeId: TIP_TYPE_ID, amount: tipAmount}); lineItems[2] = ICampaignPaymentTreasury.LineItem({typeId: INTEREST_TYPE_ID, amount: interestAmount}); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData(users.backer1Address, address(testToken), PAYMENT_ID_1, ITEM_ID_1, PAYMENT_AMOUNT_1, lineItems, 0, block.timestamp + 1 hours); vm.prank(users.backer1Address); paymentTreasury.processCryptoPayment( PAYMENT_ID_1, @@ -307,7 +313,8 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes address(testToken), PAYMENT_AMOUNT_1, lineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Only payment amount + shipping fee count toward goal @@ -520,7 +527,7 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes deal(address(testToken), users.backer1Address, totalAmount); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, totalAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); ICampaignPaymentTreasury.LineItem[] memory lineItems = new ICampaignPaymentTreasury.LineItem[](1); lineItems[0] = @@ -528,6 +535,7 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes bytes32 paymentId = keccak256("refundableFeePayment"); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData(users.backer1Address, address(testToken), paymentId, ITEM_ID_1, baseAmount, lineItems, 0, block.timestamp + 1 hours); vm.prank(users.backer1Address); paymentTreasury.processCryptoPayment( paymentId, @@ -536,7 +544,8 @@ contract PaymentTreasuryLineItems_Test is PaymentTreasury_Integration_Shared_Tes address(testToken), baseAmount, lineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); uint256 buyerBalanceAfterPayment = testToken.balanceOf(users.backer1Address); diff --git a/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasury.t.sol b/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasury.t.sol index b25a5adf..7b2ede4e 100644 --- a/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasury.t.sol +++ b/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasury.t.sol @@ -6,12 +6,19 @@ import "forge-std/console.sol"; import {TimeConstrainedPaymentTreasury} from "src/treasuries/TimeConstrainedPaymentTreasury.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {ICampaignPaymentTreasury} from "src/interfaces/ICampaignPaymentTreasury.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; import {LogDecoder} from "../../utils/LogDecoder.sol"; import {Base_Test} from "../../Base.t.sol"; import {DataRegistryKeys} from "src/constants/DataRegistryKeys.sol"; /// @notice Common testing logic needed by all TimeConstrainedPaymentTreasury integration tests. abstract contract TimeConstrainedPaymentTreasury_Integration_Shared_Test is LogDecoder, Base_Test { + bytes32 internal constant CRYPTO_PAYMENT_WITNESS_TYPEHASH = keccak256( + "CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)" + ); + string internal constant CRYPTO_PAYMENT_WITNESS_TYPE_STRING = + "CryptoPaymentWitness witness)CryptoPaymentWitness(bytes32 paymentId,bytes32 itemId,address buyerAddress,uint256 amount,bytes32 lineItemsHash)TokenPermissions(address token,uint256 amount)"; + address campaignAddress; address treasuryAddress; TimeConstrainedPaymentTreasury internal timeConstrainedPaymentTreasury; @@ -180,4 +187,28 @@ abstract contract TimeConstrainedPaymentTreasury_Integration_Shared_Test is LogD function advanceToAfterLaunch() internal { vm.warp(campaignLaunchTime + 1); } + + function _buildSignedCryptoPaymentPermitData( + address buyer, + address paymentToken, + bytes32 paymentId, + bytes32 itemId, + uint256 amount, + ICampaignPaymentTreasury.LineItem[] memory lineItems, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 totalAmount = amount; + for (uint256 i = 0; i < lineItems.length; i++) { + totalAmount += lineItems[i].amount; + } + + bytes32 lineItemsHash = keccak256(abi.encode(lineItems)); + bytes32 witness = + keccak256(abi.encode(CRYPTO_PAYMENT_WITNESS_TYPEHASH, paymentId, itemId, buyer, amount, lineItemsHash)); + + return _buildSignedPermitData( + buyer, treasuryAddress, paymentToken, totalAmount, witness, CRYPTO_PAYMENT_WITNESS_TYPE_STRING, nonce, deadline + ); + } } diff --git a/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasuryFunction.t.sol b/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasuryFunction.t.sol index f832b036..3fbddb02 100644 --- a/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasuryFunction.t.sol +++ b/test/foundry/integration/TimeConstrainedPaymentTreasury/TimeConstrainedPaymentTreasuryFunction.t.sol @@ -11,6 +11,8 @@ import {ICampaignPaymentTreasury} from "src/interfaces/ICampaignPaymentTreasury. import {TimeConstrainedPaymentTreasury} from "src/treasuries/TimeConstrainedPaymentTreasury.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {TestToken} from "../../../mocks/TestToken.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is TimeConstrainedPaymentTreasury_Integration_Shared_Test @@ -92,11 +94,21 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is function test_processCryptoPayment() external { advanceToWithinRange(); - // Approve tokens for the treasury + // Approve MockPermit2 (at canonical address) for the token. vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, @@ -105,7 +117,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Payment processed successfully @@ -146,9 +159,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId, @@ -157,7 +180,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Payment created and confirmed successfully by processCryptoPayment @@ -174,9 +198,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment for both payments which creates and confirms them vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData1 = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId1, @@ -185,13 +219,24 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData1 ); vm.prank(users.backer2Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_2); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_2); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems2 = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData2 = _buildSignedCryptoPaymentPermitData( + users.backer2Address, + address(testToken), + uniquePaymentId2, + ITEM_ID_2, + PAYMENT_AMOUNT_2, + emptyLineItems2, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId2, @@ -200,7 +245,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_2, emptyLineItems2, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData2 ); // Payments created and confirmed successfully by processCryptoPayment @@ -217,9 +263,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId, @@ -228,7 +284,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch to be able to claim refund @@ -256,9 +313,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId, @@ -267,7 +334,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch to be able to disburse fees @@ -289,9 +357,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId, @@ -300,7 +378,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch to be able to withdraw @@ -491,9 +570,19 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + uniquePaymentId, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( uniquePaymentId, @@ -502,7 +591,8 @@ contract TimeConstrainedPaymentTreasuryFunction_Integration_Shared_Test is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch time diff --git a/test/foundry/unit/CampaignInfo.t.sol b/test/foundry/unit/CampaignInfo.t.sol index 84241fe3..6d1c5d26 100644 --- a/test/foundry/unit/CampaignInfo.t.sol +++ b/test/foundry/unit/CampaignInfo.t.sol @@ -13,6 +13,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {TestToken} from "../../mocks/TestToken.sol"; import {Defaults} from "../Base.t.sol"; import {DataRegistryKeys} from "src/constants/DataRegistryKeys.sol"; +import {ProtocolErrors} from "src/errors/ProtocolErrors.sol"; contract CampaignInfo_UnitTest is Test, Defaults { CampaignInfo internal campaignInfo; @@ -81,7 +82,6 @@ contract CampaignInfo_UnitTest is Test, Defaults { CampaignInfoFactory campaignInfoFactoryImpl = new CampaignInfoFactory(); bytes memory campaignInfoFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - admin, IGlobalParams(address(globalParams)), address(new CampaignInfo()), address(treasuryFactory) @@ -90,6 +90,11 @@ contract CampaignInfo_UnitTest is Test, Defaults { new ERC1967Proxy(address(campaignInfoFactoryImpl), campaignInfoFactoryInitData); campaignInfoFactory = CampaignInfoFactory(address(campaignInfoFactoryProxy)); + // Wire campaignInfoFactory into treasuryFactory for validation + vm.startPrank(admin); + treasuryFactory.setCampaignInfoFactory(address(campaignInfoFactory)); + vm.stopPrank(); + // Create a campaign using the factory ICampaignData.CampaignData memory campaignData = ICampaignData.CampaignData({ launchTime: block.timestamp + 1 days, @@ -168,7 +173,11 @@ contract CampaignInfo_UnitTest is Test, Defaults { } function test_GetPlatformData_NotSet() public { - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_DATA_NOT_SET + ) + ); campaignInfo.getPlatformData(platformDataKey1); } @@ -198,13 +207,11 @@ contract CampaignInfo_UnitTest is Test, Defaults { function test_UpdateSelectedPlatform_SelectPlatform_Success() public { vm.startPrank(campaignOwner); - bytes32[] memory dataKeys = new bytes32[](2); + bytes32[] memory dataKeys = new bytes32[](1); dataKeys[0] = platformDataKey1; - dataKeys[1] = platformDataKey2; - bytes32[] memory dataValues = new bytes32[](2); + bytes32[] memory dataValues = new bytes32[](1); dataValues[0] = platformDataValue1; - dataValues[1] = platformDataValue2; campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); @@ -213,7 +220,6 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Verify platform data is stored assertEq(campaignInfo.getPlatformData(platformDataKey1), platformDataValue1); - assertEq(campaignInfo.getPlatformData(platformDataKey2), platformDataValue2); // Verify platform fee is set assertEq(campaignInfo.getPlatformFeePercent(platformHash1), 1000); @@ -251,7 +257,12 @@ contract CampaignInfo_UnitTest is Test, Defaults { campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); // Try to select again - should revert - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, + ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_SELECTION_UNCHANGED + ) + ); campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); } @@ -266,7 +277,12 @@ contract CampaignInfo_UnitTest is Test, Defaults { bytes32[] memory dataValues = new bytes32[](1); dataValues[0] = platformDataValue1; - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, + ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_DATA_LENGTH_MISMATCH + ) + ); campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); } @@ -280,7 +296,12 @@ contract CampaignInfo_UnitTest is Test, Defaults { bytes32[] memory dataValues = new bytes32[](1); dataValues[0] = platformDataValue1; - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, + ProtocolErrors.CampaignInfoInvalidInput.INVALID_PLATFORM_DATA_KEY + ) + ); campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); } @@ -294,7 +315,12 @@ contract CampaignInfo_UnitTest is Test, Defaults { bytes32[] memory dataValues = new bytes32[](1); dataValues[0] = bytes32(0); - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, + ProtocolErrors.CampaignInfoInvalidInput.ZERO_PLATFORM_DATA_VALUE + ) + ); campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); } @@ -334,7 +360,11 @@ contract CampaignInfo_UnitTest is Test, Defaults { vm.startPrank(campaignOwner); // Launch time in the past - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, ProtocolErrors.CampaignInfoInvalidInput.INVALID_LAUNCH_TIME + ) + ); campaignInfo.updateLaunchTime(block.timestamp - 1); vm.stopPrank(); @@ -358,7 +388,11 @@ contract CampaignInfo_UnitTest is Test, Defaults { // the duration would be less than 1 day, which violates the minimum uint256 newLaunchTime = currentDeadline - 12 hours; // Only 12 hours duration, less than 1 day minimum - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, ProtocolErrors.CampaignInfoInvalidInput.INVALID_LAUNCH_TIME + ) + ); campaignInfo.updateLaunchTime(newLaunchTime); vm.stopPrank(); @@ -463,12 +497,90 @@ contract CampaignInfo_UnitTest is Test, Defaults { function test_UpdateGoalAmount_ZeroAmount_Reverts() public { vm.startPrank(campaignOwner); - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, ProtocolErrors.CampaignInfoInvalidInput.ZERO_GOAL_AMOUNT + ) + ); campaignInfo.updateGoalAmount(0); vm.stopPrank(); } + function test_UpdateLaunchTime_RespectsCampaignLaunchBuffer_Reverts() public { + vm.startPrank(admin); + globalParams.addToRegistry( + DataRegistryKeys.CAMPAIGN_LAUNCH_BUFFER, + bytes32(uint256(2 days)) + ); + vm.stopPrank(); + + vm.startPrank(campaignOwner); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, ProtocolErrors.CampaignInfoInvalidInput.INVALID_LAUNCH_TIME + ) + ); + campaignInfo.updateLaunchTime(block.timestamp + 1 days); + vm.stopPrank(); + } + + function test_UpdateLaunchTime_AfterLaunch_Reverts() public { + vm.warp(campaignInfo.getLaunchTime()); + + vm.startPrank(campaignOwner); + vm.expectRevert(); + campaignInfo.updateLaunchTime(block.timestamp + 1 days); + vm.stopPrank(); + } + + function test_UpdateDeadline_AfterLaunch_Reverts() public { + vm.warp(campaignInfo.getLaunchTime()); + + vm.startPrank(campaignOwner); + vm.expectRevert(); + campaignInfo.updateDeadline(block.timestamp + 30 days); + vm.stopPrank(); + } + + function test_UpdateGoalAmount_AfterLaunch_Reverts() public { + vm.warp(campaignInfo.getLaunchTime()); + + vm.startPrank(campaignOwner); + vm.expectRevert(); + campaignInfo.updateGoalAmount(2000 * 10 ** 18); + vm.stopPrank(); + } + + function test_DeployTreasury_AfterDeadline_Reverts() public { + _selectPlatform1(); + + vm.warp(campaignInfo.getDeadline()); + + vm.startPrank(admin); + vm.expectRevert(TreasuryFactory.TreasuryFactorySettingPlatformInfoFailed.selector); + treasuryFactory.deploy(platformHash1, address(campaignInfo), 1); + vm.stopPrank(); + + assertFalse(campaignInfo.isLocked()); + assertFalse(campaignInfo.checkIfPlatformApproved(platformHash1)); + } + + function test_DeployTreasury_WhenCancelled_Reverts() public { + _selectPlatform1(); + + vm.prank(campaignOwner); + campaignInfo.cancelCampaign(keccak256("test cancel")); + + vm.startPrank(admin); + vm.expectRevert(TreasuryFactory.TreasuryFactorySettingPlatformInfoFailed.selector); + treasuryFactory.deploy(platformHash1, address(campaignInfo), 1); + vm.stopPrank(); + + assertFalse(campaignInfo.isLocked()); + assertFalse(campaignInfo.checkIfPlatformApproved(platformHash1)); + } + // ============ Transfer Ownership Tests ============ function test_TransferOwnership_Success() public { @@ -483,7 +595,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { function test_TransferOwnership_WhenPaused_Reverts() public { // Pause the campaign vm.startPrank(admin); - campaignInfo._pauseCampaign(keccak256("test")); + campaignInfo.pauseCampaign(keccak256("test")); vm.stopPrank(); vm.startPrank(campaignOwner); @@ -495,7 +607,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { function test_TransferOwnership_WhenCancelled_Reverts() public { // Cancel the campaign vm.startPrank(admin); - campaignInfo._cancelCampaign(keccak256("test")); + campaignInfo.cancelCampaign(keccak256("test")); vm.stopPrank(); vm.startPrank(campaignOwner); @@ -510,7 +622,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { vm.startPrank(admin); bytes32 message = keccak256("test pause"); - campaignInfo._pauseCampaign(message); + campaignInfo.pauseCampaign(message); assertTrue(campaignInfo.paused()); vm.stopPrank(); @@ -519,13 +631,13 @@ contract CampaignInfo_UnitTest is Test, Defaults { function test_UnpauseCampaign_Success() public { // First pause vm.startPrank(admin); - campaignInfo._pauseCampaign(keccak256("test pause")); + campaignInfo.pauseCampaign(keccak256("test pause")); vm.stopPrank(); // Then unpause vm.startPrank(admin); bytes32 message = keccak256("test unpause"); - campaignInfo._unpauseCampaign(message); + campaignInfo.unpauseCampaign(message); assertFalse(campaignInfo.paused()); vm.stopPrank(); @@ -535,7 +647,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { vm.startPrank(admin); bytes32 message = keccak256("test cancel"); - campaignInfo._cancelCampaign(message); + campaignInfo.cancelCampaign(message); assertTrue(campaignInfo.cancelled()); vm.stopPrank(); @@ -545,7 +657,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { vm.startPrank(campaignOwner); bytes32 message = keccak256("test cancel"); - campaignInfo._cancelCampaign(message); + campaignInfo.cancelCampaign(message); assertTrue(campaignInfo.cancelled()); vm.stopPrank(); @@ -556,7 +668,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { vm.startPrank(unauthorizedUser); vm.expectRevert(CampaignInfo.CampaignInfoUnauthorized.selector); - campaignInfo._cancelCampaign(keccak256("test cancel")); + campaignInfo.cancelCampaign(keccak256("test cancel")); vm.stopPrank(); } @@ -653,9 +765,9 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Verify campaign is not locked initially assertFalse(campaignInfo.isLocked()); - // Deploy a treasury using the treasury factory - this will call _setPlatformInfo + // Deploy a treasury using the treasury factory - this will call setPlatformInfo vm.startPrank(admin); - address treasury = treasuryFactory.deploy( + treasuryFactory.deploy( platformHash1, address(campaignInfo), 1 // implementationId @@ -770,7 +882,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Pausing should still work when locked vm.startPrank(admin); bytes32 message = keccak256("test pause"); - campaignInfo._pauseCampaign(message); + campaignInfo.pauseCampaign(message); assertTrue(campaignInfo.paused()); vm.stopPrank(); @@ -782,13 +894,13 @@ contract CampaignInfo_UnitTest is Test, Defaults { // First pause vm.startPrank(admin); - campaignInfo._pauseCampaign(keccak256("test pause")); + campaignInfo.pauseCampaign(keccak256("test pause")); vm.stopPrank(); // Then unpause - should still work when locked vm.startPrank(admin); bytes32 message = keccak256("test unpause"); - campaignInfo._unpauseCampaign(message); + campaignInfo.unpauseCampaign(message); assertFalse(campaignInfo.paused()); vm.stopPrank(); @@ -801,7 +913,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Cancelling should still work when locked vm.startPrank(admin); bytes32 message = keccak256("test cancel"); - campaignInfo._cancelCampaign(message); + campaignInfo.cancelCampaign(message); assertTrue(campaignInfo.cancelled()); vm.stopPrank(); @@ -814,7 +926,7 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Cancelling should still work when locked vm.startPrank(campaignOwner); bytes32 message = keccak256("test cancel"); - campaignInfo._cancelCampaign(message); + campaignInfo.cancelCampaign(message); assertTrue(campaignInfo.cancelled()); vm.stopPrank(); @@ -890,19 +1002,23 @@ contract CampaignInfo_UnitTest is Test, Defaults { // Approve the platform (this locks the campaign) vm.startPrank(address(treasuryFactory)); - campaignInfo._setPlatformInfo(platformHash1, address(0x1234)); + campaignInfo.setPlatformInfo(platformHash1, address(0x1234)); vm.stopPrank(); // Now try to select the already approved platform again - should revert vm.startPrank(campaignOwner); - vm.expectRevert(CampaignInfo.CampaignInfoInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfo.CampaignInfoInvalidInput.selector, + ProtocolErrors.CampaignInfoInvalidInput.PLATFORM_SELECTION_UNCHANGED + ) + ); campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); } // Helper function to lock the campaign - function _lockCampaign() internal { - // First select a platform + function _selectPlatform1() internal { vm.startPrank(campaignOwner); bytes32[] memory dataKeys = new bytes32[](1); dataKeys[0] = platformDataKey1; @@ -911,6 +1027,10 @@ contract CampaignInfo_UnitTest is Test, Defaults { campaignInfo.updateSelectedPlatform(platformHash1, true, dataKeys, dataValues); vm.stopPrank(); + } + + function _lockCampaign() internal { + _selectPlatform1(); // Then deploy a treasury (this locks the campaign) vm.startPrank(admin); diff --git a/test/foundry/unit/CampaignInfoFactory.t.sol b/test/foundry/unit/CampaignInfoFactory.t.sol index 234379e4..cb3981d2 100644 --- a/test/foundry/unit/CampaignInfoFactory.t.sol +++ b/test/foundry/unit/CampaignInfoFactory.t.sol @@ -12,6 +12,7 @@ import {Defaults} from "../Base.t.sol"; import {ICampaignData} from "src/interfaces/ICampaignData.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {DataRegistryKeys} from "src/constants/DataRegistryKeys.sol"; +import {ProtocolErrors} from "src/errors/ProtocolErrors.sol"; contract CampaignInfoFactory_UnitTest is Test, Defaults { CampaignInfoFactory internal factory; @@ -55,7 +56,6 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { CampaignInfoFactory factoryImpl = new CampaignInfoFactory(); bytes memory factoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - address(this), IGlobalParams(address(globalParams)), address(campaignInfoImplementation), address(treasuryFactory) @@ -137,7 +137,8 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { // Deploy new implementation CampaignInfoFactory newImplementation = new CampaignInfoFactory(); - // Upgrade as owner (address(this)) + // Upgrade as protocol admin + vm.prank(admin); factory.upgradeToAndCall(address(newImplementation), ""); // Factory should still work after upgrade @@ -168,8 +169,8 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { // Deploy new implementation CampaignInfoFactory newImplementation = new CampaignInfoFactory(); - // Try to upgrade as non-owner (should revert) - vm.prank(admin); + // Try to upgrade as non-admin (should revert) + vm.prank(address(0xDEAD)); vm.expectRevert(); factory.upgradeToAndCall(address(newImplementation), ""); } @@ -178,7 +179,6 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { // Try to initialize again (should revert) vm.expectRevert(); factory.initialize( - address(this), IGlobalParams(address(globalParams)), address(campaignInfoImplementation), address(treasuryFactory) @@ -203,7 +203,12 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { }); vm.prank(admin); - vm.expectRevert(CampaignInfoFactory.CampaignInfoFactoryInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfoFactory.CampaignInfoFactoryInvalidInput.selector, + ProtocolErrors.CampaignInfoFactoryInvalidInput.LAUNCH_TIME_TOO_SOON + ) + ); factory.createCampaign( creator, CAMPAIGN_1_IDENTIFIER_HASH, @@ -236,7 +241,12 @@ contract CampaignInfoFactory_UnitTest is Test, Defaults { }); vm.prank(admin); - vm.expectRevert(CampaignInfoFactory.CampaignInfoFactoryInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + CampaignInfoFactory.CampaignInfoFactoryInvalidInput.selector, + ProtocolErrors.CampaignInfoFactoryInvalidInput.DEADLINE_TOO_SOON + ) + ); factory.createCampaign( creator, CAMPAIGN_1_IDENTIFIER_HASH, diff --git a/test/foundry/unit/GlobalParams.t.sol b/test/foundry/unit/GlobalParams.t.sol index 4fece932..f53db6cb 100644 --- a/test/foundry/unit/GlobalParams.t.sol +++ b/test/foundry/unit/GlobalParams.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import "forge-std/Test.sol"; import {GlobalParams} from "src/GlobalParams.sol"; +import {ProtocolErrors} from "src/errors/ProtocolErrors.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {Defaults} from "../Base.t.sol"; import {TestToken} from "../../mocks/TestToken.sol"; @@ -214,7 +215,12 @@ contract GlobalParams_UnitTest is Test, Defaults { vm.prank(admin); globalParams.enlistPlatform(platformHash, platformAdmin, 400, address(0)); - vm.expectRevert(GlobalParams.GlobalParamsInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + GlobalParams.GlobalParamsInvalidInput.selector, + ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_GOAL_APPLIES_PROTOCOL_FEE + ) + ); vm.prank(platformAdmin); globalParams.setPlatformLineItemType( platformHash, @@ -235,7 +241,12 @@ contract GlobalParams_UnitTest is Test, Defaults { vm.prank(admin); globalParams.enlistPlatform(platformHash, platformAdmin, 450, address(0)); - vm.expectRevert(GlobalParams.GlobalParamsInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + GlobalParams.GlobalParamsInvalidInput.selector, + ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_GOAL_NOT_REFUNDABLE + ) + ); vm.prank(platformAdmin); globalParams.setPlatformLineItemType( platformHash, @@ -256,7 +267,12 @@ contract GlobalParams_UnitTest is Test, Defaults { vm.prank(admin); globalParams.enlistPlatform(platformHash, platformAdmin, 300, address(0)); - vm.expectRevert(GlobalParams.GlobalParamsInvalidInput.selector); + vm.expectRevert( + abi.encodeWithSelector( + GlobalParams.GlobalParamsInvalidInput.selector, + ProtocolErrors.GlobalParamsInvalidInput.LINE_ITEM_NON_GOAL_INSTANT_REFUNDABLE + ) + ); vm.prank(platformAdmin); globalParams.setPlatformLineItemType( platformHash, @@ -304,6 +320,24 @@ contract GlobalParams_UnitTest is Test, Defaults { assertEq(tokens.length, 0); } + function testInitializerRevertOnZeroProtocolAdmin() public { + bytes32[] memory currencies = new bytes32[](0); + address[][] memory tokensPerCurrency = new address[][](0); + + GlobalParams zeroAdminImpl = new GlobalParams(); + bytes memory initData = abi.encodeWithSelector( + GlobalParams.initialize.selector, address(0), protocolFee, currencies, tokensPerCurrency + ); + + vm.expectRevert( + abi.encodeWithSelector( + GlobalParams.GlobalParamsInvalidInput.selector, + ProtocolErrors.GlobalParamsInvalidInput.ZERO_ADDRESS + ) + ); + new ERC1967Proxy(address(zeroAdminImpl), initData); + } + function testInitializerRevertOnMismatchedArrays() public { bytes32[] memory currencies = new bytes32[](2); currencies[0] = USD; diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index d31a64d2..b6145fc4 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -13,6 +13,8 @@ import {Defaults} from "../Base.t.sol"; import {IReward} from "src/interfaces/IReward.sol"; import {ICampaignData} from "src/interfaces/ICampaignData.sol"; import {TestToken} from "../../mocks/TestToken.sol"; +import {MockPermit2} from "../../mocks/MockPermit2.sol"; +import {TreasuryErrors} from "src/errors/TreasuryErrors.sol"; contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Test { // Test constants @@ -82,21 +84,11 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te //////////////////////////////////////////////////////////////*/ function testConfigureTreasury() public { - ICampaignData.CampaignData memory newCampaignData = ICampaignData.CampaignData({ - launchTime: block.timestamp + 1 days, - deadline: block.timestamp + 31 days, - goalAmount: 5000, - currency: bytes32("USD") - }); - - KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); - - vm.prank(users.platform2AdminAddress); - keepWhatsRaised.configureTreasury(CONFIG, newCampaignData, FEE_KEYS, feeValues); - - assertEq(keepWhatsRaised.getLaunchTime(), newCampaignData.launchTime); - assertEq(keepWhatsRaised.getDeadline(), newCampaignData.deadline); - assertEq(keepWhatsRaised.getGoalAmount(), newCampaignData.goalAmount); + // configureTreasury was called once during setUp with CONFIG + CAMPAIGN_DATA. + // Verify the stored state reflects that initial configuration. + assertEq(keepWhatsRaised.getLaunchTime(), CAMPAIGN_DATA.launchTime); + assertEq(keepWhatsRaised.getDeadline(), CAMPAIGN_DATA.deadline); + assertEq(keepWhatsRaised.getGoalAmount(), CAMPAIGN_DATA.goalAmount); // Verify fee values are stored assertEq(keepWhatsRaised.getFeeValue(FLAT_FEE_KEY), uint256(FLAT_FEE_VALUE)); @@ -105,18 +97,22 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(keepWhatsRaised.getFeeValue(VAKI_COMMISSION_KEY), uint256(VAKI_COMMISSION_VALUE)); } + function testConfigureTreasury_RevertsWhenAlreadyConfigured() public { + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedAlreadyConfigured.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, FEE_KEYS, feeValues); + } + function testConfigureTreasuryWithColombianCreator() public { - ICampaignData.CampaignData memory newCampaignData = ICampaignData.CampaignData({ - launchTime: block.timestamp + 1 days, - deadline: block.timestamp + 31 days, - goalAmount: 5000, - currency: bytes32("USD") - }); + // Deploy a fresh treasury so configureTreasury can be called for the first time. + _resetTreasury(); KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); vm.prank(users.platform2AdminAddress); - keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, newCampaignData, FEE_KEYS, feeValues); + keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); // Test that Colombian creator tax is not applied in pledges _setupReward(); @@ -124,12 +120,13 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(keepWhatsRaised.getLaunchTime()); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; - keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection); + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, 0, rewardSelection, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection, permitData); vm.stopPrank(); // Available amount should not include Colombian tax deduction at pledge time @@ -150,6 +147,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te } function testConfigureTreasuryRevertWhenInvalidCampaignData() public { + // Deploy a fresh unconfigured treasury so input validation is reachable. + _resetTreasury(); + // Invalid launch time (in the past) ICampaignData.CampaignData memory invalidCampaignData = ICampaignData.CampaignData({ launchTime: block.timestamp - 1, @@ -160,22 +160,79 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedLaunchTimeInPast.selector); vm.prank(users.platform2AdminAddress); keepWhatsRaised.configureTreasury(CONFIG, invalidCampaignData, FEE_KEYS, feeValues); } function testConfigureTreasuryRevertWhenMismatchedFeeArrays() public { + // Deploy a fresh unconfigured treasury so input validation is reachable. + _resetTreasury(); + // Create mismatched fee arrays KeepWhatsRaised.FeeKeys memory mismatchedKeys = FEE_KEYS; KeepWhatsRaised.FeeValues memory mismatchedValues = _createFeeValues(); mismatchedValues.grossPercentageFeeValues = new uint256[](1); // Wrong length - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.FEE_LENGTH_MISMATCH)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, mismatchedKeys, mismatchedValues); } + function testConfigureTreasuryRevertWhenDuplicateFlatKeys() public { + _resetTreasury(); + KeepWhatsRaised.FeeKeys memory keys = FEE_KEYS; + keys.flatFeeKey = keys.cumulativeFlatFeeKey; // same key for both flat fees + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedDuplicateFeeKey.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, keys, feeValues); + } + + function testConfigureTreasuryRevertWhenFlatKeyEqualsPercentageKey() public { + _resetTreasury(); + KeepWhatsRaised.FeeKeys memory keys = FEE_KEYS; + keys.flatFeeKey = PLATFORM_FEE_KEY; // flat key collides with percentage key + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedDuplicateFeeKey.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, keys, feeValues); + } + + function testConfigureTreasuryRevertWhenDuplicatePercentageKeys() public { + _resetTreasury(); + KeepWhatsRaised.FeeKeys memory keys = FEE_KEYS; + keys.grossPercentageFeeKeys[1] = keys.grossPercentageFeeKeys[0]; // duplicate + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedDuplicateFeeKey.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, keys, feeValues); + } + + function testConfigureTreasuryRevertWhenPercentageFeeExceedsMax() public { + _resetTreasury(); + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + feeValues.grossPercentageFeeValues[0] = PERCENT_DIVIDER; // 100% not allowed + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedPercentageFeeExceedsMax.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, FEE_KEYS, feeValues); + } + + function testConfigureTreasuryRevertWhenAggregatePercentageExceedsMax() public { + _resetTreasury(); + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + feeValues.grossPercentageFeeValues[0] = 6000; // 60% + feeValues.grossPercentageFeeValues[1] = 5000; // 50% -> total 110% + + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedAggregatePercentageExceedsMax.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG, CAMPAIGN_DATA, FEE_KEYS, feeValues); + } + /*////////////////////////////////////////////////////////////// PAYMENT GATEWAY FEES //////////////////////////////////////////////////////////////*/ @@ -270,7 +327,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te } function testUpdateDeadlineRevertWhenDeadlineBeforeLaunchTime() public { - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.INVALID_DEADLINE)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.updateDeadline(LAUNCH_TIME - 1); } @@ -278,7 +335,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te function testUpdateDeadlineRevertWhenDeadlineBeforeCurrentTime() public { vm.warp(LAUNCH_TIME + 5 days); - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.INVALID_DEADLINE)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.updateDeadline(LAUNCH_TIME + 4 days); } @@ -321,7 +378,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te } function testUpdateGoalAmountRevertWhenZero() public { - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.ZERO_GOAL_AMOUNT)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.updateGoalAmount(0); } @@ -335,7 +392,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); @@ -349,7 +406,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te bytes32[] memory rewardNames = new bytes32[](2); Reward[] memory rewards = new Reward[](1); - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.REWARD_LENGTH_MISMATCH)); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); } @@ -359,7 +416,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); // Add first time vm.prank(users.creator1Address); @@ -377,7 +434,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); @@ -387,12 +444,12 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te keepWhatsRaised.removeReward(TEST_REWARD_NAME); // Verify removal - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.REWARD_NOT_FOUND)); keepWhatsRaised.getReward(TEST_REWARD_NAME); } function testRemoveRewardRevertWhenRewardDoesNotExist() public { - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.REWARD_NOT_FOUND)); vm.prank(users.creator1Address); keepWhatsRaised.removeReward(TEST_REWARD_NAME); } @@ -405,7 +462,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); vm.expectRevert(); vm.prank(users.creator1Address); @@ -418,7 +475,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); @@ -445,13 +502,14 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Pledge vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeForAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitData ); vm.stopPrank(); @@ -468,20 +526,21 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT * 2); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT * 2); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; // First pledge - keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, 0, rewardSelection, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection, permitData1); // Try to pledge with same ID - bytes32 internalPledgeId = keccak256(abi.encodePacked(TEST_PLEDGE_ID, users.backer1Address)); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, 0, rewardSelection, 1, block.timestamp + 1 hours); vm.expectRevert( - abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedPledgeAlreadyProcessed.selector, internalPledgeId) + abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedPledgeAlreadyProcessed.selector, TEST_PLEDGE_ID) ); - keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection); + keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection, permitData2); vm.stopPrank(); } @@ -491,7 +550,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, false); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, false, false); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); @@ -499,13 +558,42 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Try to pledge vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); - keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection); + PermitData memory emptyPermit; + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.EMPTY_SIGNATURE)); + keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection, emptyPermit); + vm.stopPrank(); + } + + function testPledgeForARewardRevertWhenAddOnNotAllowed() public { + bytes32 addOnRewardName = keccak256(abi.encodePacked("addOnReward")); + + bytes32[] memory rewardNames = new bytes32[](2); + rewardNames[0] = TEST_REWARD_NAME; + rewardNames[1] = addOnRewardName; + + Reward[] memory rewards = new Reward[](2); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); + rewards[1] = _createTestReward(TEST_PLEDGE_AMOUNT / 2, false, false); + + vm.prank(users.creator1Address); + keepWhatsRaised.addRewards(rewardNames, rewards); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT * 2); + + bytes32[] memory rewardSelection = new bytes32[](2); + rewardSelection[0] = TEST_REWARD_NAME; + rewardSelection[1] = addOnRewardName; + + PermitData memory emptyPermit2; + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.EMPTY_SIGNATURE)); + keepWhatsRaised.pledgeForAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, rewardSelection, emptyPermit2); vm.stopPrank(); } @@ -518,9 +606,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Pledge vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), pledgeAmount + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, pledgeAmount + TEST_TIP_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, pledgeAmount, TEST_TIP_AMOUNT, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT + TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT, permitData ); vm.stopPrank(); @@ -536,20 +625,57 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT * 2); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT * 2); // First pledge + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData1 ); - // Try to pledge with same ID - internal pledge ID includes caller - bytes32 internalPledgeId = keccak256(abi.encodePacked(TEST_PLEDGE_ID, users.backer1Address)); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 1, block.timestamp + 1 hours); vm.expectRevert( - abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedPledgeAlreadyProcessed.selector, internalPledgeId) + abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedPledgeAlreadyProcessed.selector, TEST_PLEDGE_ID) + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData2 + ); + vm.stopPrank(); + } + + function testPledgeWithoutARewardRevertWhenPermitMissing() public { + vm.warp(LAUNCH_TIME); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.EMPTY_SIGNATURE)); + vm.prank(users.backer1Address); + PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""}); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, emptyPermitData ); + } + + function testPledgeWithoutARewardRevertWhenSignedPledgeIdIsTampered() public { + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + TEST_PLEDGE_AMOUNT, + 0, + 55, + block.timestamp + 1 hours + ); + + vm.expectRevert(MockPermit2.InvalidSigner.selector); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + keccak256("tamperedPledgeId"), + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + 0, + permitData ); vm.stopPrank(); } @@ -559,16 +685,18 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME - 1); vm.expectRevert(); vm.prank(users.backer1Address); + PermitData memory emptyPermit1; keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, emptyPermit1 ); // After deadline vm.warp(DEADLINE + 1); vm.expectRevert(); vm.prank(users.backer1Address); + PermitData memory emptyPermit2; keepWhatsRaised.pledgeWithoutAReward( - keccak256("newPledge"), users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + keccak256("newPledge"), users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, emptyPermit2 ); } @@ -581,14 +709,15 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Try to pledge vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; + PermitData memory emptyPermit; vm.expectRevert(); keepWhatsRaised.pledgeForAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, emptyPermit ); vm.stopPrank(); } @@ -744,8 +873,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), largePledge); - keepWhatsRaised.pledgeWithoutAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), largePledge, 0); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, largePledge); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, largePledge, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), largePledge, 0, permitData); vm.stopPrank(); uint256 availableAfterPledge = keepWhatsRaised.getAvailableRaisedAmount(); @@ -776,7 +906,8 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te } function testWithdrawWithColombianCreatorTax() public { - // Configure with Colombian creator + // Deploy a fresh treasury and configure it with Colombian creator settings. + _resetTreasury(); KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); vm.prank(users.platform2AdminAddress); keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); @@ -786,9 +917,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); vm.stopPrank(); @@ -829,9 +961,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); uint256 tokenId = 1; // First token ID after pledge vm.stopPrank(); @@ -866,9 +999,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); uint256 tokenId = 1; // First token ID after pledge vm.stopPrank(); @@ -886,9 +1020,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); uint256 tokenId = 1; // First token ID after pledge vm.stopPrank(); @@ -925,9 +1060,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); uint256 tokenId = 0; vm.stopPrank(); @@ -947,9 +1083,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); uint256 tokenId = 0; vm.stopPrank(); @@ -1062,7 +1199,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te _setupPledges(); vm.warp(DEADLINE + WITHDRAWAL_DELAY - 1); - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedNotClaimableAdmin.selector); + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedClaimFundWindowNotReached.selector); vm.prank(users.platform2AdminAddress); keepWhatsRaised.claimFund(); } @@ -1146,8 +1283,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.expectRevert(); vm.prank(users.backer1Address); + PermitData memory emptyPermit1; keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, emptyPermit1 ); } @@ -1162,8 +1300,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.expectRevert(); vm.prank(users.backer1Address); + PermitData memory emptyPermit2; keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, emptyPermit2 ); } @@ -1238,8 +1377,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), smallPledge); - keepWhatsRaised.pledgeWithoutAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), smallPledge, 0); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, smallPledge); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, smallPledge, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(TEST_PLEDGE_ID, users.backer1Address, address(testToken), smallPledge, 0, permitData); vm.stopPrank(); vm.prank(users.platform2AdminAddress); @@ -1260,9 +1400,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); vm.stopPrank(); @@ -1275,9 +1416,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0 + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData ); vm.stopPrank(); @@ -1293,7 +1435,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te } function testGetRewardRevertWhenNotExists() public { - vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedInvalidInput.selector, TreasuryErrors.InvalidInput.REWARD_NOT_FOUND)); keepWhatsRaised.getReward(keccak256("nonexistent")); } @@ -1315,7 +1457,8 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te function testComplexFeeScenario() public { // Testing multiple pledges with different fee structures - // Configure Colombian creator for complex fee testing + // Deploy a fresh treasury and configure with Colombian creator settings. + _resetTreasury(); KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); vm.prank(users.platform2AdminAddress); keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); @@ -1329,11 +1472,12 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te ); vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), keccak256("pledge1"), TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeForAReward( - keccak256("pledge1"), users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection + keccak256("pledge1"), users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitData1 ); vm.stopPrank(); @@ -1343,8 +1487,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te users.platform2AdminAddress, address(keepWhatsRaised), keccak256("pledge2"), differentGatewayFee ); vm.startPrank(users.backer2Address); - testToken.approve(address(keepWhatsRaised), 2000e18); - keepWhatsRaised.pledgeWithoutAReward(keccak256("pledge2"), users.backer2Address, address(testToken), 2000e18, 0); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, 2000e18); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(testToken), keccak256("pledge2"), 2000e18, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("pledge2"), users.backer2Address, address(testToken), 2000e18, 0, permitData2); vm.stopPrank(); // Verify total raised and available amounts @@ -1382,9 +1527,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), smallAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, smallAmount); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(testToken), keccak256("small"), smallAmount, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("small"), users.backer1Address, address(testToken), smallAmount, 0 + keccak256("small"), users.backer1Address, address(testToken), smallAmount, 0, permitData ); vm.stopPrank(); @@ -1429,7 +1575,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te HELPER FUNCTIONS //////////////////////////////////////////////////////////////*/ - function _createTestReward(uint256 value, bool isRewardTier) internal pure returns (Reward memory) { + function _createTestReward(uint256 value, bool isRewardTier, bool canBeAddOn) internal pure returns (Reward memory) { bytes32[] memory itemIds = new bytes32[](1); uint256[] memory itemValues = new uint256[](1); uint256[] memory itemQuantities = new uint256[](1); @@ -1441,6 +1587,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te return Reward({ rewardValue: value, isRewardTier: isRewardTier, + canBeAddOn: canBeAddOn, itemId: itemIds, itemValue: itemValues, itemQuantity: itemQuantities @@ -1452,7 +1599,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te rewardNames[0] = TEST_REWARD_NAME; Reward[] memory rewards = new Reward[](1); - rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); vm.prank(users.creator1Address); keepWhatsRaised.addRewards(rewardNames, rewards); @@ -1474,19 +1621,21 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Backer 1 pledge with reward vm.startPrank(users.backer1Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); bytes32[] memory rewardSelection = new bytes32[](1); rewardSelection[0] = TEST_REWARD_NAME; + PermitData memory permitDataBacker1 = _buildSignedKeepWhatsRaisedRewardPermitData(users.backer1Address, address(testToken), keccak256("pledge1"), TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeForAReward( - keccak256("pledge1"), users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection + keccak256("pledge1"), users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitDataBacker1 ); vm.stopPrank(); // Backer 2 pledge without reward vm.startPrank(users.backer2Address); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + PermitData memory permitDataBacker2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(testToken), keccak256("pledge2"), TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("pledge2"), users.backer2Address, address(testToken), TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT + keccak256("pledge2"), users.backer2Address, address(testToken), TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT, permitDataBacker2 ); vm.stopPrank(); } @@ -1521,9 +1670,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - usdcToken.approve(address(keepWhatsRaised), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("usdc_pledge"), usdcAmount, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("usdc_pledge"), users.backer1Address, address(usdcToken), usdcAmount, 0 + keccak256("usdc_pledge"), users.backer1Address, address(usdcToken), usdcAmount, 0, permitData1 ); vm.stopPrank(); @@ -1531,9 +1681,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), keccak256("cusd_pledge"), 0); vm.startPrank(users.backer2Address); - cUSDToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(cUSDToken), keccak256("cusd_pledge"), TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("cusd_pledge"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, 0 + keccak256("cusd_pledge"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, 0, permitData2 ); vm.stopPrank(); @@ -1556,8 +1707,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); deal(address(usdcToken), users.backer1Address, usdcAmount); // Ensure enough tokens - usdcToken.approve(address(keepWhatsRaised), usdcAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("usdc"), users.backer1Address, address(usdcToken), usdcAmount, 0); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("usdc"), usdcAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("usdc"), users.backer1Address, address(usdcToken), usdcAmount, 0, permitData1); vm.stopPrank(); // Pledge with cUSD @@ -1565,8 +1717,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.startPrank(users.backer2Address); deal(address(cUSDToken), users.backer2Address, cUSDAmount); // Ensure enough tokens - cUSDToken.approve(address(keepWhatsRaised), cUSDAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("cusd"), users.backer2Address, address(cUSDToken), cUSDAmount, 0); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, cUSDAmount); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(cUSDToken), keccak256("cusd"), cUSDAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("cusd"), users.backer2Address, address(cUSDToken), cUSDAmount, 0, permitData2); vm.stopPrank(); // Approve withdrawal @@ -1606,21 +1759,24 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // USDC pledge vm.startPrank(users.backer1Address); - usdcToken.approve(address(keepWhatsRaised), usdcAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("usdc"), users.backer1Address, address(usdcToken), usdcAmount, 0); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("usdc"), usdcAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("usdc"), users.backer1Address, address(usdcToken), usdcAmount, 0, permitData1); vm.stopPrank(); // USDT pledge vm.startPrank(users.backer2Address); - usdtToken.approve(address(keepWhatsRaised), usdtAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("usdt"), users.backer2Address, address(usdtToken), usdtAmount, 0); + usdtToken.approve(CANONICAL_PERMIT2_ADDRESS, usdtAmount); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(usdtToken), keccak256("usdt"), usdtAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("usdt"), users.backer2Address, address(usdtToken), usdtAmount, 0, permitData2); vm.stopPrank(); // cUSD pledge vm.startPrank(users.backer1Address); - cUSDToken.approve(address(keepWhatsRaised), PLEDGE_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, PLEDGE_AMOUNT); + PermitData memory permitData3 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(cUSDToken), keccak256("cusd"), PLEDGE_AMOUNT, 0, 1, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("cusd"), users.backer1Address, address(cUSDToken), PLEDGE_AMOUNT, 0 + keccak256("cusd"), users.backer1Address, address(cUSDToken), PLEDGE_AMOUNT, 0, permitData3 ); vm.stopPrank(); @@ -1675,9 +1831,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - usdcToken.approve(address(keepWhatsRaised), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("usdc_pledge"), usdcAmount, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("usdc_pledge"), users.backer1Address, address(usdcToken), usdcAmount, 0 + keccak256("usdc_pledge"), users.backer1Address, address(usdcToken), usdcAmount, 0, permitData1 ); uint256 usdcTokenId = 1; // First pledge vm.stopPrank(); @@ -1686,9 +1843,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), keccak256("cusd_pledge"), 0); vm.startPrank(users.backer2Address); - cUSDToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(cUSDToken), keccak256("cusd_pledge"), TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("cusd_pledge"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, 0 + keccak256("cusd_pledge"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, 0, permitData2 ); uint256 cUSDTokenId = 2; // Second pledge vm.stopPrank(); @@ -1729,9 +1887,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(LAUNCH_TIME); vm.startPrank(users.backer1Address); - usdcToken.approve(address(keepWhatsRaised), usdcPledge + tipAmountUSDC); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcPledge + tipAmountUSDC); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("usdc"), usdcPledge, tipAmountUSDC, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("usdc"), users.backer1Address, address(usdcToken), usdcPledge, tipAmountUSDC + keccak256("usdc"), users.backer1Address, address(usdcToken), usdcPledge, tipAmountUSDC, permitData1 ); vm.stopPrank(); @@ -1739,9 +1898,10 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), keccak256("cusd"), 0); vm.startPrank(users.backer2Address); - cUSDToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + tipAmountCUSD); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + tipAmountCUSD); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(cUSDToken), keccak256("cusd"), TEST_PLEDGE_AMOUNT, tipAmountCUSD, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("cusd"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, tipAmountCUSD + keccak256("cusd"), users.backer2Address, address(cUSDToken), TEST_PLEDGE_AMOUNT, tipAmountCUSD, permitData2 ); vm.stopPrank(); @@ -1783,8 +1943,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // USDC pledge vm.startPrank(users.backer1Address); - usdcToken.approve(address(keepWhatsRaised), usdcAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("p1"), users.backer1Address, address(usdcToken), usdcAmount, 0); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdcToken), keccak256("p1"), usdcAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("p1"), users.backer1Address, address(usdcToken), usdcAmount, 0, permitData1); vm.stopPrank(); uint256 raisedAfterUSDC = keepWhatsRaised.getRaisedAmount(); @@ -1792,8 +1953,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // USDT pledge vm.startPrank(users.backer2Address); - usdtToken.approve(address(keepWhatsRaised), usdtAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("p2"), users.backer2Address, address(usdtToken), usdtAmount, 0); + usdtToken.approve(CANONICAL_PERMIT2_ADDRESS, usdtAmount); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(usdtToken), keccak256("p2"), usdtAmount, 0, 0, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("p2"), users.backer2Address, address(usdtToken), usdtAmount, 0, permitData2); vm.stopPrank(); uint256 raisedAfterUSDT = keepWhatsRaised.getRaisedAmount(); @@ -1801,8 +1963,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // cUSD pledge vm.startPrank(users.backer1Address); - cUSDToken.approve(address(keepWhatsRaised), cUSDAmount); - keepWhatsRaised.pledgeWithoutAReward(keccak256("p3"), users.backer1Address, address(cUSDToken), cUSDAmount, 0); + cUSDToken.approve(CANONICAL_PERMIT2_ADDRESS, cUSDAmount); + PermitData memory permitData3 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(cUSDToken), keccak256("p3"), cUSDAmount, 0, 1, block.timestamp + 1 hours); + keepWhatsRaised.pledgeWithoutAReward(keccak256("p3"), users.backer1Address, address(cUSDToken), cUSDAmount, 0, permitData3); vm.stopPrank(); uint256 finalRaised = keepWhatsRaised.getRaisedAmount(); @@ -1828,17 +1991,19 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // USDT pledge vm.startPrank(users.backer1Address); - usdtToken.approve(address(keepWhatsRaised), usdtAmount); + usdtToken.approve(CANONICAL_PERMIT2_ADDRESS, usdtAmount); + PermitData memory permitData1 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer1Address, address(usdtToken), keccak256("usdt_pledge"), usdtAmount, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("usdt_pledge"), users.backer1Address, address(usdtToken), usdtAmount, 0 + keccak256("usdt_pledge"), users.backer1Address, address(usdtToken), usdtAmount, 0, permitData1 ); vm.stopPrank(); // USDC pledge vm.startPrank(users.backer2Address); - usdcToken.approve(address(keepWhatsRaised), usdcAmount); + usdcToken.approve(CANONICAL_PERMIT2_ADDRESS, usdcAmount); + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData(users.backer2Address, address(usdcToken), keccak256("usdc_pledge"), usdcAmount, 0, 0, block.timestamp + 1 hours); keepWhatsRaised.pledgeWithoutAReward( - keccak256("usdc_pledge"), users.backer2Address, address(usdcToken), usdcAmount, 0 + keccak256("usdc_pledge"), users.backer2Address, address(usdcToken), usdcAmount, 0, permitData2 ); vm.stopPrank(); diff --git a/test/foundry/unit/PaymentTreasury.t.sol b/test/foundry/unit/PaymentTreasury.t.sol index a07907c2..90ac9861 100644 --- a/test/foundry/unit/PaymentTreasury.t.sol +++ b/test/foundry/unit/PaymentTreasury.t.sol @@ -7,6 +7,8 @@ import {PaymentTreasury} from "src/treasuries/PaymentTreasury.sol"; import {BasePaymentTreasury} from "src/utils/BasePaymentTreasury.sol"; import {CampaignInfo} from "src/CampaignInfo.sol"; import {TestToken} from "../../mocks/TestToken.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; +import {MockPermit2} from "../../mocks/MockPermit2.sol"; contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Test { // Helper function to create payment tokens array with same token for all payments @@ -290,7 +292,7 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te // Pause the campaign CampaignInfo actualCampaignInfo = CampaignInfo(campaignAddress); vm.prank(users.protocolAdminAddress); - actualCampaignInfo._pauseCampaign(keccak256("Pause")); + actualCampaignInfo.pauseCampaign(keccak256("Pause")); vm.expectRevert(); vm.prank(users.platform1AdminAddress); @@ -335,6 +337,32 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te assertEq(testToken.balanceOf(treasuryAddress), amount); } + function testProcessCryptoPaymentRevertWhenSignedItemIdIsTampered() public { + uint256 amount = 1500e18; + deal(address(testToken), users.backer1Address, amount); + + vm.prank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, amount); + + ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, address(testToken), PAYMENT_ID_1, ITEM_ID_1, amount, emptyLineItems, 77, block.timestamp + 1 hours + ); + + vm.expectRevert(MockPermit2.InvalidSigner.selector); + vm.prank(users.platform1AdminAddress); + paymentTreasury.processCryptoPayment( + PAYMENT_ID_1, + ITEM_ID_2, + users.backer1Address, + address(testToken), + amount, + emptyLineItems, + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData + ); + } + function testProcessCryptoPaymentStoresExternalFees() public { uint256 amount = 1000e18; deal(address(testToken), users.backer1Address, amount); @@ -370,32 +398,38 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te } function testProcessCryptoPaymentRevertWhenZeroBuyerAddress() public { - vm.expectRevert(); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); - processCryptoPayment( - users.platform1AdminAddress, + PermitData memory permitData = PermitData({nonce: 0, deadline: block.timestamp + 1 hours, signature: new bytes(65)}); + + vm.expectRevert(BasePaymentTreasury.PaymentTreasuryZeroBuyerAddress.selector); + vm.prank(users.platform1AdminAddress); + paymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, address(0), address(testToken), 1000e18, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); } function testProcessCryptoPaymentRevertWhenZeroAmount() public { - vm.expectRevert(); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); - processCryptoPayment( - users.platform1AdminAddress, + PermitData memory permitData = PermitData({nonce: 0, deadline: block.timestamp + 1 hours, signature: new bytes(65)}); + + vm.expectRevert(BasePaymentTreasury.PaymentTreasuryZeroAmount.selector); + vm.prank(users.platform1AdminAddress); + paymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, users.backer1Address, address(testToken), 0, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); } @@ -404,7 +438,7 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te deal(address(testToken), users.backer1Address, amount * 2); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, amount * 2); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, amount * 2); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); processCryptoPayment( @@ -418,16 +452,23 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te new ICampaignPaymentTreasury.ExternalFees[](0) ); + // Build permit data with a unique nonce so Permit2 doesn't reject the nonce + // before reaching the "payment exists" check in the contract + PermitData memory permitData2 = _buildSignedCryptoPaymentPermitData( + users.backer1Address, address(testToken), PAYMENT_ID_1, ITEM_ID_1, amount, emptyLineItems, 1, block.timestamp + 1 hours + ); + vm.expectRevert(); - processCryptoPayment( - users.backer1Address, + vm.prank(users.backer1Address); + paymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, users.backer1Address, address(testToken), amount, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData2 ); } @@ -467,8 +508,18 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te deal(address(testToken), users.backer1Address, totalAmount); vm.prank(users.backer1Address); - testToken.approve(treasuryAddress, totalAmount); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, totalAmount); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + lineItems, + 0, + block.timestamp + 1 hours + ); vm.prank(users.backer1Address); paymentTreasury.processCryptoPayment( PAYMENT_ID_1, @@ -477,7 +528,8 @@ contract PaymentTreasury_UnitTest is Test, PaymentTreasury_Integration_Shared_Te address(testToken), PAYMENT_AMOUNT_1, lineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); uint256 claimableAt = CampaignInfo(campaignAddress).getDeadline() + claimDelay; diff --git a/test/foundry/unit/PledgeNFT.t.sol b/test/foundry/unit/PledgeNFT.t.sol index a79122d7..0a928c3c 100644 --- a/test/foundry/unit/PledgeNFT.t.sol +++ b/test/foundry/unit/PledgeNFT.t.sol @@ -84,8 +84,12 @@ contract PledgeNFT_Test is Base_Test { uint256 tokenId2 = campaign.mintNFTForPledge(users.backer1Address, bytes32(0), address(testToken), 100e18, 0, 0); assertEq(tokenId2, 2, "Second token ID should be 2"); - // Burn first NFT + // Backer approves treasury to burn their NFT (required by ERC721Burnable) vm.prank(users.backer1Address); + campaign.approve(address(treasury), tokenId1); + + // Burn first NFT — only treasury (TREASURY_ROLE) can burn + vm.prank(address(treasury)); campaign.burn(tokenId1); // Mint third NFT - should be 3, NOT reusing 1 @@ -104,8 +108,12 @@ contract PledgeNFT_Test is Base_Test { assertEq(campaign.balanceOf(users.backer1Address), 1, "Backer should have 1 NFT"); - // Burn NFT + // Backer approves treasury to burn their NFT (required by ERC721Burnable) vm.prank(users.backer1Address); + campaign.approve(address(treasury), tokenId); + + // Burn NFT — only treasury (TREASURY_ROLE) can burn + vm.prank(address(treasury)); campaign.burn(tokenId); // Verify NFT was burned @@ -115,4 +123,23 @@ contract PledgeNFT_Test is Base_Test { vm.expectRevert(); campaign.ownerOf(tokenId); } + + function test_OnlyTreasuryCanBurnNFT() public { + // Mint NFT as treasury + vm.prank(address(treasury)); + uint256 tokenId = campaign.mintNFTForPledge(users.backer1Address, bytes32(0), address(testToken), 100e18, 0, 0); + + // Backer (NFT owner) cannot burn — TREASURY_ROLE required + vm.expectRevert(); + vm.prank(users.backer1Address); + campaign.burn(tokenId); + + // Unrelated address cannot burn + vm.expectRevert(); + vm.prank(users.creator1Address); + campaign.burn(tokenId); + + // NFT still exists + assertEq(campaign.ownerOf(tokenId), users.backer1Address, "NFT should still exist after failed burns"); + } } diff --git a/test/foundry/unit/TimeConstrainedPaymentTreasury.t.sol b/test/foundry/unit/TimeConstrainedPaymentTreasury.t.sol index 845d2446..44fc8767 100644 --- a/test/foundry/unit/TimeConstrainedPaymentTreasury.t.sol +++ b/test/foundry/unit/TimeConstrainedPaymentTreasury.t.sol @@ -7,6 +7,8 @@ import {TimeConstrainedPaymentTreasury} from "src/treasuries/TimeConstrainedPaym import {CampaignInfo} from "src/CampaignInfo.sol"; import {TestToken} from "../../mocks/TestToken.sol"; import {DataRegistryKeys} from "src/constants/DataRegistryKeys.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; contract TimeConstrainedPaymentTreasury_UnitTest is Test, @@ -219,12 +221,22 @@ contract TimeConstrainedPaymentTreasury_UnitTest is function testProcessCryptoPaymentWithinTimeRange() public { advanceToWithinRange(); - // Approve tokens for the treasury + // Approve MockPermit2 (at canonical address) for the token. vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -232,7 +244,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Payment processed successfully @@ -242,9 +255,10 @@ contract TimeConstrainedPaymentTreasury_UnitTest is function testProcessCryptoPaymentRevertWhenBeforeLaunchTime() public { advanceToBeforeLaunch(); + ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory emptyPermit; vm.expectRevert(); vm.prank(users.platform1AdminAddress); - ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -252,7 +266,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + emptyPermit ); } @@ -294,10 +309,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -305,7 +330,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Payment created and confirmed successfully by processCryptoPayment @@ -325,10 +351,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment for both payments which creates and confirms them vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData1 = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -336,14 +372,25 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData1 ); vm.prank(users.backer2Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_2); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_2); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems2 = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData2 = _buildSignedCryptoPaymentPermitData( + users.backer2Address, + address(testToken), + PAYMENT_ID_2, + ITEM_ID_2, + PAYMENT_AMOUNT_2, + emptyLineItems2, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_2, ITEM_ID_2, @@ -351,7 +398,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_2, emptyLineItems2, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData2 ); // Payments created and confirmed successfully by processCryptoPayment @@ -379,10 +427,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -390,7 +448,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch to be able to claim refund @@ -422,10 +481,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -433,7 +502,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch time @@ -460,10 +530,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -471,7 +551,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch time @@ -612,10 +693,20 @@ contract TimeConstrainedPaymentTreasury_UnitTest is // Use processCryptoPayment which creates and confirms payment in one step vm.prank(users.backer1Address); - testToken.approve(address(timeConstrainedPaymentTreasury), PAYMENT_AMOUNT_1); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, PAYMENT_AMOUNT_1); - vm.prank(users.platform1AdminAddress); ICampaignPaymentTreasury.LineItem[] memory emptyLineItems = new ICampaignPaymentTreasury.LineItem[](0); + PermitData memory permitData = _buildSignedCryptoPaymentPermitData( + users.backer1Address, + address(testToken), + PAYMENT_ID_1, + ITEM_ID_1, + PAYMENT_AMOUNT_1, + emptyLineItems, + 0, + block.timestamp + 1 hours + ); + vm.prank(users.platform1AdminAddress); timeConstrainedPaymentTreasury.processCryptoPayment( PAYMENT_ID_1, ITEM_ID_1, @@ -623,7 +714,8 @@ contract TimeConstrainedPaymentTreasury_UnitTest is address(testToken), PAYMENT_AMOUNT_1, emptyLineItems, - new ICampaignPaymentTreasury.ExternalFees[](0) + new ICampaignPaymentTreasury.ExternalFees[](0), + permitData ); // Advance to after launch time diff --git a/test/foundry/unit/Upgrades.t.sol b/test/foundry/unit/Upgrades.t.sol index 0dbb08e1..0dbef1ec 100644 --- a/test/foundry/unit/Upgrades.t.sol +++ b/test/foundry/unit/Upgrades.t.sol @@ -58,7 +58,6 @@ contract Upgrades_Test is Test, Defaults { CampaignInfoFactory campaignFactoryImpl = new CampaignInfoFactory(); bytes memory campaignFactoryInitData = abi.encodeWithSelector( CampaignInfoFactory.initialize.selector, - admin, IGlobalParams(address(globalParams)), address(campaignInfoImpl), address(treasuryFactory) @@ -294,7 +293,7 @@ contract Upgrades_Test is Test, Defaults { vm.expectRevert(); campaignFactory.initialize( - admin, IGlobalParams(address(globalParams)), address(campaignInfoImpl), address(treasuryFactory) + IGlobalParams(address(globalParams)), address(campaignInfoImpl), address(treasuryFactory) ); } diff --git a/test/foundry/utils/Defaults.sol b/test/foundry/utils/Defaults.sol index e590ba50..e8e337b0 100644 --- a/test/foundry/utils/Defaults.sol +++ b/test/foundry/utils/Defaults.sol @@ -59,7 +59,7 @@ contract Defaults is Constants, ICampaignData, IReward { // Config values for KeepWhatsRaised uint256 public constant MINIMUM_WITHDRAWAL_FOR_FEE_EXEMPTION = 50_000e18; - uint256 public constant WITHDRAWAL_DELAY = 7 days; + uint256 public constant WITHDRAWAL_DELAY = 14 days; uint256 public constant REFUND_DELAY = 14 days; uint256 public constant CONFIG_LOCK_PERIOD = 2 days; @@ -115,6 +115,7 @@ contract Defaults is Constants, ICampaignData, IReward { REWARDS[0] = Reward({ rewardValue: 1_000e18, isRewardTier: true, + canBeAddOn: false, itemId: itemIds1, itemValue: itemValues1, itemQuantity: itemQuantities1 @@ -136,6 +137,7 @@ contract Defaults is Constants, ICampaignData, IReward { REWARDS[1] = Reward({ rewardValue: 2_500e18, isRewardTier: true, + canBeAddOn: true, itemId: itemIds2, itemValue: itemValues2, itemQuantity: itemQuantities2 @@ -151,6 +153,7 @@ contract Defaults is Constants, ICampaignData, IReward { REWARDS[2] = Reward({ rewardValue: 500e18, isRewardTier: false, + canBeAddOn: true, itemId: emptyIds, itemValue: emptyValues, itemQuantity: emptyQuantities diff --git a/test/mocks/MockPermit2.sol b/test/mocks/MockPermit2.sol new file mode 100644 index 00000000..5e0e66c7 --- /dev/null +++ b/test/mocks/MockPermit2.sol @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISignatureTransfer, IEIP712} from "../../src/interfaces/IPermit2.sol"; + +/** + * @title MockPermit2 + * @notice A solc 0.8.22-compatible re-implementation of Uniswap's Permit2 + * (SignatureTransfer only) for use in Foundry tests. + * @dev Faithfully reproduces the EIP-712 domain, struct hashing, nonce bitmap, + * signature verification, and token transfer logic of the canonical Permit2 + * so that tests exercise real cryptographic signing paths. + */ +contract MockPermit2 is ISignatureTransfer { + using SafeERC20 for IERC20; + + // ------------------------------------------------------------------------- + // EIP-712 domain + // ------------------------------------------------------------------------- + bytes32 private immutable _CACHED_DOMAIN_SEPARATOR; + uint256 private immutable _CACHED_CHAIN_ID; + + bytes32 private constant _HASHED_NAME = keccak256("Permit2"); + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + + // ------------------------------------------------------------------------- + // PermitHash constants (mirrors permit2/src/libraries/PermitHash.sol) + // ------------------------------------------------------------------------- + bytes32 private constant _TOKEN_PERMISSIONS_TYPEHASH = + keccak256("TokenPermissions(address token,uint256 amount)"); + + bytes32 private constant _PERMIT_TRANSFER_FROM_TYPEHASH = keccak256( + "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); + + bytes32 private constant _PERMIT_BATCH_TRANSFER_FROM_TYPEHASH = keccak256( + "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ); + + string private constant _PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB = + "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,"; + + string private constant _PERMIT_BATCH_WITNESS_TRANSFER_FROM_TYPEHASH_STUB = + "PermitBatchWitnessTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline,"; + + // ------------------------------------------------------------------------- + // Errors (mirrors permit2/src/PermitErrors.sol & SignatureVerification.sol) + // ------------------------------------------------------------------------- + error SignatureExpired(uint256 signatureDeadline); + error InvalidNonce(); + error InvalidSignatureLength(); + error InvalidSignature(); + error InvalidSigner(); + error InvalidContractSignature(); + + // ------------------------------------------------------------------------- + // Nonce bitmap + // ------------------------------------------------------------------------- + /// @inheritdoc ISignatureTransfer + mapping(address => mapping(uint256 => uint256)) public nonceBitmap; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + constructor() { + _CACHED_CHAIN_ID = block.chainid; + _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + // ------------------------------------------------------------------------- + // IEIP712 + // ------------------------------------------------------------------------- + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + return block.chainid == _CACHED_CHAIN_ID + ? _CACHED_DOMAIN_SEPARATOR + : _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME); + } + + // ------------------------------------------------------------------------- + // ISignatureTransfer — single-token functions + // ------------------------------------------------------------------------- + /// @inheritdoc ISignatureTransfer + function permitTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes calldata signature + ) external { + _permitTransferFrom(permit, transferDetails, owner, _hash(permit), signature); + } + + /// @inheritdoc ISignatureTransfer + function permitWitnessTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external { + _permitTransferFrom( + permit, transferDetails, owner, _hashWithWitness(permit, witness, witnessTypeString), signature + ); + } + + // ------------------------------------------------------------------------- + // ISignatureTransfer — batch functions + // ------------------------------------------------------------------------- + /// @inheritdoc ISignatureTransfer + function permitTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes calldata signature + ) external { + _permitBatchTransferFrom(permit, transferDetails, owner, _hash(permit), signature); + } + + /// @inheritdoc ISignatureTransfer + function permitWitnessTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes32 witness, + string calldata witnessTypeString, + bytes calldata signature + ) external { + _permitBatchTransferFrom( + permit, transferDetails, owner, _hashWithWitness(permit, witness, witnessTypeString), signature + ); + } + + /// @inheritdoc ISignatureTransfer + function invalidateUnorderedNonces(uint256 wordPos, uint256 mask) external { + nonceBitmap[msg.sender][wordPos] |= mask; + emit UnorderedNonceInvalidation(msg.sender, wordPos, mask); + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + // ---- single-token transfer core ---- + function _permitTransferFrom( + PermitTransferFrom memory permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes32 dataHash, + bytes calldata signature + ) private { + uint256 requestedAmount = transferDetails.requestedAmount; + + if (block.timestamp > permit.deadline) revert SignatureExpired(permit.deadline); + if (requestedAmount > permit.permitted.amount) revert InvalidAmount(permit.permitted.amount); + + _useUnorderedNonce(owner, permit.nonce); + _verify(signature, _hashTypedData(dataHash), owner); + + IERC20(permit.permitted.token).safeTransferFrom(owner, transferDetails.to, requestedAmount); + } + + // ---- batch-token transfer core ---- + function _permitBatchTransferFrom( + PermitBatchTransferFrom memory permit, + SignatureTransferDetails[] calldata transferDetails, + address owner, + bytes32 dataHash, + bytes calldata signature + ) private { + uint256 numPermitted = permit.permitted.length; + + if (block.timestamp > permit.deadline) revert SignatureExpired(permit.deadline); + if (numPermitted != transferDetails.length) revert LengthMismatch(); + + _useUnorderedNonce(owner, permit.nonce); + _verify(signature, _hashTypedData(dataHash), owner); + + unchecked { + for (uint256 i = 0; i < numPermitted; ++i) { + TokenPermissions memory permitted = permit.permitted[i]; + uint256 requestedAmount = transferDetails[i].requestedAmount; + + if (requestedAmount > permitted.amount) revert InvalidAmount(permitted.amount); + + if (requestedAmount != 0) { + IERC20(permitted.token).safeTransferFrom(owner, transferDetails[i].to, requestedAmount); + } + } + } + } + + // ---- EIP-712 ---- + function _buildDomainSeparator(bytes32 typeHash, bytes32 nameHash) private view returns (bytes32) { + return keccak256(abi.encode(typeHash, nameHash, block.chainid, address(this))); + } + + function _hashTypedData(bytes32 dataHash) private view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), dataHash)); + } + + // ---- struct hashing (mirrors PermitHash library) ---- + function _hashTokenPermissions(TokenPermissions memory permitted) private pure returns (bytes32) { + return keccak256(abi.encode(_TOKEN_PERMISSIONS_TYPEHASH, permitted)); + } + + function _hash(PermitTransferFrom memory permit) private view returns (bytes32) { + bytes32 tokenPermissionsHash = _hashTokenPermissions(permit.permitted); + return keccak256( + abi.encode(_PERMIT_TRANSFER_FROM_TYPEHASH, tokenPermissionsHash, msg.sender, permit.nonce, permit.deadline) + ); + } + + function _hash(PermitBatchTransferFrom memory permit) private view returns (bytes32) { + uint256 numPermitted = permit.permitted.length; + bytes32[] memory tokenPermissionHashes = new bytes32[](numPermitted); + + for (uint256 i = 0; i < numPermitted; ++i) { + tokenPermissionHashes[i] = _hashTokenPermissions(permit.permitted[i]); + } + + return keccak256( + abi.encode( + _PERMIT_BATCH_TRANSFER_FROM_TYPEHASH, + keccak256(abi.encodePacked(tokenPermissionHashes)), + msg.sender, + permit.nonce, + permit.deadline + ) + ); + } + + function _hashWithWitness( + PermitTransferFrom memory permit, + bytes32 witness, + string calldata witnessTypeString + ) private view returns (bytes32) { + bytes32 typeHash = + keccak256(abi.encodePacked(_PERMIT_TRANSFER_FROM_WITNESS_TYPEHASH_STUB, witnessTypeString)); + bytes32 tokenPermissionsHash = _hashTokenPermissions(permit.permitted); + return keccak256( + abi.encode(typeHash, tokenPermissionsHash, msg.sender, permit.nonce, permit.deadline, witness) + ); + } + + function _hashWithWitness( + PermitBatchTransferFrom memory permit, + bytes32 witness, + string calldata witnessTypeString + ) private view returns (bytes32) { + bytes32 typeHash = + keccak256(abi.encodePacked(_PERMIT_BATCH_WITNESS_TRANSFER_FROM_TYPEHASH_STUB, witnessTypeString)); + + uint256 numPermitted = permit.permitted.length; + bytes32[] memory tokenPermissionHashes = new bytes32[](numPermitted); + + for (uint256 i = 0; i < numPermitted; ++i) { + tokenPermissionHashes[i] = _hashTokenPermissions(permit.permitted[i]); + } + + return keccak256( + abi.encode( + typeHash, + keccak256(abi.encodePacked(tokenPermissionHashes)), + msg.sender, + permit.nonce, + permit.deadline, + witness + ) + ); + } + + // ---- nonce management ---- + function _useUnorderedNonce(address from, uint256 nonce) private { + (uint256 wordPos, uint256 bitPos) = _bitmapPositions(nonce); + uint256 bit = 1 << bitPos; + uint256 flipped = nonceBitmap[from][wordPos] ^= bit; + + if (flipped & bit == 0) revert InvalidNonce(); + } + + function _bitmapPositions(uint256 nonce) private pure returns (uint256 wordPos, uint256 bitPos) { + wordPos = uint248(nonce >> 8); + bitPos = uint8(nonce); + } + + // ---- signature verification (mirrors SignatureVerification library) ---- + bytes32 private constant _UPPER_BIT_MASK = + 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + + function _verify(bytes calldata signature, bytes32 hash, address claimedSigner) private view { + bytes32 r; + bytes32 s; + uint8 v; + + if (claimedSigner.code.length == 0) { + if (signature.length == 65) { + (r, s) = abi.decode(signature, (bytes32, bytes32)); + v = uint8(signature[64]); + } else if (signature.length == 64) { + // EIP-2098 + bytes32 vs; + (r, vs) = abi.decode(signature, (bytes32, bytes32)); + s = vs & _UPPER_BIT_MASK; + v = uint8(uint256(vs >> 255)) + 27; + } else { + revert InvalidSignatureLength(); + } + address signer = ecrecover(hash, v, r, s); + if (signer == address(0)) revert InvalidSignature(); + if (signer != claimedSigner) revert InvalidSigner(); + } else { + (bool success, bytes memory result) = claimedSigner.staticcall( + abi.encodeWithSignature("isValidSignature(bytes32,bytes)", hash, signature) + ); + if (!success || result.length < 32 || abi.decode(result, (bytes4)) != bytes4(0x1626ba7e)) { + revert InvalidContractSignature(); + } + } + } +}