From 8f163b9ae5eaa0567128142f397154e81f005ed4 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 5 Nov 2024 10:53:45 -0800 Subject: [PATCH] Updated ERC1155 Discount Validator (#100) * Added multi-id erc1155 discount validator * lint * test: add unit tests for ERC1155DiscountValidatorV2 * Address reentrancy vuln with balance check via staticcall * lint --------- Co-authored-by: Abdulla Al-Kamil --- .../discounts/ERC1155DiscountValidatorV2.sol | 66 +++++++++++++++++++ .../ERC1155DiscountValidatorV2Base.t.sol | 24 +++++++ .../IsValidDiscountRegistration.t.sol | 65 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/L2/discounts/ERC1155DiscountValidatorV2.sol create mode 100644 test/discounts/ERC1155DiscountValidatorV2/ERC1155DiscountValidatorV2Base.t.sol create mode 100644 test/discounts/ERC1155DiscountValidatorV2/IsValidDiscountRegistration.t.sol diff --git a/src/L2/discounts/ERC1155DiscountValidatorV2.sol b/src/L2/discounts/ERC1155DiscountValidatorV2.sol new file mode 100644 index 0000000..09f9ffe --- /dev/null +++ b/src/L2/discounts/ERC1155DiscountValidatorV2.sol @@ -0,0 +1,66 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; + +/// @title Discount Validator for: ERC1155 NFTs +/// +/// @notice Implements an NFT ownership validator for a stored mapping of `approvedTokenIds` for an ERC1155 +/// `token` contract. +/// IMPORTANT: This discount validator should only be used for "soul-bound" tokens. +/// +/// @author Coinbase (https://github.com/base-org/usernames) +contract ERC1155DiscountValidatorV2 is IDiscountValidator { + using Address for address; + + /// @notice The ERC1155 token contract to validate against. + address immutable token; + + /// @notice The approved token Ids of the ERC1155 token contract. + mapping(uint256 tokenId => bool approved) approvedTokenIds; + + /// @notice ERC1155 Discount Validator constructor. + /// + /// @param token_ The address of the token contract. + /// @param tokenIds The approved token ids the token `claimer` must hold. + constructor(address token_, uint256[] memory tokenIds) { + token = token_; + for (uint256 i; i < tokenIds.length; i++) { + approvedTokenIds[tokenIds[i]] = true; + } + } + + /// @notice Required implementation for compatibility with IDiscountValidator. + /// + /// @dev Encoded array of token Ids to check, set by `abi.encode(uint256[] ids)` + /// + /// @param claimer the discount claimer's address. + /// @param validationData opaque bytes for performing the validation. + /// + /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. + function isValidDiscountRegistration(address claimer, bytes calldata validationData) + public + view + override + returns (bool) + { + uint256[] memory ids = abi.decode(validationData, (uint256[])); + for (uint256 i; i < ids.length; i++) { + uint256 id = ids[i]; + if (approvedTokenIds[id] && _getBalance(claimer, id) > 0) { + return true; + } + } + return false; + } + + /// @notice Helper for staticcalling getBalance to avoid reentrancy vector. + function _getBalance(address claimer, uint256 id) internal view returns (uint256) { + bytes memory data = abi.encodeWithSelector(IERC1155.balanceOf.selector, claimer, id); + (bytes memory returnData) = token.functionStaticCall(data); + return (abi.decode(returnData, (uint256))); + } +} diff --git a/test/discounts/ERC1155DiscountValidatorV2/ERC1155DiscountValidatorV2Base.t.sol b/test/discounts/ERC1155DiscountValidatorV2/ERC1155DiscountValidatorV2Base.t.sol new file mode 100644 index 0000000..c5d7239 --- /dev/null +++ b/test/discounts/ERC1155DiscountValidatorV2/ERC1155DiscountValidatorV2Base.t.sol @@ -0,0 +1,24 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {ERC1155DiscountValidatorV2} from "src/L2/discounts/ERC1155DiscountValidatorV2.sol"; +import {MockERC1155} from "test/mocks/MockERC1155.sol"; + +contract ERC1155DiscountValidatorV2Base is Test { + ERC1155DiscountValidatorV2 validator; + MockERC1155 token; + uint256 firstValidTokenId = 1; + uint256 secondValidTokenId = 2; + uint256 invalidTokenId = type(uint256).max; + address userA = makeAddr("userA"); + address userB = makeAddr("userB"); + + function setUp() public { + token = new MockERC1155(); + uint256[] memory validTokens = new uint256[](2); + validTokens[0] = firstValidTokenId; + validTokens[1] = secondValidTokenId; + validator = new ERC1155DiscountValidatorV2(address(token), validTokens); + } +} diff --git a/test/discounts/ERC1155DiscountValidatorV2/IsValidDiscountRegistration.t.sol b/test/discounts/ERC1155DiscountValidatorV2/IsValidDiscountRegistration.t.sol new file mode 100644 index 0000000..b5fe902 --- /dev/null +++ b/test/discounts/ERC1155DiscountValidatorV2/IsValidDiscountRegistration.t.sol @@ -0,0 +1,65 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {ERC1155DiscountValidatorV2Base} from "./ERC1155DiscountValidatorV2Base.t.sol"; + +contract IsValidDiscountRegistration is ERC1155DiscountValidatorV2Base { + function test_returnsTrue_whenTheUserHasOneToken() public { + uint256[] memory tokensToTest = new uint256[](1); + tokensToTest[0] = firstValidTokenId; + token.mint(userA, firstValidTokenId, 1); + assertTrue(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsTrue_whenTheUserHasOneTokenProvidingMultipleIds() public { + uint256[] memory tokensToTest = new uint256[](2); + tokensToTest[0] = firstValidTokenId; + tokensToTest[1] = invalidTokenId; + token.mint(userA, firstValidTokenId, 1); + assertTrue(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsTrue_whenTheUserHasMultipleTokens() public { + uint256[] memory tokensToTest = new uint256[](2); + tokensToTest[0] = firstValidTokenId; + tokensToTest[1] = secondValidTokenId; + token.mint(userA, firstValidTokenId, 1); + token.mint(userA, secondValidTokenId, 1); + assertTrue(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsFalse_whenTheUserHasNoToken() public view { + uint256[] memory tokensToTest = new uint256[](1); + tokensToTest[0] = firstValidTokenId; + assertFalse(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsFalse_whenAnotherUserHasAToken() public { + uint256[] memory tokensToTest = new uint256[](1); + tokensToTest[0] = firstValidTokenId; + token.mint(userB, firstValidTokenId, 1); + assertFalse(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsFalse_whenTheUserHasAnInvalidToken() public { + uint256[] memory tokensToTest = new uint256[](3); + tokensToTest[0] = firstValidTokenId; + tokensToTest[1] = secondValidTokenId; + tokensToTest[2] = invalidTokenId; + token.mint(userA, invalidTokenId, 1); + assertFalse(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsFalseWhenTheUserHasTokenButProvidesWrongList() public { + uint256[] memory tokensToTest = new uint256[](1); + tokensToTest[0] = secondValidTokenId; + token.mint(userA, firstValidTokenId, 1); + assertFalse(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } + + function test_returnsFalseWhenUserProvidesEmptyList() public { + uint256[] memory tokensToTest = new uint256[](0); + token.mint(userA, firstValidTokenId, 1); + assertFalse(validator.isValidDiscountRegistration(userA, abi.encode(tokensToTest))); + } +}