diff --git a/contracts/airdrop/AirdropERC1155.sol b/contracts/airdrop/AirdropERC1155.sol index a482b8f2c..721521a42 100644 --- a/contracts/airdrop/AirdropERC1155.sol +++ b/contracts/airdrop/AirdropERC1155.sol @@ -44,10 +44,12 @@ contract AirdropERC1155 is uint256 public payeeCount; uint256 public processedCount; - uint256[] private indicesOfFailed; + uint256[] public indicesOfFailed; mapping(uint256 => AirdropContent) private airdropContent; + CancelledPayments[] public cancelledPaymentIndices; + /*/////////////////////////////////////////////////////////////// Constructor + initializer logic //////////////////////////////////////////////////////////////*/ @@ -80,22 +82,45 @@ contract AirdropERC1155 is //////////////////////////////////////////////////////////////*/ ///@notice Lets contract-owner set up an airdrop of ERC721 NFTs to a list of addresses. - function addAirdropRecipients(AirdropContent[] calldata _contents) external onlyRole(DEFAULT_ADMIN_ROLE) { + function addRecipients(AirdropContent[] calldata _contents) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 len = _contents.length; require(len > 0, "No payees provided."); uint256 currentCount = payeeCount; payeeCount += len; - for (uint256 i = currentCount; i < len; i += 1) { - airdropContent[i] = _contents[i]; + for (uint256 i = 0; i < len; ) { + airdropContent[i + currentCount] = _contents[i]; + + unchecked { + i += 1; + } } - emit RecipientsAdded(_contents); + emit RecipientsAdded(currentCount, currentCount + len); + } + + ///@notice Lets contract-owner cancel any pending payments. + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 countOfProcessed = processedCount; + + // increase processedCount by the specified count -- all pending payments in between will be treated as cancelled. + uint256 newProcessedCount = countOfProcessed + numberOfPaymentsToCancel; + require(newProcessedCount <= payeeCount, "Exceeds total payees."); + processedCount = newProcessedCount; + + CancelledPayments memory range = CancelledPayments({ + startIndex: countOfProcessed, + endIndex: newProcessedCount - 1 + }); + + cancelledPaymentIndices.push(range); + + emit PaymentsCancelledByAdmin(countOfProcessed, newProcessedCount - 1); } /// @notice Lets contract-owner send ERC721 NFTs to a list of addresses. - function airdrop(uint256 paymentsToProcess) external nonReentrant { + function processPayments(uint256 paymentsToProcess) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { uint256 totalPayees = payeeCount; uint256 countOfProcessed = processedCount; @@ -103,18 +128,36 @@ contract AirdropERC1155 is processedCount += paymentsToProcess; - for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); i += 1) { + for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); ) { AirdropContent memory content = airdropContent[i]; - IERC1155(content.tokenAddress).safeTransferFrom( - content.tokenOwner, - content.recipient, - content.tokenId, - content.amount, - "" - ); - - emit AirdropPayment(content.recipient, content); + bool failed; + try + IERC1155(content.tokenAddress).safeTransferFrom{ gas: 80_000 }( + content.tokenOwner, + content.recipient, + content.tokenId, + content.amount, + "" + ) + {} catch { + // revert if failure is due to unapproved tokens + require( + IERC1155(content.tokenAddress).balanceOf(content.tokenOwner, content.tokenId) >= content.amount && + IERC1155(content.tokenAddress).isApprovedForAll(content.tokenOwner, address(this)), + "Not balance or approved" + ); + + // record and continue for all other failures, likely originating from recipient accounts + indicesOfFailed.push(i); + failed = true; + } + + emit AirdropPayment(content.recipient, i, failed); + + unchecked { + i += 1; + } } } @@ -123,21 +166,38 @@ contract AirdropERC1155 is * @dev The token-owner should approve target tokens to Airdrop contract, * which acts as operator for the tokens. * - * @param _tokenAddress Contract address of ERC1155 tokens to air-drop. - * @param _tokenOwner Address from which to transfer tokens. * @param _contents List containing recipient, tokenId and amounts to airdrop. */ - function airdrop( - address _tokenAddress, - address _tokenOwner, - AirdropContent[] calldata _contents - ) external nonReentrant onlyOwner { + function airdrop(AirdropContent[] calldata _contents) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { uint256 len = _contents.length; - IERC1155 token = IERC1155(_tokenAddress); - - for (uint256 i = 0; i < len; i++) { - token.safeTransferFrom(_tokenOwner, _contents[i].recipient, _contents[i].tokenId, _contents[i].amount, ""); + for (uint256 i = 0; i < len; ) { + bool failed; + try + IERC1155(_contents[i].tokenAddress).safeTransferFrom( + _contents[i].tokenOwner, + _contents[i].recipient, + _contents[i].tokenId, + _contents[i].amount, + "" + ) + {} catch { + // revert if failure is due to unapproved tokens + require( + IERC1155(_contents[i].tokenAddress).balanceOf(_contents[i].tokenOwner, _contents[i].tokenId) >= + _contents[i].amount && + IERC1155(_contents[i].tokenAddress).isApprovedForAll(_contents[i].tokenOwner, address(this)), + "Not balance or approved" + ); + + failed = true; + } + + emit StatelessAirdrop(_contents[i].recipient, _contents[i], failed); + + unchecked { + i += 1; + } } } @@ -146,38 +206,45 @@ contract AirdropERC1155 is //////////////////////////////////////////////////////////////*/ /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents) { - uint256 count = payeeCount; - contents = new AirdropContent[](count); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; + contents = new AirdropContent[](endId - startId + 1); + + for (uint256 i = startId; i <= endId; i += 1) { + contents[i - startId] = airdropContent[i]; } } /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents) { - uint256 endCount = payeeCount; - uint256 startCount = processedCount; - contents = new AirdropContent[](endCount - startCount); + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); + + uint256 processed = processedCount; + if (processed == payeeCount) { + return contents; + } + + if (startId < processed) { + startId = processed; + } + contents = new AirdropContent[](endId - startId + 1); uint256 idx; - for (uint256 i = startCount; i < endCount; i += 1) { + for (uint256 i = startId; i <= endId; i += 1) { contents[idx] = airdropContent[i]; idx += 1; } } - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents) { - uint256 count = processedCount; - contents = new AirdropContent[](count); - - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; - } - } - /// @notice Returns all pending airdrop failed. function getAllAirdropPaymentsFailed() external view returns (AirdropContent[] memory contents) { uint256 count = indicesOfFailed.length; @@ -188,12 +255,17 @@ contract AirdropERC1155 is } } + /// @notice Returns all blocks of cancelled payments as an array of index range. + function getCancelledPaymentIndices() external view returns (CancelledPayments[] memory) { + return cancelledPaymentIndices; + } + /*/////////////////////////////////////////////////////////////// Miscellaneous //////////////////////////////////////////////////////////////*/ /// @dev Returns whether owner can be set in the given execution context. function _canSetOwner() internal view virtual override returns (bool) { - return msg.sender == owner(); + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); } } diff --git a/contracts/airdrop/AirdropERC20.sol b/contracts/airdrop/AirdropERC20.sol index 845c8c165..fc2e3dec9 100644 --- a/contracts/airdrop/AirdropERC20.sol +++ b/contracts/airdrop/AirdropERC20.sol @@ -21,6 +21,7 @@ import "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol"; import "../interfaces/airdrop/IAirdropERC20.sol"; import { CurrencyTransferLib } from "../lib/CurrencyTransferLib.sol"; +import "../eip/interface/IERC20.sol"; // ========== Features ========== import "../extension/Ownable.sol"; @@ -44,10 +45,12 @@ contract AirdropERC20 is uint256 public payeeCount; uint256 public processedCount; - uint256[] private indicesOfFailed; + uint256[] public indicesOfFailed; mapping(uint256 => AirdropContent) private airdropContent; + CancelledPayments[] public cancelledPaymentIndices; + /*/////////////////////////////////////////////////////////////// Constructor + initializer logic //////////////////////////////////////////////////////////////*/ @@ -80,7 +83,7 @@ contract AirdropERC20 is //////////////////////////////////////////////////////////////*/ ///@notice Lets contract-owner set up an airdrop of ERC20 or native tokens to a list of addresses. - function addAirdropRecipients(AirdropContent[] calldata _contents) external payable onlyRole(DEFAULT_ADMIN_ROLE) { + function addRecipients(AirdropContent[] calldata _contents) external payable onlyRole(DEFAULT_ADMIN_ROLE) { uint256 len = _contents.length; require(len > 0, "No payees provided."); @@ -89,39 +92,143 @@ contract AirdropERC20 is uint256 nativeTokenAmount; - for (uint256 i = currentCount; i < len; i += 1) { - airdropContent[i] = _contents[i]; + for (uint256 i = 0; i < len; ) { + airdropContent[i + currentCount] = _contents[i]; if (_contents[i].tokenAddress == CurrencyTransferLib.NATIVE_TOKEN) { nativeTokenAmount += _contents[i].amount; } + + unchecked { + i += 1; + } } require(nativeTokenAmount == msg.value, "Incorrect native token amount"); - emit RecipientsAdded(_contents); + emit RecipientsAdded(currentCount, currentCount + len); + } + + ///@notice Lets contract-owner cancel any pending payments. + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 countOfProcessed = processedCount; + uint256 nativeTokenAmount; + + // increase processedCount by the specified count -- all pending payments in between will be treated as cancelled. + uint256 newProcessedCount = countOfProcessed + numberOfPaymentsToCancel; + require(newProcessedCount <= payeeCount, "Exceeds total payees."); + processedCount = newProcessedCount; + + CancelledPayments memory range = CancelledPayments({ + startIndex: countOfProcessed, + endIndex: newProcessedCount - 1 + }); + + cancelledPaymentIndices.push(range); + + for (uint256 i = countOfProcessed; i < newProcessedCount; ) { + AirdropContent memory content = airdropContent[i]; + + if (content.tokenAddress == CurrencyTransferLib.NATIVE_TOKEN) { + nativeTokenAmount += content.amount; + } + + unchecked { + i += 1; + } + } + + if (nativeTokenAmount > 0) { + // refund amount to contract admin address + CurrencyTransferLib.safeTransferNativeToken(msg.sender, nativeTokenAmount); + } + + emit PaymentsCancelledByAdmin(countOfProcessed, newProcessedCount - 1); } /// @notice Lets contract-owner send ERC20 or native tokens to a list of addresses. - function airdrop(uint256 paymentsToProcess) external nonReentrant { + function processPayments(uint256 paymentsToProcess) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { uint256 totalPayees = payeeCount; uint256 countOfProcessed = processedCount; + uint256 nativeTokenAmount; require(countOfProcessed + paymentsToProcess <= totalPayees, "invalid no. of payments"); processedCount += paymentsToProcess; - for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); i += 1) { + for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); ) { AirdropContent memory content = airdropContent[i]; - CurrencyTransferLib.transferCurrency( + bool success = _transferCurrencyWithReturnVal( content.tokenAddress, content.tokenOwner, content.recipient, content.amount ); - emit AirdropPayment(content.recipient, content); + if (!success) { + indicesOfFailed.push(i); + + if (content.tokenAddress == CurrencyTransferLib.NATIVE_TOKEN) { + nativeTokenAmount += content.amount; + } + + success = false; + } + + emit AirdropPayment(content.recipient, i, !success); + + unchecked { + i += 1; + } + } + + if (nativeTokenAmount > 0) { + // refund failed payments' amount to contract admin address + CurrencyTransferLib.safeTransferNativeToken(msg.sender, nativeTokenAmount); + } + } + + /** + * @notice Lets contract-owner send ERC20 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _contents List containing recipient, tokenId and amounts to airdrop. + */ + function airdrop(AirdropContent[] calldata _contents) external payable nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 len = _contents.length; + uint256 nativeTokenAmount; + uint256 refundAmount; + + for (uint256 i = 0; i < len; ) { + bool success = _transferCurrencyWithReturnVal( + _contents[i].tokenAddress, + _contents[i].tokenOwner, + _contents[i].recipient, + _contents[i].amount + ); + + if (_contents[i].tokenAddress == CurrencyTransferLib.NATIVE_TOKEN) { + nativeTokenAmount += _contents[i].amount; + + if (!success) { + refundAmount += _contents[i].amount; + } + } + + emit StatelessAirdrop(_contents[i].recipient, _contents[i], !success); + + unchecked { + i += 1; + } + } + + require(nativeTokenAmount == msg.value, "Incorrect native token amount"); + + if (refundAmount > 0) { + // refund failed payments' amount to contract admin address + CurrencyTransferLib.safeTransferNativeToken(msg.sender, refundAmount); } } @@ -130,38 +237,45 @@ contract AirdropERC20 is //////////////////////////////////////////////////////////////*/ /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents) { - uint256 count = payeeCount; - contents = new AirdropContent[](count); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; + contents = new AirdropContent[](endId - startId + 1); + + for (uint256 i = startId; i <= endId; i += 1) { + contents[i - startId] = airdropContent[i]; } } /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents) { - uint256 endCount = payeeCount; - uint256 startCount = processedCount; - contents = new AirdropContent[](endCount - startCount); + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); + + uint256 processed = processedCount; + if (processed == payeeCount) { + return contents; + } + + if (startId < processed) { + startId = processed; + } + contents = new AirdropContent[](endId - startId + 1); uint256 idx; - for (uint256 i = startCount; i < endCount; i += 1) { + for (uint256 i = startId; i <= endId; i += 1) { contents[idx] = airdropContent[i]; idx += 1; } } - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents) { - uint256 count = processedCount; - contents = new AirdropContent[](count); - - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; - } - } - /// @notice Returns all pending airdrop failed. function getAllAirdropPaymentsFailed() external view returns (AirdropContent[] memory contents) { uint256 count = indicesOfFailed.length; @@ -172,12 +286,51 @@ contract AirdropERC20 is } } + /// @notice Returns all blocks of cancelled payments as an array of index range. + function getCancelledPaymentIndices() external view returns (CancelledPayments[] memory) { + return cancelledPaymentIndices; + } + /*/////////////////////////////////////////////////////////////// Miscellaneous //////////////////////////////////////////////////////////////*/ + /// @dev Transfers ERC20 tokens and returns a boolean i.e. the status of the transfer. + function _transferCurrencyWithReturnVal( + address _currency, + address _from, + address _to, + uint256 _amount + ) internal returns (bool success) { + if (_amount == 0) { + success = true; + return success; + } + + if (_currency == CurrencyTransferLib.NATIVE_TOKEN) { + // solhint-disable avoid-low-level-calls + // slither-disable-next-line low-level-calls + (success, ) = _to.call{ value: _amount, gas: 80_000 }(""); + } else { + (bool success_, bytes memory data_) = _currency.call( + abi.encodeWithSelector(IERC20.transferFrom.selector, _from, _to, _amount) + ); + + success = success_; + if (!success || (data_.length > 0 && !abi.decode(data_, (bool)))) { + success = false; + + require( + IERC20(_currency).balanceOf(_from) >= _amount && + IERC20(_currency).allowance(_from, address(this)) >= _amount, + "Not balance or allowance" + ); + } + } + } + /// @dev Returns whether owner can be set in the given execution context. function _canSetOwner() internal view virtual override returns (bool) { - return msg.sender == owner(); + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); } } diff --git a/contracts/airdrop/AirdropERC721.sol b/contracts/airdrop/AirdropERC721.sol index 9f8246dae..10162c98d 100644 --- a/contracts/airdrop/AirdropERC721.sol +++ b/contracts/airdrop/AirdropERC721.sol @@ -44,10 +44,12 @@ contract AirdropERC721 is uint256 public payeeCount; uint256 public processedCount; - uint256[] private indicesOfFailed; + uint256[] public indicesOfFailed; mapping(uint256 => AirdropContent) private airdropContent; + CancelledPayments[] public cancelledPaymentIndices; + /*/////////////////////////////////////////////////////////////// Constructor + initializer logic //////////////////////////////////////////////////////////////*/ @@ -80,22 +82,45 @@ contract AirdropERC721 is //////////////////////////////////////////////////////////////*/ ///@notice Lets contract-owner set up an airdrop of ERC721 NFTs to a list of addresses. - function addAirdropRecipients(AirdropContent[] calldata _contents) external onlyRole(DEFAULT_ADMIN_ROLE) { + function addRecipients(AirdropContent[] calldata _contents) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 len = _contents.length; require(len > 0, "No payees provided."); uint256 currentCount = payeeCount; payeeCount += len; - for (uint256 i = currentCount; i < len; i += 1) { - airdropContent[i] = _contents[i]; + for (uint256 i = 0; i < len; ) { + airdropContent[i + currentCount] = _contents[i]; + + unchecked { + i += 1; + } } - emit RecipientsAdded(_contents); + emit RecipientsAdded(currentCount, currentCount + len); + } + + ///@notice Lets contract-owner cancel any pending payments. + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 countOfProcessed = processedCount; + + // increase processedCount by the specified count -- all pending payments in between will be treated as cancelled. + uint256 newProcessedCount = countOfProcessed + numberOfPaymentsToCancel; + require(newProcessedCount <= payeeCount, "Exceeds total payees."); + processedCount = newProcessedCount; + + CancelledPayments memory range = CancelledPayments({ + startIndex: countOfProcessed, + endIndex: newProcessedCount - 1 + }); + + cancelledPaymentIndices.push(range); + + emit PaymentsCancelledByAdmin(countOfProcessed, newProcessedCount - 1); } /// @notice Lets contract-owner send ERC721 NFTs to a list of addresses. - function airdrop(uint256 paymentsToProcess) external nonReentrant { + function processPayments(uint256 paymentsToProcess) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { uint256 totalPayees = payeeCount; uint256 countOfProcessed = processedCount; @@ -103,12 +128,73 @@ contract AirdropERC721 is processedCount += paymentsToProcess; - for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); i += 1) { + for (uint256 i = countOfProcessed; i < (countOfProcessed + paymentsToProcess); ) { AirdropContent memory content = airdropContent[i]; - IERC721(content.tokenAddress).safeTransferFrom(content.tokenOwner, content.recipient, content.tokenId); + bool failed; + try + IERC721(content.tokenAddress).safeTransferFrom{ gas: 80_000 }( + content.tokenOwner, + content.recipient, + content.tokenId + ) + {} catch { + // revert if failure is due to unapproved tokens + require( + (IERC721(content.tokenAddress).ownerOf(content.tokenId) == content.tokenOwner && + address(this) == IERC721(content.tokenAddress).getApproved(content.tokenId)) || + IERC721(content.tokenAddress).isApprovedForAll(content.tokenOwner, address(this)), + "Not owner or approved" + ); + + // record all other failures, likely originating from recipient accounts + indicesOfFailed.push(i); + failed = true; + } + + emit AirdropPayment(content.recipient, i, failed); + + unchecked { + i += 1; + } + } + } + + /** + * @notice Lets contract-owner send ERC721 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _contents List containing recipient, tokenId to airdrop. + */ + function airdrop(AirdropContent[] calldata _contents) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 len = _contents.length; - emit AirdropPayment(content.recipient, content); + for (uint256 i = 0; i < len; ) { + bool failed; + try + IERC721(_contents[i].tokenAddress).safeTransferFrom( + _contents[i].tokenOwner, + _contents[i].recipient, + _contents[i].tokenId + ) + {} catch { + // revert if failure is due to unapproved tokens + require( + (IERC721(_contents[i].tokenAddress).ownerOf(_contents[i].tokenId) == _contents[i].tokenOwner && + address(this) == IERC721(_contents[i].tokenAddress).getApproved(_contents[i].tokenId)) || + IERC721(_contents[i].tokenAddress).isApprovedForAll(_contents[i].tokenOwner, address(this)), + "Not owner or approved" + ); + + failed = true; + } + + emit StatelessAirdrop(_contents[i].recipient, _contents[i], failed); + + unchecked { + i += 1; + } } } @@ -117,35 +203,41 @@ contract AirdropERC721 is //////////////////////////////////////////////////////////////*/ /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents) { - uint256 count = payeeCount; - contents = new AirdropContent[](count); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; + contents = new AirdropContent[](endId - startId + 1); + + for (uint256 i = startId; i <= endId; i += 1) { + contents[i - startId] = airdropContent[i]; } } /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents) { - uint256 endCount = payeeCount; - uint256 startCount = processedCount; - contents = new AirdropContent[](endCount - startCount); - - uint256 idx; - for (uint256 i = startCount; i < endCount; i += 1) { - contents[idx] = airdropContent[i]; - idx += 1; + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents) + { + require(startId <= endId && endId < payeeCount, "invalid range"); + + uint256 processed = processedCount; + if (processed == payeeCount) { + return contents; } - } - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents) { - uint256 count = processedCount; - contents = new AirdropContent[](count); + if (startId < processed) { + startId = processed; + } + contents = new AirdropContent[](endId - startId + 1); - for (uint256 i = 0; i < count; i += 1) { - contents[i] = airdropContent[i]; + uint256 index; + for (uint256 i = startId; i <= endId; i += 1) { + contents[index++] = airdropContent[i]; } } @@ -159,12 +251,17 @@ contract AirdropERC721 is } } + /// @notice Returns all blocks of cancelled payments as an array of index range. + function getCancelledPaymentIndices() external view returns (CancelledPayments[] memory) { + return cancelledPaymentIndices; + } + /*/////////////////////////////////////////////////////////////// Miscellaneous //////////////////////////////////////////////////////////////*/ /// @dev Returns whether owner can be set in the given execution context. function _canSetOwner() internal view virtual override returns (bool) { - return msg.sender == owner(); + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); } } diff --git a/contracts/interfaces/airdrop/IAirdropERC1155.sol b/contracts/interfaces/airdrop/IAirdropERC1155.sol index fb497f1ce..ad2e724c0 100644 --- a/contracts/interfaces/airdrop/IAirdropERC1155.sol +++ b/contracts/interfaces/airdrop/IAirdropERC1155.sol @@ -11,9 +11,13 @@ pragma solidity ^0.8.11; interface IAirdropERC1155 { /// @notice Emitted when airdrop recipients are uploaded to the contract. - event RecipientsAdded(AirdropContent[] _contents); + event RecipientsAdded(uint256 startIndex, uint256 endIndex); + /// @notice Emitted when pending payments are cancelled, and processed count is reset. + event PaymentsCancelledByAdmin(uint256 startIndex, uint256 endIndex); /// @notice Emitted when an airdrop payment is made to a recipient. - event AirdropPayment(address indexed recipient, AirdropContent content); + event AirdropPayment(address indexed recipient, uint256 index, bool failed); + /// @notice Emitted when an airdrop is made using the stateless airdrop function. + event StatelessAirdrop(address indexed recipient, AirdropContent content, bool failed); /** * @notice Details of amount and recipient for airdropped token. @@ -32,14 +36,29 @@ interface IAirdropERC1155 { uint256 amount; } + /** + * @notice Range of indices of a set of cancelled payments. Each call to cancel payments + * stores this range in an array. + * + * @param startIndex First index of the set of cancelled payment indices. + * @param endIndex Last index of the set of cancelled payment indices. + */ + struct CancelledPayments { + uint256 startIndex; + uint256 endIndex; + } + /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents); - - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents); + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop failed. function getAllAirdropPaymentsFailed() external view returns (AirdropContent[] memory contents); @@ -51,7 +70,12 @@ interface IAirdropERC1155 { * * @param _contents List containing recipients, tokenIds to airdrop. */ - function addAirdropRecipients(AirdropContent[] calldata _contents) external; + function addRecipients(AirdropContent[] calldata _contents) external; + + /** + * @notice Lets contract-owner cancel any pending payments. + */ + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external; /** * @notice Lets contract-owner set up an airdrop of ERC1155 tokens to a list of addresses. @@ -60,5 +84,14 @@ interface IAirdropERC1155 { * * @param paymentsToProcess The number of airdrop payments to process. */ - function airdrop(uint256 paymentsToProcess) external; + function processPayments(uint256 paymentsToProcess) external; + + /** + * @notice Lets contract-owner send ERC1155 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _contents List containing recipient, tokenId to airdrop. + */ + function airdrop(AirdropContent[] calldata _contents) external; } diff --git a/contracts/interfaces/airdrop/IAirdropERC20.sol b/contracts/interfaces/airdrop/IAirdropERC20.sol index 8306ecfed..bf81e71cc 100644 --- a/contracts/interfaces/airdrop/IAirdropERC20.sol +++ b/contracts/interfaces/airdrop/IAirdropERC20.sol @@ -11,13 +11,19 @@ pragma solidity ^0.8.11; interface IAirdropERC20 { /// @notice Emitted when airdrop recipients are uploaded to the contract. - event RecipientsAdded(AirdropContent[] _contents); + event RecipientsAdded(uint256 startIndex, uint256 endIndex); + /// @notice Emitted when pending payments are cancelled, and processed count is reset. + event PaymentsCancelledByAdmin(uint256 startIndex, uint256 endIndex); /// @notice Emitted when an airdrop payment is made to a recipient. - event AirdropPayment(address indexed recipient, AirdropContent content); + event AirdropPayment(address indexed recipient, uint256 index, bool failed); + /// @notice Emitted when an airdrop is made using the stateless airdrop function. + event StatelessAirdrop(address indexed recipient, AirdropContent content, bool failed); /** * @notice Details of amount and recipient for airdropped token. * + * @param tokenAddress The contract address of the tokens to transfer. + * @param tokenOwner The owner of the the tokens to transfer. * @param recipient The recipient of the tokens. * @param amount The quantity of tokens to airdrop. */ @@ -28,14 +34,29 @@ interface IAirdropERC20 { uint256 amount; } + /** + * @notice Range of indices of a set of cancelled payments. Each call to cancel payments + * stores this range in an array. + * + * @param startIndex First index of the set of cancelled payment indices. + * @param endIndex Last index of the set of cancelled payment indices. + */ + struct CancelledPayments { + uint256 startIndex; + uint256 endIndex; + } + /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents); - - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents); + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop failed. function getAllAirdropPaymentsFailed() external view returns (AirdropContent[] memory contents); @@ -45,7 +66,12 @@ interface IAirdropERC20 { * * @param _contents List containing recipients, amounts to airdrop. */ - function addAirdropRecipients(AirdropContent[] calldata _contents) external payable; + function addRecipients(AirdropContent[] calldata _contents) external payable; + + /** + * @notice Lets contract-owner cancel any pending payments. + */ + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external; /** * @notice Lets contract-owner send ERC20 or native tokens to a list of addresses. @@ -54,5 +80,14 @@ interface IAirdropERC20 { * * @param paymentsToProcess The number of airdrop payments to process. */ - function airdrop(uint256 paymentsToProcess) external; + function processPayments(uint256 paymentsToProcess) external; + + /** + * @notice Lets contract-owner send ERC20 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _contents List containing recipient, tokenId to airdrop. + */ + function airdrop(AirdropContent[] calldata _contents) external payable; } diff --git a/contracts/interfaces/airdrop/IAirdropERC721.sol b/contracts/interfaces/airdrop/IAirdropERC721.sol index 6795ad92b..d0d0e680f 100644 --- a/contracts/interfaces/airdrop/IAirdropERC721.sol +++ b/contracts/interfaces/airdrop/IAirdropERC721.sol @@ -11,9 +11,13 @@ pragma solidity ^0.8.11; interface IAirdropERC721 { /// @notice Emitted when airdrop recipients are uploaded to the contract. - event RecipientsAdded(AirdropContent[] _contents); + event RecipientsAdded(uint256 startIndex, uint256 endIndex); + /// @notice Emitted when pending payments are cancelled, and processed count is reset. + event PaymentsCancelledByAdmin(uint256 startIndex, uint256 endIndex); /// @notice Emitted when an airdrop payment is made to a recipient. - event AirdropPayment(address indexed recipient, AirdropContent content); + event AirdropPayment(address indexed recipient, uint256 index, bool failed); + /// @notice Emitted when an airdrop is made using the stateless airdrop function. + event StatelessAirdrop(address indexed recipient, AirdropContent content, bool failed); /** * @notice Details of amount and recipient for airdropped token. @@ -30,14 +34,29 @@ interface IAirdropERC721 { uint256 tokenId; } + /** + * @notice Range of indices of a set of cancelled payments. Each call to cancel payments + * stores this range in an array. + * + * @param startIndex First index of the set of cancelled payment indices. + * @param endIndex Last index of the set of cancelled payment indices. + */ + struct CancelledPayments { + uint256 startIndex; + uint256 endIndex; + } + /// @notice Returns all airdrop payments set up -- pending, processed or failed. - function getAllAirdropPayments() external view returns (AirdropContent[] memory contents); + function getAllAirdropPayments(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop payments. - function getAllAirdropPaymentsPending() external view returns (AirdropContent[] memory contents); - - /// @notice Returns all pending airdrop processed. - function getAllAirdropPaymentsProcessed() external view returns (AirdropContent[] memory contents); + function getAllAirdropPaymentsPending(uint256 startId, uint256 endId) + external + view + returns (AirdropContent[] memory contents); /// @notice Returns all pending airdrop failed. function getAllAirdropPaymentsFailed() external view returns (AirdropContent[] memory contents); @@ -49,7 +68,12 @@ interface IAirdropERC721 { * * @param _contents List containing recipients, tokenIds to airdrop. */ - function addAirdropRecipients(AirdropContent[] calldata _contents) external; + function addRecipients(AirdropContent[] calldata _contents) external; + + /** + * @notice Lets contract-owner cancel any pending payments. + */ + function cancelPendingPayments(uint256 numberOfPaymentsToCancel) external; /** * @notice Lets contract-owner set up an airdrop of ERC721 tokens to a list of addresses. @@ -58,5 +82,14 @@ interface IAirdropERC721 { * * @param paymentsToProcess The number of airdrop payments to process. */ - function airdrop(uint256 paymentsToProcess) external; + function processPayments(uint256 paymentsToProcess) external; + + /** + * @notice Lets contract-owner send ERC721 tokens to a list of addresses. + * @dev The token-owner should approve target tokens to Airdrop contract, + * which acts as operator for the tokens. + * + * @param _contents List containing recipient, tokenId to airdrop. + */ + function airdrop(AirdropContent[] calldata _contents) external; } diff --git a/src/test/airdrop/AirdropERC1155.t.sol b/src/test/airdrop/AirdropERC1155.t.sol index cd77e2490..f3e36fde8 100644 --- a/src/test/airdrop/AirdropERC1155.t.sol +++ b/src/test/airdrop/AirdropERC1155.t.sol @@ -12,7 +12,11 @@ contract AirdropERC1155Test is BaseTest { Wallet internal tokenOwner; - IAirdropERC1155.AirdropContent[] internal _contents; + IAirdropERC1155.AirdropContent[] internal _contentsOne; + IAirdropERC1155.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; function setUp() public override { super.setUp(); @@ -29,8 +33,23 @@ contract AirdropERC1155Test is BaseTest { tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); - for (uint256 i = 0; i < 1000; i++) { - _contents.push( + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC1155.AirdropContent({ + tokenAddress: address(erc1155), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: i % 5, + amount: 5 + }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( IAirdropERC1155.AirdropContent({ tokenAddress: address(erc1155), tokenOwner: address(tokenOwner), @@ -43,18 +62,192 @@ contract AirdropERC1155Test is BaseTest { } /*/////////////////////////////////////////////////////////////// - Unit tests: `createPack` + Unit tests: `processPayments` //////////////////////////////////////////////////////////////*/ - function test_state_airdrop() public { + function test_state_processPayments_full() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); + } + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); + assertEq(erc1155.balanceOf(address(tokenOwner), 1), 1000); + assertEq(erc1155.balanceOf(address(tokenOwner), 2), 2000); + assertEq(erc1155.balanceOf(address(tokenOwner), 3), 3000); + assertEq(erc1155.balanceOf(address(tokenOwner), 4), 4000); + } + + function test_state_processPayments_partial() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); + } + } + + function test_revert_processPayments_notOwner() public { + vm.prank(address(25)); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + TWStrings.toHexString(uint160(address(25)), 20), + " is missing role ", + TWStrings.toHexString(uint256(0x00), 32) + ) + ); + drop.addRecipients(_contentsOne); + } + + function test_revert_processPayments_notApproved() public { + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), false); + vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - drop.airdrop(_contents.length); + drop.addRecipients(_contentsOne); + vm.expectRevert("Not balance or approved"); + drop.processPayments(_contentsOne.length); vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `cancelPayments` + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelPayments() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); - for (uint256 i = 0; i < 1000; i++) { - assertEq(erc1155.balanceOf(_contents[i].recipient, i % 5), 5); + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC1155.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); + } + } + + function test_state_cancelPayments_addMore() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC1155.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + // add more recipients + vm.prank(deployer); + drop.addRecipients(_contentsTwo); + + // check state + assertEq(drop.getAllAirdropPayments(0, countOne + countTwo - 1).length, countOne + countTwo); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne + countTwo - 1).length, countTwo); // pending payments equal to count of new recipients added + assertEq(drop.payeeCount(), countOne + countTwo); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); } + + // cancel more + vm.prank(deployer); + drop.cancelPendingPayments(100); + + cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 2); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + assertEq(cancelledPayments[1].startIndex, countOne); + assertEq(cancelledPayments[1].endIndex, countOne + 100 - 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdrop(_contentsOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc1155.balanceOf(_contentsOne[i].recipient, i % 5), 5); + } + assertEq(erc1155.balanceOf(address(tokenOwner), 0), 0); assertEq(erc1155.balanceOf(address(tokenOwner), 1), 1000); assertEq(erc1155.balanceOf(address(tokenOwner), 2), 2000); @@ -72,16 +265,71 @@ contract AirdropERC1155Test is BaseTest { TWStrings.toHexString(uint256(0x00), 32) ) ); - drop.addAirdropRecipients(_contents); + drop.airdrop(_contentsOne); } function test_revert_airdrop_notApproved() public { tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), false); vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - vm.expectRevert("ERC1155: caller is not token owner nor approved"); - drop.airdrop(_contents.length); + vm.expectRevert("Not balance or approved"); + drop.airdrop(_contentsOne); vm.stopPrank(); } } + +contract AirdropERC1155GasTest is BaseTest { + AirdropERC1155 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC1155(getContract("AirdropERC1155")); + + tokenOwner = getWallet(); + + erc1155.mint(address(tokenOwner), 0, 1000); + + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(drop), true); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_safeTransferFrom_toEOA() public { + vm.prank(address(tokenOwner)); + erc1155.safeTransferFrom(address(tokenOwner), address(0x123), 0, 10, ""); + } + + function test_safeTransferFrom_toContract() public { + vm.prank(address(tokenOwner)); + erc1155.safeTransferFrom(address(tokenOwner), address(this), 0, 10, ""); + } + + function test_safeTransferFrom_toEOA_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + erc1155.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0, 10, ""); + console.log(gasleft()); + } + + function test_safeTransferFrom_toContract_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + erc1155.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0, 10, ""); + console.log(gasleft()); + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external returns (bytes4) { + return this.onERC1155Received.selector; + } +} diff --git a/src/test/airdrop/AirdropERC1155Benchmark.t.sol b/src/test/airdrop/AirdropERC1155Benchmark.t.sol index 2f69f7e11..ace77aff0 100644 --- a/src/test/airdrop/AirdropERC1155Benchmark.t.sol +++ b/src/test/airdrop/AirdropERC1155Benchmark.t.sol @@ -43,18 +43,18 @@ contract AirdropERC1155BenchmarkTest is BaseTest { vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); + drop.addRecipients(_contents); } function test_benchmark_airdrop_one() public { - drop.airdrop(1); + drop.processPayments(1); } function test_benchmark_airdrop_two() public { - drop.airdrop(2); + drop.processPayments(2); } function test_benchmark_airdrop_five() public { - drop.airdrop(5); + drop.processPayments(5); } } diff --git a/src/test/airdrop/AirdropERC20.t.sol b/src/test/airdrop/AirdropERC20.t.sol index db5a96c4b..9aaa35164 100644 --- a/src/test/airdrop/AirdropERC20.t.sol +++ b/src/test/airdrop/AirdropERC20.t.sol @@ -1,18 +1,24 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import "contracts/airdrop/AirdropERC20.sol"; +import { AirdropERC20, IAirdropERC20 } from "contracts/airdrop/AirdropERC20.sol"; // Test imports import { Wallet } from "../utils/Wallet.sol"; import "../utils/BaseTest.sol"; +import "../mocks/MockERC20NonCompliant.sol"; + contract AirdropERC20Test is BaseTest { AirdropERC20 internal drop; Wallet internal tokenOwner; - IAirdropERC20.AirdropContent[] internal _contents; + IAirdropERC20.AirdropContent[] internal _contentsOne; + IAirdropERC20.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; function setUp() public override { super.setUp(); @@ -24,8 +30,22 @@ contract AirdropERC20Test is BaseTest { erc20.mint(address(tokenOwner), 10_000 ether); tokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); - for (uint256 i = 0; i < 1000; i++) { - _contents.push( + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC20.AirdropContent({ + tokenAddress: address(erc20), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + amount: 10 ether + }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( IAirdropERC20.AirdropContent({ tokenAddress: address(erc20), tokenOwner: address(tokenOwner), @@ -37,56 +57,325 @@ contract AirdropERC20Test is BaseTest { } /*/////////////////////////////////////////////////////////////// - Unit tests: `createPack` + Unit tests: `processPayments` //////////////////////////////////////////////////////////////*/ - function test_state_airdrop() public { - vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - drop.airdrop(_contents.length); - vm.stopPrank(); + function test_state_processPayments_full() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); - for (uint256 i = 0; i < 1000; i++) { - assertEq(erc20.balanceOf(_contents[i].recipient), _contents[i].amount); + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); } assertEq(erc20.balanceOf(address(tokenOwner)), 0); } - function test_state_airdrop_nativeToken() public { + function test_state_processPayments_partial() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20.balanceOf(address(tokenOwner)), 3000 ether); + } + + function test_state_processPayments_nativeToken_full() public { vm.deal(deployer, 10_000 ether); uint256 balBefore = deployer.balance; - for (uint256 i = 0; i < 1000; i++) { - _contents[i].tokenAddress = NATIVE_TOKEN; + for (uint256 i = 0; i < countOne; i++) { + _contentsOne[i].tokenAddress = NATIVE_TOKEN; } - vm.startPrank(deployer); - drop.addAirdropRecipients{ value: 10_000 ether }(_contents); - drop.airdrop(_contents.length); - vm.stopPrank(); + vm.prank(deployer); + drop.addRecipients{ value: 10_000 ether }(_contentsOne); - for (uint256 i = 0; i < 1000; i++) { - assertEq(_contents[i].recipient.balance, _contents[i].amount); + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(_contentsOne[i].recipient.balance, _contentsOne[i].amount); + } + assertEq(deployer.balance, balBefore - 10_000 ether); + } + + function test_state_processPayments_nativeToken_partial() public { + vm.deal(deployer, 10_000 ether); + + uint256 balBefore = deployer.balance; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne[i].tokenAddress = NATIVE_TOKEN; + } + + vm.prank(deployer); + drop.addRecipients{ value: 10_000 ether }(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(_contentsOne[i].recipient.balance, _contentsOne[i].amount); } assertEq(deployer.balance, balBefore - 10_000 ether); } - function test_revert_airdrop_incorrectNativeTokenAmt() public { + function test_revert_processPayments_incorrectNativeTokenAmt() public { vm.deal(deployer, 11_000 ether); uint256 incorrectAmt = 10_000 ether + 1; for (uint256 i = 0; i < 1000; i++) { - _contents[i].tokenAddress = NATIVE_TOKEN; + _contentsOne[i].tokenAddress = NATIVE_TOKEN; } vm.prank(deployer); vm.expectRevert("Incorrect native token amount"); - drop.addAirdropRecipients{ value: incorrectAmt }(_contents); + drop.addRecipients{ value: incorrectAmt }(_contentsOne); + } + + function test_revert_processPayments_notAdmin() public { + vm.prank(address(25)); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + TWStrings.toHexString(uint160(address(25)), 20), + " is missing role ", + TWStrings.toHexString(uint256(0x00), 32) + ) + ); + drop.addRecipients(_contentsOne); + } + + function test_revert_processPayments_notApproved() public { + tokenOwner.setAllowanceERC20(address(erc20), address(drop), 0); + + vm.startPrank(deployer); + drop.addRecipients(_contentsOne); + vm.expectRevert("Not balance or allowance"); + drop.processPayments(_contentsOne.length); + vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `cancelPayments` + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelPayments() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC20.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20.balanceOf(address(tokenOwner)), 3000 ether); + } + + function test_state_cancelPayments_addMore() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + // add more recipients + vm.prank(deployer); + drop.addRecipients(_contentsTwo); + + // check state + assertEq(drop.getAllAirdropPayments(0, countOne + countTwo - 1).length, countOne + countTwo); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne + countTwo - 1).length, countTwo); // pending payments equal to count of new recipients added + assertEq(drop.payeeCount(), countOne + countTwo); + assertEq(drop.processedCount(), countOne); + + IAirdropERC20.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20.balanceOf(address(tokenOwner)), 3000 ether); + + // cancel more + vm.prank(deployer); + drop.cancelPendingPayments(100); + + cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 2); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + assertEq(cancelledPayments[1].startIndex, countOne); + assertEq(cancelledPayments[1].endIndex, countOne + 100 - 1); + } + + function test_state_cancelPayments_nativeToken() public { + vm.deal(deployer, 10_000 ether); + + uint256 balBefore = deployer.balance; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne[i].tokenAddress = NATIVE_TOKEN; + } + + vm.prank(deployer); + drop.addRecipients{ value: 10_000 ether }(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC20.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + for (uint256 i = 0; i < countOne - 300; i++) { + assertEq(_contentsOne[i].recipient.balance, _contentsOne[i].amount); + } + assertEq(deployer.balance, balBefore - 7_000 ether); // native token amount gets refunded } - function test_revert_airdrop_notAdmin() public { + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdrop(_contentsOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc20.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20.balanceOf(address(tokenOwner)), 0); + } + + function test_revert_airdrop_notOwner() public { vm.prank(address(25)); vm.expectRevert( abi.encodePacked( @@ -96,16 +385,130 @@ contract AirdropERC20Test is BaseTest { TWStrings.toHexString(uint256(0x00), 32) ) ); - drop.addAirdropRecipients(_contents); + drop.airdrop(_contentsOne); } function test_revert_airdrop_notApproved() public { tokenOwner.setAllowanceERC20(address(erc20), address(drop), 0); vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - vm.expectRevert("ERC20: insufficient allowance"); - drop.airdrop(_contents.length); + vm.expectRevert("Not balance or allowance"); + drop.airdrop(_contentsOne); vm.stopPrank(); } } + +contract AirdropERC20AuditTest is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC20.AirdropContent[] internal _contentsOne; + IAirdropERC20.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + MockERC20NonCompliant public erc20_nonCompliant; + + function setUp() public override { + super.setUp(); + + erc20_nonCompliant = new MockERC20NonCompliant(); + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20_nonCompliant.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20_nonCompliant), address(drop), type(uint256).max); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC20.AirdropContent({ + tokenAddress: address(erc20_nonCompliant), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + amount: 10 ether + }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( + IAirdropERC20.AirdropContent({ + tokenAddress: address(erc20_nonCompliant), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + amount: 10 ether + }) + ); + } + } + + function test_process_payments_with_non_compliant_token() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + vm.prank(deployer); + drop.processPayments(countOne); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < countOne; i++) { + assertEq(erc20_nonCompliant.balanceOf(_contentsOne[i].recipient), _contentsOne[i].amount); + } + assertEq(erc20_nonCompliant.balanceOf(address(tokenOwner)), 0); + } +} + +contract AirdropERC20GasTest is BaseTest { + AirdropERC20 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC20(getContract("AirdropERC20")); + + tokenOwner = getWallet(); + + erc20.mint(address(tokenOwner), 10_000 ether); + tokenOwner.setAllowanceERC20(address(erc20), address(drop), type(uint256).max); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_transferNativeToken_toEOA() public { + vm.prank(address(tokenOwner)); + address(0x123).call{ value: 1 ether }(""); + } + + function test_transferNativeToken_toContract() public { + vm.prank(address(tokenOwner)); + address(this).call{ value: 1 ether }(""); + } + + function test_transferNativeToken_toEOA_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + address(0x123).call{ value: 1 ether, gas: 100_000 }(""); + console.log(gasleft()); + } + + function test_transferNativeToken_toContract_gasOverride() public { + vm.prank(address(tokenOwner)); + console.log(gasleft()); + address(this).call{ value: 1 ether, gas: 100_000 }(""); + console.log(gasleft()); + } +} diff --git a/src/test/airdrop/AirdropERC20Benchmark.t.sol b/src/test/airdrop/AirdropERC20Benchmark.t.sol index 3be9a5105..975594a89 100644 --- a/src/test/airdrop/AirdropERC20Benchmark.t.sol +++ b/src/test/airdrop/AirdropERC20Benchmark.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.0; -import "contracts/airdrop/AirdropERC20.sol"; +import { AirdropERC20, IAirdropERC20 } from "contracts/airdrop/AirdropERC20.sol"; // Test imports import { Wallet } from "../utils/Wallet.sol"; @@ -38,18 +38,18 @@ contract AirdropERC20BenchmarkTest is BaseTest { vm.deal(deployer, 10_000 ether); vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); + drop.addRecipients(_contents); } function test_benchmark_airdrop_one_ERC20() public { - drop.airdrop(1); + drop.processPayments(1); } function test_benchmark_airdrop_two_ERC20() public { - drop.airdrop(2); + drop.processPayments(2); } function test_benchmark_airdrop_five_ERC20() public { - drop.airdrop(5); + drop.processPayments(5); } } diff --git a/src/test/airdrop/AirdropERC721.t.sol b/src/test/airdrop/AirdropERC721.t.sol index 0dce29f33..6e54c1178 100644 --- a/src/test/airdrop/AirdropERC721.t.sol +++ b/src/test/airdrop/AirdropERC721.t.sol @@ -12,7 +12,11 @@ contract AirdropERC721Test is BaseTest { Wallet internal tokenOwner; - IAirdropERC721.AirdropContent[] internal _contents; + IAirdropERC721.AirdropContent[] internal _contentsOne; + IAirdropERC721.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; function setUp() public override { super.setUp(); @@ -21,11 +25,25 @@ contract AirdropERC721Test is BaseTest { tokenOwner = getWallet(); - erc721.mint(address(tokenOwner), 1000); + erc721.mint(address(tokenOwner), 1500); tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); - for (uint256 i = 0; i < 1000; i++) { - _contents.push( + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC721.AirdropContent({ + tokenAddress: address(erc721), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: i + }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( IAirdropERC721.AirdropContent({ tokenAddress: address(erc721), tokenOwner: address(tokenOwner), @@ -37,17 +55,202 @@ contract AirdropERC721Test is BaseTest { } /*/////////////////////////////////////////////////////////////// - Unit tests: `createPack` + Unit tests: `processPayments` //////////////////////////////////////////////////////////////*/ - function test_state_airdrop() public { + function test_state_processPayments_full() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(_contentsOne.length); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < 1000; i++) { + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); + } + } + + function test_state_processPayments_partial() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + for (uint256 i = 0; i < 700; i++) { + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); + } + + for (uint256 i = 700; i < 1000; i++) { + assertEq(erc721.ownerOf(i), address(tokenOwner)); + } + } + + function test_revert_processPayments_notOwner() public { + vm.prank(address(25)); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + TWStrings.toHexString(uint160(address(25)), 20), + " is missing role ", + TWStrings.toHexString(uint256(0x00), 32) + ) + ); + drop.addRecipients(_contentsOne); + + vm.prank(deployer); + drop.addRecipients(_contentsOne); + vm.expectRevert( + abi.encodePacked( + "Permissions: account ", + TWStrings.toHexString(uint160(address(25)), 20), + " is missing role ", + TWStrings.toHexString(uint256(0x00), 32) + ) + ); + vm.prank(address(25)); + drop.processPayments(countOne); + } + + function test_revert_processPayments_notApproved() public { + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), false); + vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - drop.airdrop(_contents.length); + drop.addRecipients(_contentsOne); + vm.expectRevert("Not owner or approved"); + drop.processPayments(_contentsOne.length); vm.stopPrank(); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: `cancelPayments` + //////////////////////////////////////////////////////////////*/ + + function test_state_cancelPayments() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // check state before airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, countOne); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), 0); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // check state after airdrop + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 300); + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC721.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + for (uint256 i = 0; i < 700; i++) { + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); + } + } + + function test_state_cancelPayments_addMore() public { + vm.prank(deployer); + drop.addRecipients(_contentsOne); + + // perform airdrop + vm.prank(deployer); + drop.processPayments(countOne - 300); + + // cancel payments + vm.prank(deployer); + drop.cancelPendingPayments(300); + + // check state after reset + assertEq(drop.getAllAirdropPayments(0, countOne - 1).length, countOne); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne - 1).length, 0); // 0 pending payments after reset + assertEq(drop.payeeCount(), countOne); + assertEq(drop.processedCount(), countOne); // processed count set equal to total payee count + + IAirdropERC721.CancelledPayments[] memory cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 1); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + + // add more recipients + vm.prank(deployer); + drop.addRecipients(_contentsTwo); + + // check state + assertEq(drop.getAllAirdropPayments(0, countOne + countTwo - 1).length, countOne + countTwo); + assertEq(drop.getAllAirdropPaymentsPending(0, countOne + countTwo - 1).length, countTwo); // pending payments equal to count of new recipients added + assertEq(drop.payeeCount(), countOne + countTwo); + assertEq(drop.processedCount(), countOne); + + for (uint256 i = 0; i < 700; i++) { + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); + } + + // cancel more + vm.prank(deployer); + drop.cancelPendingPayments(100); + + cancelledPayments = drop.getCancelledPaymentIndices(); + assertEq(cancelledPayments.length, 2); + assertEq(cancelledPayments[0].startIndex, countOne - 300); + assertEq(cancelledPayments[0].endIndex, countOne - 1); + assertEq(cancelledPayments[1].startIndex, countOne); + assertEq(cancelledPayments[1].endIndex, countOne + 100 - 1); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: stateless airdrop + //////////////////////////////////////////////////////////////*/ + + function test_state_airdrop() public { + vm.prank(deployer); + drop.airdrop(_contentsOne); for (uint256 i = 0; i < 1000; i++) { - assertEq(erc721.ownerOf(i), _contents[i].recipient); + assertEq(erc721.ownerOf(i), _contentsOne[i].recipient); } } @@ -61,16 +264,146 @@ contract AirdropERC721Test is BaseTest { TWStrings.toHexString(uint256(0x00), 32) ) ); - drop.addAirdropRecipients(_contents); + drop.airdrop(_contentsOne); } function test_revert_airdrop_notApproved() public { tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), false); vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); - vm.expectRevert("ERC721: caller is not token owner nor approved"); - drop.airdrop(_contents.length); + vm.expectRevert("Not owner or approved"); + drop.airdrop(_contentsOne); vm.stopPrank(); } } + +contract AirdropERC721AuditTest is BaseTest { + AirdropERC721 internal drop; + + Wallet internal tokenOwner; + + IAirdropERC721.AirdropContent[] internal _contentsOne; + IAirdropERC721.AirdropContent[] internal _contentsTwo; + + uint256 countOne; + uint256 countTwo; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC721(getContract("AirdropERC721")); + + tokenOwner = getWallet(); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + + countOne = 1000; + countTwo = 200; + + for (uint256 i = 0; i < countOne; i++) { + _contentsOne.push( + IAirdropERC721.AirdropContent({ + tokenAddress: address(erc721), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: i + }) + ); + } + + for (uint256 i = countOne; i < countOne + countTwo; i++) { + _contentsTwo.push( + IAirdropERC721.AirdropContent({ + tokenAddress: address(erc721), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: i + }) + ); + } + } + + function test_NewRecipientsAreEmptyPreventingAirdrops() public { + // create a memory array of one as the recipient array to use for this test + IAirdropERC721.AirdropContent[] memory _c = new IAirdropERC721.AirdropContent[](1); + _c[0] = _contentsOne[0]; + // add recipients the first time + vm.prank(deployer); + drop.addRecipients(_c); + // everything should be normal at this point + assertEq(drop.payeeCount(), 1); + assertEq(drop.getAllAirdropPayments(0, 0).length, 1); + assertEq(drop.payeeCount(), 1); + // grab another recipient + _c[0] = _contentsOne[1]; + // add this new one, this is where the issues occur + vm.prank(deployer); + drop.addRecipients(_c); + // payee count is correct, everything seems fine + assertEq(drop.payeeCount(), 2); + // get all the airdrop payments to double check + IAirdropERC721.AirdropContent[] memory _res = drop.getAllAirdropPayments(0, 1); + // length seems fine + assertEq(_res.length, 2); + // first entry is correct + assertEq(_res[0].tokenAddress, _contentsOne[0].tokenAddress); + assertEq(_res[1].tokenAddress, _contentsOne[1].tokenAddress); + assertEq(_res[1].tokenAddress == _contentsOne[1].tokenAddress, true); + + vm.prank(deployer); + drop.processPayments(2); + } +} + +contract AirdropERC721GasTest is BaseTest { + AirdropERC721 internal drop; + + Wallet internal tokenOwner; + + function setUp() public override { + super.setUp(); + + drop = AirdropERC721(getContract("AirdropERC721")); + + tokenOwner = getWallet(); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(drop), true); + + vm.startPrank(address(tokenOwner)); + } + + /*/////////////////////////////////////////////////////////////// + Unit tests: gas benchmarks, etc. + //////////////////////////////////////////////////////////////*/ + + function test_safeTransferFrom_toEOA() public { + erc721.safeTransferFrom(address(tokenOwner), address(0x123), 0); + } + + function test_safeTransferFrom_toContract() public { + erc721.safeTransferFrom(address(tokenOwner), address(this), 0); + } + + function test_safeTransferFrom_toEOA_gasOverride() public { + console.log(gasleft()); + erc721.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(0x123), 0); + console.log(gasleft()); + } + + function test_safeTransferFrom_toContract_gasOverride() public { + console.log(gasleft()); + erc721.safeTransferFrom{ gas: 100_000 }(address(tokenOwner), address(this), 0); + console.log(gasleft()); + } + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external view returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/src/test/airdrop/AirdropERC721Benchmark.t.sol b/src/test/airdrop/AirdropERC721Benchmark.t.sol index 314425505..21f525c81 100644 --- a/src/test/airdrop/AirdropERC721Benchmark.t.sol +++ b/src/test/airdrop/AirdropERC721Benchmark.t.sol @@ -37,18 +37,18 @@ contract AirdropERC721BenchmarkTest is BaseTest { vm.startPrank(deployer); - drop.addAirdropRecipients(_contents); + drop.addRecipients(_contents); } function test_benchmark_airdrop_one_ERC721() public { - drop.airdrop(1); + drop.processPayments(1); } function test_benchmark_airdrop_two_ERC721() public { - drop.airdrop(2); + drop.processPayments(2); } function test_benchmark_airdrop_five_ERC721() public { - drop.airdrop(5); + drop.processPayments(5); } } diff --git a/src/test/airdrop/AirdropGriefingTest.t.sol b/src/test/airdrop/AirdropGriefingTest.t.sol new file mode 100644 index 000000000..e61aa64d9 --- /dev/null +++ b/src/test/airdrop/AirdropGriefingTest.t.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test imports +import { Wallet } from "../utils/Wallet.sol"; +import "../utils/BaseTest.sol"; + +import "contracts/interfaces/airdrop/IAirdropERC20.sol"; + +contract GriefingContract { + event YouAreBeingGriefed(); + + receive() external payable { + grief(); + } + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external returns (bytes4) { + grief(); + return GriefingContract.onERC721Received.selector; + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external returns (bytes4) { + grief(); + return GriefingContract.onERC1155Received.selector; + } + + function grief() internal { + while (true) { + emit YouAreBeingGriefed(); + } + } +} + +contract AirdropGriefingTest is BaseTest { + AirdropERC721 internal dropERC721; + AirdropERC1155 internal dropERC1155; + AirdropERC20 internal dropERC20; + + Wallet internal tokenOwner; + + IAirdropERC721.AirdropContent[] internal contentsERC721; + IAirdropERC1155.AirdropContent[] internal contentsERC1155; + IAirdropERC20.AirdropContent[] internal contentsERC20; + + GriefingContract private griefingContract; + + function setUp() public override { + super.setUp(); + + tokenOwner = getWallet(); + + // setup erc721 + dropERC721 = AirdropERC721(getContract("AirdropERC721")); + + erc721.mint(address(tokenOwner), 1500); + tokenOwner.setApprovalForAllERC721(address(erc721), address(dropERC721), true); + + griefingContract = new GriefingContract(); + + // add griefing contract to airdrop + contentsERC721.push( + IAirdropERC721.AirdropContent({ + tokenAddress: address(erc721), + tokenOwner: address(tokenOwner), + recipient: address(griefingContract), + tokenId: 50 + }) + ); + + for (uint256 i = 0; i < 5; i++) { + contentsERC721.push( + IAirdropERC721.AirdropContent({ + tokenAddress: address(erc721), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: i + }) + ); + } + + vm.prank(deployer); + dropERC721.addRecipients(contentsERC721); + + // setup erc1155 + dropERC1155 = AirdropERC1155(getContract("AirdropERC1155")); + + erc1155.mint(address(tokenOwner), 1, 1500); + tokenOwner.setApprovalForAllERC1155(address(erc1155), address(dropERC1155), true); + + //add griefing contract to airdrop + contentsERC1155.push( + IAirdropERC1155.AirdropContent({ + tokenAddress: address(erc1155), + tokenOwner: address(tokenOwner), + recipient: address(griefingContract), + tokenId: 1, + amount: 1 + }) + ); + + for (uint256 i = 0; i < 5; i++) { + contentsERC1155.push( + IAirdropERC1155.AirdropContent({ + tokenAddress: address(erc1155), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + tokenId: 1, + amount: 1 + }) + ); + } + + vm.prank(deployer); + dropERC1155.addRecipients(contentsERC1155); + + // setup erc20 + dropERC20 = AirdropERC20(getContract("AirdropERC20")); + + //add griefing contract to airdrop + contentsERC20.push( + IAirdropERC20.AirdropContent({ + tokenAddress: address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), + tokenOwner: address(tokenOwner), + recipient: address(griefingContract), + amount: 1 + }) + ); + + for (uint256 i = 0; i < 5; i++) { + contentsERC20.push( + IAirdropERC20.AirdropContent({ + tokenAddress: address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), + tokenOwner: address(tokenOwner), + recipient: getActor(uint160(i)), + amount: 1 + }) + ); + } + + vm.deal(deployer, 10_000 ether); + + vm.prank(deployer); + dropERC20.addRecipients{ value: 6 }(contentsERC20); + } + + function test_GriefingERC721_Exceeds_30M_Gas() public { + vm.prank(deployer); + dropERC721.processPayments(6); + } + + function test_GriefingERC1155_Exceeds_30M_Gas() public { + vm.prank(deployer); + dropERC1155.processPayments(6); + } + + function test_GriefingERC20_Exceeds_30M_Gas() public { + vm.prank(deployer); + dropERC20.processPayments(6); + } +} diff --git a/src/test/mocks/MockERC20NonCompliant.sol b/src/test/mocks/MockERC20NonCompliant.sol new file mode 100644 index 000000000..8bc8889b5 --- /dev/null +++ b/src/test/mocks/MockERC20NonCompliant.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +contract MockERC20NonCompliant { + mapping(address => uint256) private _balances; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalSupply; + + constructor() {} + + function decimals() public view virtual returns (uint8) { + return 18; + } + + function totalSupply() public view virtual returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view virtual returns (uint256) { + return _balances[account]; + } + + function allowance(address owner, address spender) public view virtual returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) public virtual returns (bool) { + address owner = msg.sender; + _approve(owner, spender, amount); + return true; + } + + // non-compliant ERC20 as transfer doesn't return a bool + function transfer(address to, uint256 amount) public virtual { + address owner = msg.sender; + _transfer(owner, to, amount); + } + + // non-compliant ERC20 as transferFrom doesn't return a bool + function transferFrom( + address from, + address to, + uint256 amount + ) public { + address spender = msg.sender; + _spendAllowance(from, spender, amount); + _transfer(from, to, amount); + } + + function _transfer( + address from, + address to, + uint256 amount + ) internal virtual { + require(from != address(0), "ERC20: transfer from the zero address"); + require(to != address(0), "ERC20: transfer to the zero address"); + + uint256 fromBalance = _balances[from]; + require(fromBalance >= amount, "ERC20: transfer amount exceeds balance"); + unchecked { + _balances[from] = fromBalance - amount; + } + _balances[to] += amount; + } + + function _mint(address account, uint256 amount) internal virtual { + require(account != address(0), "ERC20: mint to the zero address"); + + _totalSupply += amount; + unchecked { + // Overflow not possible: balance + amount is at most totalSupply + amount, which is checked above. + _balances[account] += amount; + } + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function _approve( + address owner, + address spender, + uint256 amount + ) internal virtual { + require(owner != address(0), "ERC20: approve from the zero address"); + require(spender != address(0), "ERC20: approve to the zero address"); + + _allowances[owner][spender] = amount; + } + + function _spendAllowance( + address owner, + address spender, + uint256 amount + ) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + unchecked { + _approve(owner, spender, currentAllowance - amount); + } + } + } +} diff --git a/src/test/utils/BaseTest.sol b/src/test/utils/BaseTest.sol index 43adebbeb..15afa215a 100644 --- a/src/test/utils/BaseTest.sol +++ b/src/test/utils/BaseTest.sol @@ -31,7 +31,7 @@ import { ContractPublisher } from "contracts/ContractPublisher.sol"; import { IContractPublisher } from "contracts/interfaces/IContractPublisher.sol"; import "contracts/airdrop/AirdropERC721.sol"; import "contracts/airdrop/AirdropERC721Claimable.sol"; -import "contracts/airdrop/AirdropERC20.sol"; +import { AirdropERC20 } from "contracts/airdrop/AirdropERC20.sol"; import "contracts/airdrop/AirdropERC20Claimable.sol"; import "contracts/airdrop/AirdropERC1155.sol"; import "contracts/airdrop/AirdropERC1155Claimable.sol";