diff --git a/.gitmodules b/.gitmodules index 5b64a62..2a44d42 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,19 @@ [submodule "lib/solarray"] path = lib/solarray url = https://github.com/evmcheb/solarray +[submodule "redeemables"] + path = redeemables + url = https://github.com/ProjectOpenSea/redeemables.git +[submodule "lib/ERC721A"] + path = lib/ERC721A + url = https://github.com/chiru-labs/ERC721A +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/openzeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/seadrop"] + path = lib/seadrop + url = https://github.com/ProjectOpenSea/seadrop + branch = v2 +[submodule "lib/ds-test"] + path = lib/ds-test + url = https://github.com/dapphub/ds-test diff --git a/foundry.toml b/foundry.toml index 9afd017..caf43fa 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,21 +1,30 @@ [profile.default] +solc = "0.8.21" src = "src" out = "out" libs = ["lib"] remappings = [ 'forge-std/=lib/forge-std/src', + 'ds-test/=lib/ds-test/src', + 'ERC721A/=lib/ERC721A/contracts/', 'solady/=lib/solady/src/', 'solady-test/=lib/solady/test/', 'openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/', + 'operator-filter-registry/=lib/seadrop/lib/operator-filter-registry/src/', 'shipyard-core/=src/', 'seaport-types/=lib/seaport-types/src/', 'solarray/=lib/solarray/src/', 'dynamic-traits/=lib/dynamic-traits/src/', 'openzeppelin-contracts/contracts/=lib/openzeppelin-contracts/contracts/', + '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/', + 'openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/', + 'utility-contracts/=lib/utility-contracts/src/', + 'seadrop/=lib/seadrop/src/', ] +auto_detect_remappings = false # bytecode_hash = 'none' ignored_error_codes = ['license', 'code-size', 'init-code-size', 2519] -optimizer_runs = 99_999_999 +optimizer_runs = 2_000_000 [profile.lite.fuzz] runs = 1 diff --git a/lib/ERC721A b/lib/ERC721A new file mode 160000 index 0000000..9be81f0 --- /dev/null +++ b/lib/ERC721A @@ -0,0 +1 @@ +Subproject commit 9be81f029a0883e0c7c6d5a410ee49c48c69205d diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..3d4c0d5 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 3d4c0d5741b131c231e558d7a6213392ab3672a5 diff --git a/lib/redeemables/.github/pull_request_template.md b/lib/redeemables/.github/pull_request_template.md new file mode 100644 index 0000000..3c4cb66 --- /dev/null +++ b/lib/redeemables/.github/pull_request_template.md @@ -0,0 +1,25 @@ + + +## Motivation + + + +## Solution + + diff --git a/lib/redeemables/.github/workflows/test.yml b/lib/redeemables/.github/workflows/test.yml new file mode 100644 index 0000000..2f3d5fb --- /dev/null +++ b/lib/redeemables/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Test CI + +on: + push: + branches: [main] + tags: ["*"] + pull_request: + types: [opened, reopened, synchronize] + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Run Forge build + run: | + forge --version + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test + + - name: Run Forge format + run: | + forge fmt + [ -z "`git status --porcelain`" ] && echo "No diff for format" || { echo "Diff exists for format"; exit 1; } + id: lint diff --git a/lib/redeemables/.gitignore b/lib/redeemables/.gitignore new file mode 100644 index 0000000..bf61470 --- /dev/null +++ b/lib/redeemables/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores broadcast logs +/broadcast + +# Docs +docs/ + +# Dotenv file +.env + +.vscode diff --git a/lib/redeemables/.gitmodules b/lib/redeemables/.gitmodules new file mode 100644 index 0000000..5e5a87f --- /dev/null +++ b/lib/redeemables/.gitmodules @@ -0,0 +1,21 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/solady"] + path = lib/solady + url = git@github.com:Vectorized/solady.git +[submodule "lib/solarray"] + path = lib/solarray + url = https://github.com/evmcheb/solarray +[submodule "lib/seaport-types"] + path = lib/seaport-types + url = https://github.com/ProjectOpenSea/seaport-types +[submodule "lib/seaport-sol"] + path = lib/seaport-sol + url = https://github.com/ProjectOpenSea/seaport-sol +[submodule "lib/seaport-core"] + path = lib/seaport-core + url = https://github.com/ProjectOpenSea/seaport-core +[submodule "lib/murky"] + path = lib/murky + url = https://github.com/dmfxyz/murky.git diff --git a/lib/redeemables/LICENSE b/lib/redeemables/LICENSE new file mode 100644 index 0000000..2d35427 --- /dev/null +++ b/lib/redeemables/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Ozone Networks, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/redeemables/README.md b/lib/redeemables/README.md new file mode 100644 index 0000000..10923c0 --- /dev/null +++ b/lib/redeemables/README.md @@ -0,0 +1,78 @@ +# Redeemables + +EVM smart contracts for redeemables and dynamic traits. + +## Foundry + +To install Foundry (assuming a Linux or macOS system): + +```bash +curl -L https://foundry.paradigm.xyz | bash +``` + +This will download foundryup. To start Foundry, run: + +```bash +foundryup +``` + +To install dependencies: + +``` +forge install +``` + +To run tests: + +``` +forge test +``` + +To run format: + +``` +forge fmt +``` + + + +The following modifiers are also available: + +- Level 2 (-vv): Logs emitted during tests are also displayed. +- Level 3 (-vvv): Stack traces for failing tests are also displayed. +- Level 4 (-vvvv): Stack traces for all tests are displayed, and setup traces for failing tests are displayed. +- Level 5 (-vvvvv): Stack traces and setup traces are always displayed. + +```bash +forge test -vv +``` + +For more information on foundry testing and use, see [Foundry Book installation instructions](https://book.getfoundry.sh/getting-started/installation). + +## Contributing + +Contributions are welcome by anyone interested in writing more tests, improving readability, optimizing for gas efficiency, or extending the protocol with new features. + +When making a pull request, ensure that: + +- All tests pass. +- Code coverage remains at 100% (coverage tests must currently be written in hardhat). +- All new code adheres to the style guide: + - All lint checks pass. + - Code is thoroughly commented with natspec where relevant. +- If making a change to the contracts: + - Gas snapshots are provided and demonstrate an improvement (or an acceptable deficit given other improvements). + - Reference contracts are modified correspondingly if relevant. + - New tests (ideally via foundry) are included for all new features or code paths. +- If making a modification to third-party dependencies, `yarn audit` passes. +- A descriptive summary of the PR has been provided. + +## License + +[MIT](LICENSE) Copyright 2023 Ozone Networks, Inc. diff --git a/lib/redeemables/foundry.toml b/lib/redeemables/foundry.toml new file mode 100644 index 0000000..2c7fb1a --- /dev/null +++ b/lib/redeemables/foundry.toml @@ -0,0 +1,14 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc_version = '0.8.19' +remappings = [ + 'ds-test/=lib/forge-std/lib/ds-test/src/', + 'solady/=lib/solady/', + 'seaport-core/=lib/seaport-core/', + 'seaport-sol/=lib/seaport-sol/', + 'seaport-types/=lib/seaport-types/' +] + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file diff --git a/lib/redeemables/script/DeployAndConfigureExampleCampaign.s.sol b/lib/redeemables/script/DeployAndConfigureExampleCampaign.s.sol new file mode 100644 index 0000000..891de17 --- /dev/null +++ b/lib/redeemables/script/DeployAndConfigureExampleCampaign.s.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; + +import {ItemType} from "seaport-types/src/lib/ConsiderationEnums.sol"; +import {OfferItem, ConsiderationItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {RedeemableContractOfferer} from "../src/RedeemableContractOfferer.sol"; +import {CampaignParams} from "../src/lib/RedeemableStructs.sol"; +import {ERC721RedemptionMintable} from "../src/lib/ERC721RedemptionMintable.sol"; +import {TestERC721} from "../test/utils/mocks/TestERC721.sol"; + +contract DeployAndConfigureExampleCampaign is Script { + // Addresses: Seaport + address seaport = 0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC; + address conduit = 0x1E0049783F008A0085193E00003D00cd54003c71; + bytes32 conduitKey = 0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000; + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function run() external { + vm.startBroadcast(); + + RedeemableContractOfferer offerer = new RedeemableContractOfferer( + conduit, + conduitKey, + seaport + ); + TestERC721 redeemableToken = new TestERC721(); + ERC721RedemptionMintable redemptionToken = new ERC721RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + + // Configure the campaign. + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1_000_000), + maxCampaignRedemptions: 1_000, + manager: msg.sender + }); + offerer.createCampaign(params, "ipfs://QmdChMVnMSq4U6oVKhud7wUSEZGnwuMuTY5rUQx57Ayp6H"); + + // Mint tokens 1 and 5 to redeem for tokens 1 and 5. + redeemableToken.mint(msg.sender, 1); + redeemableToken.mint(msg.sender, 5); + + // Let's redeem them! + uint256 campaignId = 1; + bytes32 redemptionHash = bytes32(0); + bytes memory data = abi.encode(campaignId, redemptionHash); + redeemableToken.safeTransferFrom(msg.sender, address(offerer), 1, data); + redeemableToken.safeTransferFrom(msg.sender, address(offerer), 5, data); + } +} diff --git a/lib/redeemables/src/ERC1155Redeemable.sol b/lib/redeemables/src/ERC1155Redeemable.sol new file mode 100644 index 0000000..9f862fe --- /dev/null +++ b/lib/redeemables/src/ERC1155Redeemable.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; +import {IERCDynamicTraits} from "./interfaces/IDynamicTraits.sol"; +import {SignedRedeem} from "./lib/SignedRedeem.sol"; + +// contract ERC1155Redeemable is ERC1155, IERCDynamicTraits, SignedRedeem {} diff --git a/lib/redeemables/src/ERC721Redeemable.sol b/lib/redeemables/src/ERC721Redeemable.sol new file mode 100644 index 0000000..7c9204f --- /dev/null +++ b/lib/redeemables/src/ERC721Redeemable.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {DynamicTraits} from "./lib/DynamicTraits.sol"; +import {SignedRedeem} from "./lib/SignedRedeem.sol"; +import {RedeemableErrorsAndEvents} from "./lib/RedeemableErrorsAndEvents.sol"; + +// contract ERC721Redeemable is +// ERC721, +// RedeemableErrorsAndEvents, +// DynamicTraits, +// SignedRedeem +// {} diff --git a/lib/redeemables/src/RedeemableContractOfferer.sol b/lib/redeemables/src/RedeemableContractOfferer.sol new file mode 100644 index 0000000..33b9bff --- /dev/null +++ b/lib/redeemables/src/RedeemableContractOfferer.sol @@ -0,0 +1,705 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ContractOffererInterface} from "seaport-types/src/interfaces/ContractOffererInterface.sol"; +import {SeaportInterface} from "seaport-types/src/interfaces/SeaportInterface.sol"; +import {ItemType, OrderType} from "seaport-types/src/lib/ConsiderationEnums.sol"; +import { + AdvancedOrder, + CriteriaResolver, + OrderParameters, + OfferItem, + ConsiderationItem, + ReceivedItem, + Schema, + SpentItem +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {ERC20} from "solady/src/tokens/ERC20.sol"; +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; +import {IERC721Receiver} from "seaport-types/src/interfaces/IERC721Receiver.sol"; +import {IERC1155Receiver} from "./interfaces/IERC1155Receiver.sol"; +import {IERC721RedemptionMintable} from "./interfaces/IERC721RedemptionMintable.sol"; +import {IERC1155RedemptionMintable} from "./interfaces/IERC1155RedemptionMintable.sol"; +import {SignedRedeemContractOfferer} from "./lib/SignedRedeemContractOfferer.sol"; +import {RedeemableErrorsAndEvents} from "./lib/RedeemableErrorsAndEvents.sol"; +import {CampaignParams} from "./lib/RedeemableStructs.sol"; + +/** + * @title RedeemablesContractOfferer + * @author ryanio, stephankmin + * @notice A Seaport contract offerer that allows users to burn to redeem off chain redeemables. + */ +contract RedeemableContractOfferer is + ContractOffererInterface, + RedeemableErrorsAndEvents, + SignedRedeemContractOfferer +{ + /// @dev The Seaport address allowed to interact with this contract offerer. + address internal immutable _SEAPORT; + + /// @dev The conduit address to allow as an operator for this contract for newly minted tokens. + address internal immutable _CONDUIT; + + bytes32 internal immutable _CONDUIT_KEY; + + /// @dev Counter for next campaign id. + uint256 private _nextCampaignId = 1; + + /// @dev The campaign parameters by campaign id. + mapping(uint256 campaignId => CampaignParams params) private _campaignParams; + + /// @dev The campaign URIs by campaign id. + mapping(uint256 campaignId => string campaignURI) private _campaignURIs; + + /// @dev The total current redemptions by campaign id. + mapping(uint256 campaignId => uint256 count) private _totalRedemptions; + + constructor(address conduit, bytes32 conduitKey, address seaport) { + _CONDUIT = conduit; + _CONDUIT_KEY = conduitKey; + _SEAPORT = seaport; + } + + function createCampaign(CampaignParams calldata params, string calldata uri) + external + returns (uint256 campaignId) + { + // Revert if there are no consideration items, since the redemption should require at least something. + if (params.consideration.length == 0) revert NoConsiderationItems(); + + // Revert if startTime is past endTime. + if (params.startTime > params.endTime) revert InvalidTime(); + + // Revert if any of the consideration item recipients is the zero address. The 0xdead address should be used instead. + for (uint256 i = 0; i < params.consideration.length;) { + if (params.consideration[i].recipient == address(0)) { + revert ConsiderationItemRecipientCannotBeZeroAddress(); + } + unchecked { + ++i; + } + } + + // Check for and set token approvals for the campaign. + _setTokenApprovals(params); + + // Set the campaign params for the next campaignId. + _campaignParams[_nextCampaignId] = params; + + // Set the campaign URI for the next campaignId. + _campaignURIs[_nextCampaignId] = uri; + + // Set the correct current campaignId to return before incrementing + // the next campaignId. + campaignId = _nextCampaignId; + + // Increment the next campaignId. + _nextCampaignId++; + + emit CampaignUpdated(campaignId, params, _campaignURIs[campaignId]); + } + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) external { + if (campaignId == 0 || campaignId >= _nextCampaignId) { + revert InvalidCampaignId(); + } + + // Revert if there are no consideration items, since the redemption should require at least something. + if (params.consideration.length == 0) revert NoConsiderationItems(); + + // Revert if startTime is past endTime. + if (params.startTime > params.endTime) revert InvalidTime(); + + // Revert if msg.sender is not the manager. + address existingManager = _campaignParams[campaignId].manager; + if (params.manager != msg.sender && (existingManager != address(0) && existingManager != params.manager)) { + revert NotManager(); + } + + // Revert if any of the consideration item recipients is the zero address. The 0xdead address should be used instead. + for (uint256 i = 0; i < params.consideration.length;) { + if (params.consideration[i].recipient == address(0)) { + revert ConsiderationItemRecipientCannotBeZeroAddress(); + } + unchecked { + ++i; + } + } + + // Check for and set token approvals for the campaign. + _setTokenApprovals(params); + + // Set the campaign params for the given campaignId. + _campaignParams[campaignId] = params; + + // Update campaign uri if it was provided. + if (bytes(uri).length != 0) { + _campaignURIs[campaignId] = uri; + } + + emit CampaignUpdated(campaignId, params, _campaignURIs[campaignId]); + } + + function _setTokenApprovals(CampaignParams memory params) internal { + // Allow Seaport and the conduit as operators on behalf of this contract for offer items to be minted and transferred. + for (uint256 i = 0; i < params.offer.length;) { + // Native items do not need to be approved. + if (params.offer[i].itemType == ItemType.NATIVE) { + revert InvalidNativeOfferItem(); + } + // ERC721 and ERC1155 have the same function signatures for isApprovedForAll and setApprovalForAll. + else if (params.offer[i].itemType >= ItemType.ERC721) { + if (!ERC721(params.offer[i].token).isApprovedForAll(_CONDUIT, address(this))) { + ERC721(params.offer[i].token).setApprovalForAll(_CONDUIT, true); + } + // Set the maximum approval amount for ERC20 tokens. + } else { + ERC20(params.offer[i].token).approve(_CONDUIT, type(uint256).max); + } + unchecked { + ++i; + } + } + + // Allow Seaport and the conduit as operators on behalf of this contract for consideration items to be transferred in the onReceived hooks. + for (uint256 i = 0; i < params.consideration.length;) { + // ERC721 and ERC1155 have the same function signatures for isApprovedForAll and setApprovalForAll. + if (params.consideration[i].itemType >= ItemType.ERC721) { + if (!ERC721(params.consideration[i].token).isApprovedForAll(_CONDUIT, address(this))) { + ERC721(params.consideration[i].token).setApprovalForAll(_CONDUIT, true); + } + // Set the maximum approval amount for ERC20 tokens. + } else { + ERC20(params.consideration[i].token).approve(_CONDUIT, type(uint256).max); + } + unchecked { + ++i; + } + } + } + + function updateCampaignURI(uint256 campaignId, string calldata uri) external { + CampaignParams storage params = _campaignParams[campaignId]; + + if (params.manager != msg.sender) revert NotManager(); + + _campaignURIs[campaignId] = uri; + + emit CampaignUpdated(campaignId, params, uri); + } + + /** + * @dev Generates an order with the specified minimum and maximum spent + * items, and optional context (supplied as extraData). + * + * @param fulfiller The address of the fulfiller. + * @param minimumReceived The minimum items that the caller must receive. + * @param maximumSpent The maximum items the caller is willing to spend. + * @param context Additional context of the order. + * + * @return offer A tuple containing the offer items. + * @return consideration An array containing the consideration items. + */ + function generateOrder( + address fulfiller, + SpentItem[] calldata minimumReceived, + SpentItem[] calldata maximumSpent, + bytes calldata context // encoded based on the schemaID + ) external override returns (SpentItem[] memory offer, ReceivedItem[] memory consideration) { + // Derive the offer and consideration with effects. + (offer, consideration) = _createOrder(fulfiller, minimumReceived, maximumSpent, context, true); + } + + /** + * @dev Ratifies an order with the specified offer, consideration, and + * optional context (supplied as extraData). + * + * @custom:param offer The offer items. + * @custom:param consideration The consideration items. + * @custom:param context Additional context of the order. + * @custom:param orderHashes The hashes to ratify. + * @custom:param contractNonce The nonce of the contract. + * + * @return ratifyOrderMagicValue The magic value returned by the contract + * offerer. + */ + function ratifyOrder( + SpentItem[] calldata, /* offer */ + ReceivedItem[] calldata, /* consideration */ + bytes calldata, /* context */ // encoded based on the schemaID + bytes32[] calldata, /* orderHashes */ + uint256 /* contractNonce */ + ) external pure override returns (bytes4) { + assembly { + // Return the RatifyOrder magic value. + mstore(0, 0xf4dd92ce) + return(0x1c, 32) + } + } + + /** + * @dev View function to preview an order generated in response to a minimum + * set of received items, maximum set of spent items, and context + * (supplied as extraData). + * + * @custom:param caller The address of the caller (e.g. Seaport). + * @param fulfiller The address of the fulfiller (e.g. the account + * calling Seaport). + * @param minimumReceived The minimum items that the caller is willing to + * receive. + * @param maximumSpent The maximum items caller is willing to spend. + * @param context Additional context of the order. + * + * @return offer A tuple containing the offer items. + * @return consideration A tuple containing the consideration items. + */ + function previewOrder( + address, /* caller */ + address fulfiller, + SpentItem[] calldata minimumReceived, + SpentItem[] calldata maximumSpent, + bytes calldata context // encoded based on the schemaID + ) external view override returns (SpentItem[] memory offer, ReceivedItem[] memory consideration) { + // To avoid the solidity compiler complaining about calling a non-view + // function here (_createOrder), we will cast it as a view and use it. + // This is okay because we are not modifying any state when passing + // withEffects=false. + function( + address, + SpentItem[] memory, + SpentItem[] memory, + bytes calldata, + bool + ) internal view returns (SpentItem[] memory, ReceivedItem[] memory) fn; + function( + address, + SpentItem[] memory, + SpentItem[] memory, + bytes calldata, + bool + ) + internal + returns ( + SpentItem[] memory, + ReceivedItem[] memory + ) fn2 = _createOrder; + assembly { + fn := fn2 + } + + // Derive the offer and consideration without effects. + (offer, consideration) = fn(fulfiller, minimumReceived, maximumSpent, context, false); + } + + /** + * @dev Gets the metadata for this contract offerer. + * + * @return name The name of the contract offerer. + * @return schemas The schemas supported by the contract offerer. + */ + function getSeaportMetadata() + external + pure + override + returns ( + string memory name, + Schema[] memory schemas // map to Seaport Improvement Proposal IDs + ) + { + schemas = new Schema[](0); + return ("RedeemablesContractOfferer", schemas); + } + + function supportsInterface(bytes4 interfaceId) external view virtual returns (bool) { + return interfaceId == type(ContractOffererInterface).interfaceId + || interfaceId == type(IERC721Receiver).interfaceId || interfaceId == type(IERC1155Receiver).interfaceId; + } + + function _createOrder( + address fulfiller, + SpentItem[] memory minimumReceived, + SpentItem[] memory maximumSpent, + bytes calldata context, + bool withEffects + ) internal returns (SpentItem[] memory offer, ReceivedItem[] memory consideration) { + // Get the campaign. + uint256 campaignId = uint256(bytes32(context[0:32])); + CampaignParams storage params = _campaignParams[campaignId]; + + // Declare an error buffer; first check is that caller is Seaport or the token contract. + uint256 errorBuffer = _cast(msg.sender != _SEAPORT && msg.sender != params.consideration[0].token); + + // Check the redemption is active. + errorBuffer |= _cast(_isInactive(params.startTime, params.endTime)) << 1; + + // Check max total redemptions would not be exceeded. + errorBuffer |= _cast(_totalRedemptions[campaignId] + maximumSpent.length > params.maxCampaignRedemptions) << 2; + + // Get the redemption hash. + bytes32 redemptionHash = bytes32(context[32:64]); + + // Check the signature is valid if required. + if (params.signer != address(0)) { + uint256 salt = uint256(bytes32(context[64:96])); + bytes memory signature = context[96:]; + // _verifySignature will revert if the signature is invalid or digest is already used. + _verifySignature(params.signer, fulfiller, maximumSpent, redemptionHash, salt, signature, withEffects); + } + + if (errorBuffer > 0) { + if (errorBuffer << 255 != 0) { + revert InvalidCaller(msg.sender); + } else if (errorBuffer << 254 != 0) { + revert NotActive(block.timestamp, params.startTime, params.endTime); + } else if (errorBuffer << 253 != 0) { + revert MaxCampaignRedemptionsReached( + _totalRedemptions[campaignId] + maximumSpent.length, params.maxCampaignRedemptions + ); + // TODO: do we need this error? + // } else if (errorBuffer << 252 != 0) { + // revert InvalidConsiderationLength( + // maximumSpent.length, + // params.consideration.length + // ); + } else if (errorBuffer << 252 != 0) { + revert InvalidConsiderationItem(maximumSpent[0].token, params.consideration[0].token); + } else { + // todo more validation errors + } + } + + // Set the offer from the params. + offer = new SpentItem[](params.offer.length); + for (uint256 i = 0; i < params.offer.length;) { + OfferItem memory offerItem = params.offer[i]; + + uint256 tokenId = IERC721RedemptionMintable(offerItem.token).mintRedemption(address(this), maximumSpent); + + // Set the itemType without criteria. + ItemType itemType = offerItem.itemType == ItemType.ERC721_WITH_CRITERIA + ? ItemType.ERC721 + : offerItem.itemType == ItemType.ERC1155_WITH_CRITERIA ? ItemType.ERC1155 : offerItem.itemType; + + offer[i] = SpentItem({ + itemType: itemType, + token: offerItem.token, + identifier: tokenId, + amount: offerItem.startAmount // TODO: do we need to calculate amount based on timestamp? + }); + unchecked { + ++i; + } + } + + // Set the consideration from the params. + consideration = new ReceivedItem[](params.consideration.length); + for (uint256 i = 0; i < params.consideration.length;) { + ConsiderationItem memory considerationItem = params.consideration[i]; + + // TODO: make helper getItemTypeWithoutCriteria + ItemType itemType; + uint256 identifier; + + // If consideration item is wildcard criteria item, set itemType to ERC721 + // and identifier to the maximumSpent item identifier. + if ( + (considerationItem.itemType == ItemType.ERC721_WITH_CRITERIA) + && (considerationItem.identifierOrCriteria == 0) + ) { + itemType = ItemType.ERC721; + identifier = maximumSpent[i].identifier; + } else if ( + (considerationItem.itemType == ItemType.ERC1155_WITH_CRITERIA) + && (considerationItem.identifierOrCriteria == 0) + ) { + itemType = ItemType.ERC1155; + identifier = maximumSpent[i].identifier; + } else { + itemType = considerationItem.itemType; + identifier = considerationItem.identifierOrCriteria; + } + + consideration[i] = ReceivedItem({ + itemType: itemType, + token: considerationItem.token, + identifier: identifier, + amount: considerationItem.startAmount, + recipient: considerationItem.recipient + }); + unchecked { + ++i; + } + } + + // If withEffects is true then make state changes. + if (withEffects) { + // Increment total redemptions. + _totalRedemptions[campaignId] += maximumSpent.length; + + SpentItem[] memory spent = new SpentItem[](consideration.length); + for (uint256 i = 0; i < consideration.length;) { + spent[i] = SpentItem({ + itemType: consideration[i].itemType, + token: consideration[i].token, + identifier: consideration[i].identifier, + amount: consideration[i].amount + }); + unchecked { + ++i; + } + } + + // Emit Redemption event. + emit Redemption(campaignId, redemptionHash); + } + } + + function onERC721Received( + address, + /* operator */ + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4) { + if (from == address(0)) { + return IERC721Receiver.onERC721Received.selector; + } + + // Get the campaign. + uint256 campaignId = uint256(bytes32(data[0:32])); + CampaignParams storage params = _campaignParams[campaignId]; + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: params.offer[0].token, + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: msg.sender, + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(address(0x000000000000000000000000000000000000dEaD)) + }); + + OrderParameters memory parameters = OrderParameters({ + offerer: address(this), + zone: address(0), + offer: offer, + consideration: consideration, + orderType: OrderType.CONTRACT, + startTime: block.timestamp, + endTime: block.timestamp + 10, // TODO: fix + zoneHash: bytes32(0), // TODO: fix + salt: uint256(0), // TODO: fix + conduitKey: _CONDUIT_KEY, + totalOriginalConsiderationItems: consideration.length + }); + + AdvancedOrder memory order = + AdvancedOrder({parameters: parameters, numerator: 1, denominator: 1, signature: "", extraData: data}); + + SeaportInterface(_SEAPORT).fulfillAdvancedOrder(order, new CriteriaResolver[](0), _CONDUIT_KEY, from); + + return IERC721Receiver.onERC721Received.selector; + } + + function onERC1155Received( + address, + /* operator */ + address from, + uint256 id, + uint256 value, + bytes calldata data + ) external returns (bytes4) { + if (from == address(0)) { + return IERC1155Receiver.onERC1155Received.selector; + } + + // Get the campaign. + uint256 campaignId = uint256(bytes32(data[0:32])); + CampaignParams storage params = _campaignParams[campaignId]; + + SpentItem[] memory minimumReceived = new SpentItem[](1); + minimumReceived[0] = SpentItem({ + itemType: ItemType.ERC721, + token: params.offer[0].token, + identifier: params.offer[0].identifierOrCriteria, + amount: params.offer[0].startAmount + }); + + SpentItem[] memory maximumSpent = new SpentItem[](1); + maximumSpent[0] = SpentItem({itemType: ItemType.ERC1155, token: msg.sender, identifier: id, amount: value}); + + // _createOrder will revert if any validations fail. + _createOrder(from, minimumReceived, maximumSpent, data, true); + + // Transfer the token to the consideration item recipient. + address recipient = _getConsiderationRecipient(params.consideration, msg.sender); + ERC1155(msg.sender).safeTransferFrom(address(this), recipient, id, value, ""); + + // Transfer the newly minted token to the fulfiller. + ERC721(params.offer[0].token).safeTransferFrom(address(this), from, id, ""); + + return IERC1155Receiver.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, /* operator */ + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4) { + if (from == address(0)) { + return IERC1155Receiver.onERC1155BatchReceived.selector; + } + + if (ids.length != values.length) revert RedeemMismatchedLengths(); + + // Get the campaign. + uint256 campaignId = uint256(bytes32(data[0:32])); + CampaignParams storage params = _campaignParams[campaignId]; + + SpentItem[] memory minimumReceived = new SpentItem[](1); + minimumReceived[0] = SpentItem({ + itemType: ItemType.ERC721, + token: params.offer[0].token, + identifier: params.offer[0].identifierOrCriteria, + amount: params.offer[0].startAmount + }); + + SpentItem[] memory maximumSpent = new SpentItem[](ids.length); + for (uint256 i = 0; i < ids.length;) { + maximumSpent[i] = + SpentItem({itemType: ItemType.ERC1155, token: msg.sender, identifier: ids[i], amount: values[i]}); + unchecked { + ++i; + } + } + + // _createOrder will revert if any validations fail. + _createOrder(from, minimumReceived, maximumSpent, data, true); + + // Transfer the tokens to the consideration item recipient. + address recipient = _getConsiderationRecipient(params.consideration, msg.sender); + ERC1155(msg.sender).safeBatchTransferFrom(address(this), recipient, ids, values, ""); + + // Transfer the newly minted token to the fulfiller. + ERC721(params.offer[0].token).safeTransferFrom(address(this), from, ids[0]); + + return IERC1155Receiver.onERC1155BatchReceived.selector; + } + + function getCampaign(uint256 campaignId) + external + view + returns (CampaignParams memory params, string memory uri, uint256 totalRedemptions) + { + if (campaignId >= _nextCampaignId) revert InvalidCampaignId(); + params = _campaignParams[campaignId]; + uri = _campaignURIs[campaignId]; + totalRedemptions = _totalRedemptions[campaignId]; + } + + function _getConsiderationRecipient(ConsiderationItem[] storage consideration, address token) + internal + view + returns (address) + { + for (uint256 i = 0; i < consideration.length;) { + if (consideration[i].token == token) { + return consideration[i].recipient; + } + unchecked { + ++i; + } + } + revert ConsiderationRecipientNotFound(token); + } + + function _isInactive(uint256 startTime, uint256 endTime) internal view returns (bool inactive) { + // Using the same check for time boundary from Seaport. + // startTime <= block.timestamp < endTime + assembly { + inactive := or(iszero(gt(endTime, timestamp())), gt(startTime, timestamp())) + } + } + + function _isValidTokenAddress(CampaignParams memory params, address token) internal pure returns (bool valid) { + for (uint256 i = 0; i < params.consideration.length;) { + if (params.consideration[i].token == token) { + valid = true; + break; + } + unchecked { + ++i; + } + } + } + + /** + * @notice Internal utility function to remove a uint from a supplied + * enumeration. + * + * @param toRemove The uint to remove. + * @param enumeration The enumerated uints to parse. + */ + function _removeFromEnumeration(uint256 toRemove, uint256[] storage enumeration) internal { + // Cache the length. + uint256 enumerationLength = enumeration.length; + for (uint256 i = 0; i < enumerationLength;) { + // Check if the enumerated element is the one we are deleting. + if (enumeration[i] == toRemove) { + // Swap with the last element. + enumeration[i] = enumeration[enumerationLength - 1]; + // Delete the (now duplicated) last element. + enumeration.pop(); + // Exit the loop. + break; + } + unchecked { + ++i; + } + } + } + + /** + * @notice Internal utility function to cast uint types to address + * to dedupe the need for multiple implementations of + * `_removeFromEnumeration`. + * + * @param fnIn The fn with uint input. + * + * @return fnOut The fn with address input. + */ + function _asAddressArray(function(uint256, uint256[] storage) internal fnIn) + internal + pure + returns (function(address, address[] storage) internal fnOut) + { + assembly { + fnOut := fnIn + } + } + + /** + * @dev Internal pure function to cast a `bool` value to a `uint256` value. + * + * @param b The `bool` value to cast. + * + * @return u The `uint256` value. + */ + function _cast(bool b) internal pure returns (uint256 u) { + assembly { + u := b + } + } +} diff --git a/lib/redeemables/src/interfaces/IDynamicTraits.sol b/lib/redeemables/src/interfaces/IDynamicTraits.sol new file mode 100644 index 0000000..45d831c --- /dev/null +++ b/lib/redeemables/src/interfaces/IDynamicTraits.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IERCDynamicTraits { + event TraitUpdated(uint256 indexed tokenId, bytes32 indexed traitKey, bytes32 oldValue, bytes32 newValue); + event TraitBulkUpdated(uint256 indexed fromTokenId, uint256 indexed toTokenId, bytes32 indexed traitKeyPattern); + + function getTrait(uint256 tokenId, bytes32 traitKey) external view returns (bytes32); +} diff --git a/lib/redeemables/src/interfaces/IERC1155Receiver.sol b/lib/redeemables/src/interfaces/IERC1155Receiver.sol new file mode 100644 index 0000000..ef19c11 --- /dev/null +++ b/lib/redeemables/src/interfaces/IERC1155Receiver.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface IERC1155Receiver { + /** + * @dev Handles the receipt of a single ERC1155 token type. This function is + * called at the end of a `safeTransferFrom` after the balance has been updated. + * + * NOTE: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param operator The address which initiated the transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param id The ID of the token being transferred + * @param value The amount of tokens being transferred + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed + */ + function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data) + external + returns (bytes4); + + /** + * @dev Handles the receipt of a multiple ERC1155 token types. This function + * is called at the end of a `safeBatchTransferFrom` after the balances have + * been updated. + * + * NOTE: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param operator The address which initiated the batch transfer (i.e. msg.sender) + * @param from The address which previously owned the token + * @param ids An array containing ids of each token being transferred (order and length must match values array) + * @param values An array containing amounts of each token being transferred (order and length must match ids array) + * @param data Additional data with no specified format + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed + */ + function onERC1155BatchReceived( + address operator, + address from, + uint256[] calldata ids, + uint256[] calldata values, + bytes calldata data + ) external returns (bytes4); +} diff --git a/lib/redeemables/src/interfaces/IERC1155RedemptionMintable.sol b/lib/redeemables/src/interfaces/IERC1155RedemptionMintable.sol new file mode 100644 index 0000000..a57e8c2 --- /dev/null +++ b/lib/redeemables/src/interfaces/IERC1155RedemptionMintable.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +interface IERC1155RedemptionMintable { + function mintRedemption(address to, SpentItem[] calldata spent) external returns (uint256 tokenId); +} diff --git a/lib/redeemables/src/interfaces/IERC721RedemptionMintable.sol b/lib/redeemables/src/interfaces/IERC721RedemptionMintable.sol new file mode 100644 index 0000000..0bc2cfe --- /dev/null +++ b/lib/redeemables/src/interfaces/IERC721RedemptionMintable.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +interface IERC721RedemptionMintable { + function mintRedemption(address to, SpentItem[] calldata spent) external returns (uint256 tokenId); +} diff --git a/lib/redeemables/src/interfaces/IERC7XXX.sol b/lib/redeemables/src/interfaces/IERC7XXX.sol new file mode 100644 index 0000000..92fc7bd --- /dev/null +++ b/lib/redeemables/src/interfaces/IERC7XXX.sol @@ -0,0 +1,28 @@ +interface IERC7XXX { + /* Events */ + event TraitUpdated(uint256 indexed tokenId, bytes32 indexed traitKey, bytes32 value); + event TraitUpdatedBulkConsecutive(uint256 fromTokenId, uint256 toTokenId, bytes32 indexed traitKeyPattern); + event TraitUpdatedBulkList(uint256[] tokenIds, bytes32 indexed traitKeyPattern); + event TraitLabelsURIUpdated(string uri); + + /* Getters */ + function getTrait(bytes32 traitKey, uint256 tokenId) external view returns (bytes32); + + // function getTotalTraitKeys() external view returns (uint256); + + // function getTraitKeyAt(uint256 index) external view returns (bytes32); + + // function getTraitLabelsURI() external view returns (string memory); + + // TODO to consider: + // function getTraitKeys() external view returns (bytes32[] memory); + + function getTraits(bytes32 traitKey, uint256[] calldata tokenIds) external view returns (bytes32[] memory); + + /* Setters */ + function setTrait(uint256 tokenId, bytes32 traitKey, bytes32 value) external; + + function supportsInterface(bytes4 interfaceId) external view returns (bool); + + // function setTraitLabelsURI(string calldata uri) external; +} diff --git a/lib/redeemables/src/interfaces/IRedeemableContractOfferer.sol b/lib/redeemables/src/interfaces/IRedeemableContractOfferer.sol new file mode 100644 index 0000000..e261bbb --- /dev/null +++ b/lib/redeemables/src/interfaces/IRedeemableContractOfferer.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { + OfferItem, + ConsiderationItem, + SpentItem, + AdvancedOrder, + OrderParameters, + CriteriaResolver, + FulfillmentComponent +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {CampaignParams, TraitRedemption} from "../lib/RedeemableStructs.sol"; + +interface IRedeemableContractOfferer { + /* Events */ + event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string URI); + event Redemption(uint256 indexed campaignId, bytes32 redemptionHash); + + /* Getters */ + function getCampaign(uint256 campaignId) + external + view + returns (CampaignParams memory params, string memory uri, uint256 totalRedemptions); + + /* Setters */ + function createCampaign(CampaignParams calldata params, string calldata uri) + external + returns (uint256 campaignId); + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) external; +} diff --git a/lib/redeemables/src/lib/DynamicTraits.sol b/lib/redeemables/src/lib/DynamicTraits.sol new file mode 100644 index 0000000..d735b39 --- /dev/null +++ b/lib/redeemables/src/lib/DynamicTraits.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ECDSA} from "solady/src/utils/ECDSA.sol"; +import {IERCDynamicTraits} from "../interfaces/IDynamicTraits.sol"; +import {RedeemableErrorsAndEvents} from "./RedeemableErrorsAndEvents.sol"; + +contract DynamicTraits is IERCDynamicTraits, RedeemableErrorsAndEvents { + mapping(uint256 tokenId => mapping(bytes32 traitKey => bytes32 traitValue)) internal _traits; + + function getTrait(uint256 tokenId, bytes32 traitKey) public view virtual override returns (bytes32) { + return _traits[tokenId][traitKey]; + } + + function _setTrait(uint256 tokenId, bytes32 traitKey, bytes32 newValue) internal { + bytes32 oldValue = _traits[tokenId][traitKey]; + + if (oldValue == newValue) { + revert TraitValueUnchanged(traitKey, oldValue); + } + + _traits[tokenId][traitKey] = newValue; + + emit TraitUpdated(tokenId, traitKey, oldValue, newValue); + } + + function _setTraitBulk(uint256 fromTokenId, uint256 toTokenId, bytes32 traitKey, bytes32 newValue) internal { + for (uint256 tokenId = fromTokenId; tokenId <= toTokenId; tokenId++) { + _traits[tokenId][traitKey] = newValue; + } + + emit TraitBulkUpdated(fromTokenId, toTokenId, traitKey); + } +} diff --git a/lib/redeemables/src/lib/ERC1155RedemptionMintable.sol b/lib/redeemables/src/lib/ERC1155RedemptionMintable.sol new file mode 100644 index 0000000..11cf204 --- /dev/null +++ b/lib/redeemables/src/lib/ERC1155RedemptionMintable.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; +import {IERC1155RedemptionMintable} from "../interfaces/IERC1155RedemptionMintable.sol"; +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +contract ERC1155RedemptionMintable is ERC1155, IERC1155RedemptionMintable { + address internal immutable _REDEEMABLE_CONTRACT_OFFERER; + address internal immutable _REDEEM_TOKEN; + + /// @dev Revert if the sender of mintRedemption is not the redeemable contract offerer. + error InvalidSender(); + + /// @dev Revert if the redemption spent is not the required token. + error InvalidRedemption(); + + constructor(address redeemableContractOfferer, address redeemToken) { + _REDEEMABLE_CONTRACT_OFFERER = redeemableContractOfferer; + _REDEEM_TOKEN = redeemToken; + } + + function mintRedemption(address to, SpentItem[] calldata spent) external returns (uint256 tokenId) { + if (msg.sender != _REDEEMABLE_CONTRACT_OFFERER) revert InvalidSender(); + + SpentItem memory spentItem = spent[0]; + if (spentItem.token != _REDEEM_TOKEN) revert InvalidRedemption(); + + // Mint the same token ID redeemed and same amount redeemed. + _mint(to, spentItem.identifier, spentItem.amount, ""); + + return spentItem.identifier; + } + + function uri(uint256 id) public pure override returns (string memory) { + return string(abi.encodePacked("https://example.com/", id)); + } +} diff --git a/lib/redeemables/src/lib/ERC721RedemptionMintable.sol b/lib/redeemables/src/lib/ERC721RedemptionMintable.sol new file mode 100644 index 0000000..5714b5e --- /dev/null +++ b/lib/redeemables/src/lib/ERC721RedemptionMintable.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {IERC721RedemptionMintable} from "../interfaces/IERC721RedemptionMintable.sol"; +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +contract ERC721RedemptionMintable is ERC721, IERC721RedemptionMintable { + address internal immutable _REDEEMABLE_CONTRACT_OFFERER; + address internal immutable _REDEEM_TOKEN; + + /// @dev Revert if the sender of mintRedemption is not the redeemable contract offerer. + error InvalidSender(); + + /// @dev Revert if the redemption spent is not the required token. + error InvalidRedemption(); + + constructor(address redeemableContractOfferer, address redeemToken) { + _REDEEMABLE_CONTRACT_OFFERER = redeemableContractOfferer; + _REDEEM_TOKEN = redeemToken; + } + + function mintRedemption(address to, SpentItem[] calldata spent) external returns (uint256 tokenId) { + if (msg.sender != _REDEEMABLE_CONTRACT_OFFERER) revert InvalidSender(); + + SpentItem memory spentItem = spent[0]; + if (spentItem.token != _REDEEM_TOKEN) revert InvalidRedemption(); + + // Mint the same token ID redeemed. + _mint(to, spentItem.identifier); + + return spentItem.identifier; + } + + function name() public pure override returns (string memory) { + return "ERC721RedemptionMintable"; + } + + function symbol() public pure override returns (string memory) { + return "721RM"; + } + + function tokenURI(uint256 tokenId) public pure override returns (string memory) { + return string(abi.encodePacked("https://example.com/", tokenId)); + } +} diff --git a/lib/redeemables/src/lib/ERC721RedemptionMintableWithCounter.sol b/lib/redeemables/src/lib/ERC721RedemptionMintableWithCounter.sol new file mode 100644 index 0000000..62a0968 --- /dev/null +++ b/lib/redeemables/src/lib/ERC721RedemptionMintableWithCounter.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {IERC721RedemptionMintable} from "../interfaces/IERC721RedemptionMintable.sol"; +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +contract ERC721RedemptionMintableWithCounter is ERC721, IERC721RedemptionMintable { + address internal immutable _REDEEMABLE_CONTRACT_OFFERER; + address internal immutable _REDEEM_TOKEN; + uint256 internal _tokenIdCounter; + + /// @dev Revert if the sender of mintRedemption is not the redeemable contract offerer. + error InvalidSender(); + + /// @dev Revert if the redemption spent is not the required token. + error InvalidRedemption(); + + constructor(address redeemableContractOfferer, address redeemToken) { + _REDEEMABLE_CONTRACT_OFFERER = redeemableContractOfferer; + _REDEEM_TOKEN = redeemToken; + } + + function mintRedemption(address to, SpentItem[] calldata spent) external returns (uint256 tokenId) { + if (msg.sender != _REDEEMABLE_CONTRACT_OFFERER) revert InvalidSender(); + + SpentItem memory spentItem = spent[0]; + if (spentItem.token != _REDEEM_TOKEN) revert InvalidRedemption(); + + // Mint the token. + _mint(to, _tokenIdCounter); + + tokenId = _tokenIdCounter; + + _tokenIdCounter++; + } + + function name() public pure override returns (string memory) { + return "ERC721RedemptionMintable"; + } + + function symbol() public pure override returns (string memory) { + return "721RM"; + } + + function tokenURI(uint256 tokenId) public pure override returns (string memory) { + return string(abi.encodePacked("https://example.com/", tokenId)); + } +} diff --git a/lib/redeemables/src/lib/RedeemableErrorsAndEvents.sol b/lib/redeemables/src/lib/RedeemableErrorsAndEvents.sol new file mode 100644 index 0000000..0c477a3 --- /dev/null +++ b/lib/redeemables/src/lib/RedeemableErrorsAndEvents.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {CampaignParams} from "./RedeemableStructs.sol"; + +interface RedeemableErrorsAndEvents { + /// Configuration errors + error NotManager(); + error InvalidTime(); + error NoConsiderationItems(); + error ConsiderationItemRecipientCannotBeZeroAddress(); + + /// Redemption errors + error InvalidCampaignId(); + error CampaignAlreadyExists(); + error InvalidCaller(address caller); + error NotActive(uint256 currentTimestamp, uint256 startTime, uint256 endTime); + error MaxRedemptionsReached(uint256 total, uint256 max); + error MaxCampaignRedemptionsReached(uint256 total, uint256 max); + error RedeemMismatchedLengths(); + error TraitValueUnchanged(bytes32 traitKey, bytes32 value); + error InvalidConsiderationLength(uint256 got, uint256 want); + error InvalidConsiderationItem(address got, address want); + error InvalidOfferLength(uint256 got, uint256 want); + error InvalidNativeOfferItem(); + error InvalidOwner(); + error InvalidRequiredValue(bytes32 got, bytes32 want); + error InvalidSubstandard(uint256 substandard); + error InvalidTraitRedemption(); + error InvalidTraitRedemptionToken(address token); + error ConsiderationRecipientNotFound(address token); + error RedemptionValuesAreImmutable(); + + /// Events + event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string uri); + event Redemption(uint256 indexed campaignId, bytes32 redemptionHash); +} diff --git a/lib/redeemables/src/lib/RedeemableStructs.sol b/lib/redeemables/src/lib/RedeemableStructs.sol new file mode 100644 index 0000000..db35636 --- /dev/null +++ b/lib/redeemables/src/lib/RedeemableStructs.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; + +struct CampaignParams { + uint32 startTime; + uint32 endTime; + uint32 maxCampaignRedemptions; + address manager; + address signer; + OfferItem[] offer; + ConsiderationItem[] consideration; +} + +struct TraitRedemption { + uint8 substandard; + address token; + uint256 identifier; + bytes32 traitKey; + bytes32 traitValue; + bytes32 substandardValue; +} + +struct RedemptionContext { + SpentItem[] spent; +} diff --git a/lib/redeemables/src/lib/SignedRedeem.sol b/lib/redeemables/src/lib/SignedRedeem.sol new file mode 100644 index 0000000..6891dbc --- /dev/null +++ b/lib/redeemables/src/lib/SignedRedeem.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {SignatureCheckerLib} from "solady/src/utils/SignatureCheckerLib.sol"; +import {SignedRedeemErrorsAndEvents} from "./SignedRedeemErrorsAndEvents.sol"; + +contract SignedRedeem is Ownable, SignedRedeemErrorsAndEvents { + /// @dev Signer approval to redeem tokens (e.g. KYC), required when set. + address internal _redeemSigner; + + /// @dev The used digests, each digest can only be used once. + mapping(bytes32 => bool) internal _usedDigests; + + /// @notice Internal constants for EIP-712: Typed structured + /// data hashing and signing + bytes32 internal constant _SIGNED_REDEEM_TYPEHASH = + keccak256("SignedRedeem(address owner,uint256[] tokenIds,uint256 salt)"); + bytes32 internal constant _EIP_712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 internal constant _NAME_HASH = keccak256("SignedRedeem"); + bytes32 internal constant _VERSION_HASH = keccak256("1.0"); + uint256 internal immutable _CHAIN_ID = block.chainid; + bytes32 internal immutable _DOMAIN_SEPARATOR; + + constructor() { + _initializeOwner(msg.sender); + _DOMAIN_SEPARATOR = _deriveDomainSeparator(); + } + + function updateSigner(address newSigner) public onlyOwner { + _redeemSigner = newSigner; + } + + function _verifySignatureAndRecordDigest( + address owner, + uint256[] calldata tokenIds, + uint256 salt, + bytes calldata signature + ) internal { + // Get the digest. + bytes32 digest = _getDigest(owner, tokenIds, salt); + + // Revert if signature does not recover to signer. + if (!SignatureCheckerLib.isValidSignatureNowCalldata(_redeemSigner, digest, signature)) revert InvalidSigner(); + + // Revert if the digest is already used. + if (_usedDigests[digest]) revert DigestAlreadyUsed(); + + // Record digest as used. + _usedDigests[digest] = true; + } + + /* + * @notice Verify an EIP-712 signature by recreating the data structure + * that we signed on the client side, and then using that to recover + * the address that signed the signature for this data. + */ + function _getDigest(address owner, uint256[] calldata tokenIds, uint256 salt) + internal + view + returns (bytes32 digest) + { + digest = keccak256( + bytes.concat( + bytes2(0x1901), + _domainSeparator(), + keccak256(abi.encode(_SIGNED_REDEEM_TYPEHASH, owner, tokenIds, salt)) + ) + ); + } + + /** + * @dev Internal view function to get the EIP-712 domain separator. If the + * chainId matches the chainId set on deployment, the cached domain + * separator will be returned; otherwise, it will be derived from + * scratch. + * + * @return The domain separator. + */ + function _domainSeparator() internal view returns (bytes32) { + return block.chainid == _CHAIN_ID ? _DOMAIN_SEPARATOR : _deriveDomainSeparator(); + } + + /** + * @dev Internal view function to derive the EIP-712 domain separator. + * + * @return The derived domain separator. + */ + function _deriveDomainSeparator() internal view returns (bytes32) { + return keccak256(abi.encode(_EIP_712_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this))); + } +} diff --git a/lib/redeemables/src/lib/SignedRedeemContractOfferer.sol b/lib/redeemables/src/lib/SignedRedeemContractOfferer.sol new file mode 100644 index 0000000..53028e5 --- /dev/null +++ b/lib/redeemables/src/lib/SignedRedeemContractOfferer.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {SignatureCheckerLib} from "solady/src/utils/SignatureCheckerLib.sol"; +import {SignedRedeemErrorsAndEvents} from "./SignedRedeemErrorsAndEvents.sol"; + +contract SignedRedeemContractOfferer is SignedRedeemErrorsAndEvents { + /// @dev The used digests, each digest can only be used once. + mapping(bytes32 => bool) internal _usedDigests; + + /// @notice Internal constants for EIP-712: Typed structured + /// data hashing and signing + bytes32 internal constant _SIGNED_REDEEM_TYPEHASH = + keccak256("SignedRedeem(address owner,address token,uint256[] tokenIds,bytes32 redemptionHash,uint256 salt)"); + bytes32 internal constant _EIP_712_DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 internal constant _NAME_HASH = keccak256("SignedRedeem"); + bytes32 internal constant _VERSION_HASH = keccak256("1.0"); + uint256 internal immutable _CHAIN_ID = block.chainid; + bytes32 internal immutable _DOMAIN_SEPARATOR; + + constructor() { + _DOMAIN_SEPARATOR = _deriveDomainSeparator(); + } + + function _verifySignature( + address signer, + address owner, + SpentItem[] memory maximumSpent, + bytes32 redemptionHash, + uint256 salt, + bytes memory signature, + bool recordDigest + ) internal { + // Get the digest. + bytes32 digest = _getDigest(owner, maximumSpent, redemptionHash, salt); + + // Revert if signature does not recover to signer. + if (!SignatureCheckerLib.isValidSignatureNow(signer, digest, signature)) revert InvalidSigner(); + + // Revert if the digest is already used. + if (_usedDigests[digest]) revert DigestAlreadyUsed(); + + // Record digest as used. + if (recordDigest) _usedDigests[digest] = true; + } + + /* + * @notice Verify an EIP-712 signature by recreating the data structure + * that we signed on the client side, and then using that to recover + * the address that signed the signature for this data. + */ + function _getDigest(address owner, SpentItem[] memory maximumSpent, bytes32 redemptionHash, uint256 salt) + internal + view + returns (bytes32 digest) + { + digest = keccak256( + bytes.concat( + bytes2(0x1901), + _domainSeparator(), + keccak256(abi.encode(_SIGNED_REDEEM_TYPEHASH, owner, maximumSpent, redemptionHash, salt)) + ) + ); + } + + /** + * @dev Internal view function to get the EIP-712 domain separator. If the + * chainId matches the chainId set on deployment, the cached domain + * separator will be returned; otherwise, it will be derived from + * scratch. + * + * @return The domain separator. + */ + function _domainSeparator() internal view returns (bytes32) { + return block.chainid == _CHAIN_ID ? _DOMAIN_SEPARATOR : _deriveDomainSeparator(); + } + + /** + * @dev Internal view function to derive the EIP-712 domain separator. + * + * @return The derived domain separator. + */ + function _deriveDomainSeparator() internal view returns (bytes32) { + return keccak256(abi.encode(_EIP_712_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this))); + } +} diff --git a/lib/redeemables/src/lib/SignedRedeemErrorsAndEvents.sol b/lib/redeemables/src/lib/SignedRedeemErrorsAndEvents.sol new file mode 100644 index 0000000..b06e5be --- /dev/null +++ b/lib/redeemables/src/lib/SignedRedeemErrorsAndEvents.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +interface SignedRedeemErrorsAndEvents { + error InvalidSigner(); + error DigestAlreadyUsed(); +} diff --git a/lib/redeemables/test/RedeemViaSeaport-1155.t.sol b/lib/redeemables/test/RedeemViaSeaport-1155.t.sol new file mode 100644 index 0000000..5414012 --- /dev/null +++ b/lib/redeemables/test/RedeemViaSeaport-1155.t.sol @@ -0,0 +1,1233 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Solarray} from "solarray/Solarray.sol"; +import {BaseOrderTest} from "./utils/BaseOrderTest.sol"; +import {TestERC20} from "./utils/mocks/TestERC20.sol"; +import {TestERC721} from "./utils/mocks/TestERC721.sol"; +import {TestERC1155} from "./utils/mocks/TestERC1155.sol"; +import { + OfferItem, + ConsiderationItem, + SpentItem, + AdvancedOrder, + OrderParameters, + CriteriaResolver, + FulfillmentComponent +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {ItemType, OrderType, Side} from "seaport-sol/src/SeaportEnums.sol"; +import {MockERC721DynamicTraits} from "./utils/mocks/MockERC721DynamicTraits.sol"; +import {OfferItemLib, ConsiderationItemLib, OrderParametersLib} from "seaport-sol/src/SeaportSol.sol"; +import {RedeemableContractOfferer} from "../src/RedeemableContractOfferer.sol"; +import {CampaignParams, TraitRedemption} from "../src/lib/RedeemableStructs.sol"; +import {RedeemableErrorsAndEvents} from "../src/lib/RedeemableErrorsAndEvents.sol"; +import {ERC1155RedemptionMintable} from "../src/lib/ERC1155RedemptionMintable.sol"; +import {ERC721RedemptionMintable} from "../src/lib/ERC721RedemptionMintable.sol"; +import {ERC721RedemptionMintableWithCounter} from "../src/lib/ERC721RedemptionMintableWithCounter.sol"; +import {Merkle} from "../lib/murky/src/Merkle.sol"; + +contract RedeemViaSeaport1155 is BaseOrderTest, RedeemableErrorsAndEvents { + using OrderParametersLib for OrderParameters; + + error InvalidContractOrder(bytes32 orderHash); + + RedeemableContractOfferer offerer; + TestERC1155 redeemableToken; + ERC1155RedemptionMintable redemptionToken; + CriteriaResolver[] criteriaResolvers; + Merkle merkle = new Merkle(); + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function setUp() public override { + super.setUp(); + offerer = new RedeemableContractOfferer( + address(conduit), + conduitKey, + address(seaport) + ); + redeemableToken = new TestERC1155(); + redemptionToken = new ERC1155RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + vm.label(address(redeemableToken), "redeemableToken"); + vm.label(address(redemptionToken), "redemptionToken"); + } + + function testRedeemWithSeaport() public { + uint256 tokenId = 2; + uint256 amount = 10 ** 18; + redeemableToken.mint(address(this), tokenId, amount); + redeemableToken.setApprovalForAll(address(conduit), true); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: amount, + endAmount: amount + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: amount, + endAmount: amount, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC1155, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: amount, + endAmount: amount + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC1155, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: amount, + endAmount: amount, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + consideration[0].identifierOrCriteria = tokenId; + + // TODO: validate OrderFulfilled event + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // { + // bytes + // vm.expectEmit(true, true, true, true); + // emit OrderFulfilled(); + // } + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + assertEq(redeemableToken.balanceOf(_BURN_ADDRESS, tokenId), amount); + assertEq(redemptionToken.balanceOf(address(this), tokenId), amount); + } + } + + // TODO: write test with ETH redemption consideration + // TODO: 1155 tests with same tokenId (amount > 1), different tokenIds + // TODO: update erc20 amount to use decimals + + function testRedeemAndSendErc20ToThirdAddressViaSeaport() public { + uint256 tokenId = 2; + uint256 amount = 10 ** 18; + redeemableToken.mint(address(this), tokenId, amount); + redeemableToken.setApprovalForAll(address(conduit), true); + + // Deploy the ERC20 + TestERC20 erc20 = new TestERC20(); + uint256 erc20Amount = 10; + + // Mint 100 tokens to the test contract + erc20.mint(address(this), 100); + + // Approve the conduit to spend tokens + erc20.approve(address(conduit), type(uint256).max); + + OfferItem[] memory campaignOffer = new OfferItem[](1); + campaignOffer[0] = OfferItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: amount, + endAmount: amount + }); + + ConsiderationItem[] memory campaignConsideration = new ConsiderationItem[](2); + campaignConsideration[0] = ConsiderationItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: amount, + endAmount: amount, + recipient: payable(_BURN_ADDRESS) + }); + campaignConsideration[1] = ConsiderationItem({ + itemType: ItemType.ERC20, + token: address(erc20), + identifierOrCriteria: 0, + startAmount: erc20Amount, + endAmount: erc20Amount, + recipient: payable(eve.addr) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: campaignOffer, + consideration: campaignConsideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC1155, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC1155, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(campaignConsideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + campaignConsideration[0].itemType = ItemType.ERC1155; + campaignConsideration[0].identifierOrCriteria = tokenId; + + // TODO: validate OrderFulfilled event + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(campaignConsideration).withOffer(campaignOffer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(campaignConsideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + uint256 erc20BalanceBefore = erc20.balanceOf(address(this)); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + assertEq(redeemableToken.balanceOf(_BURN_ADDRESS, tokenId), amount); + assertEq(redemptionToken.balanceOf(address(this), tokenId), amount); + assertEq(erc20BalanceBefore - erc20.balanceOf(address(this)), erc20Amount); + assertEq(erc20.balanceOf(eve.addr), erc20Amount); + } + } + + // TODO: add resolved tokenId to extradata + // TODO: fix redemptionToken being minted with merkle root + function xtestRedeemWithCriteriaResolversViaSeaport() public { + uint256 tokenId = 2; + uint256 amount = 10 ** 18; + redeemableToken.mint(address(this), tokenId, amount); + redeemableToken.setApprovalForAll(address(conduit), true); + + CriteriaResolver[] memory resolvers = new CriteriaResolver[](1); + + // Create an array of hashed identifiers (0-4) + // Only tokenIds 0-4 can be redeemed + bytes32[] memory hashedIdentifiers = new bytes32[](5); + for (uint256 i = 0; i < hashedIdentifiers.length; i++) { + hashedIdentifiers[i] = keccak256(abi.encode(i)); + } + bytes32 root = merkle.getRoot(hashedIdentifiers); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Contract offerer will only consider tokenIds 0-4 + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC1155_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: uint256(root), + startAmount: amount, + endAmount: amount, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(consideration).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + resolvers[0] = CriteriaResolver({ + orderIndex: 0, + side: Side.CONSIDERATION, + index: 0, + identifier: tokenId, + criteriaProof: merkle.getProof(hashedIdentifiers, 2) + }); + + // TODO: validate OrderFulfilled event + // vm.expectEmit(true, true, true, true); + // emit OrderFulfilled(); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: resolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // TODO: failing because redemptionToken tokenId is merkle root + assertEq(redeemableToken.balanceOf(_BURN_ADDRESS, tokenId), amount); + // assertEq(redemptionToken.ownerOf(tokenId), address(this)); + } + } + + // // TODO: burn 1, send weth to third address, also redeem trait + // // TODO: mock erc20 to third address or burn + // // TODO: mock erc721 with tokenId counter + // // TODO: make MockErc20RedemptionMintable with mintRedemption + // // TODO: burn nft and send erc20 to third address, get nft and erc20 + // // TODO: mintRedemption should return tokenIds array + // // TODO: then add dynamic traits + // // TODO: by EOW, have dynamic traits demo + + // // notice: redemptionToken tokenId will be tokenId of first item in consideration + // function testBurn2Redeem1ViaSeaport() public { + // // Set the two tokenIds to be burned + // uint256 burnTokenId0 = 2; + // uint256 burnTokenId1 = 3; + + // // Mint two redeemableTokens of tokenId burnTokenId0 and burnTokenId1 to the test contract + // redeemableToken.mint(address(this), burnTokenId0); + // redeemableToken.mint(address(this), burnTokenId1); + + // // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + // redeemableToken.setApprovalForAll(address(conduit), true); + + // // Create a single-item OfferItem array with the redemption token the caller will receive + // OfferItem[] memory offer = new OfferItem[](1); + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create a single-item ConsiderationItem array and require the caller to burn two redeemableTokens (of any tokenId) + // ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + // consideration[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // consideration[1] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Create the CampaignParams with the offer and consideration from above. + // { + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // // Call createCampaign on the offerer and pass in the CampaignParams + // offerer.createCampaign(params, ""); + // } + + // // uint256 campaignId = 1; + // // bytes32 redemptionHash = bytes32(0); + + // { + // // Create the offer we expect to be emitted in the event + // OfferItem[] memory offerFromEvent = new OfferItem[](1); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionToken), + // identifierOrCriteria: burnTokenId0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create the consideration we expect to be emitted in the event + // ConsiderationItem[] + // memory considerationFromEvent = new ConsiderationItem[](2); + // considerationFromEvent[0] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableToken), + // identifierOrCriteria: burnTokenId0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // considerationFromEvent[1] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableToken), + // identifierOrCriteria: burnTokenId1, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + // assertEq(uint256(consideration[0].itemType), 4); + + // // Check that the consideration emitted in the event has itemType ERC721 + // assertEq(uint256(considerationFromEvent[0].itemType), 2); + // assertEq(uint256(considerationFromEvent[1].itemType), 2); + + // // Create the extraData to be passed into fulfillAdvancedOrder + // bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // // TODO: validate OrderFulfilled event + + // // Create the OrderParameters to be passed into fulfillAdvancedOrder + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(considerationFromEvent) + // .withOffer(offer) + // .withConduitKey(conduitKey) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems( + // considerationFromEvent.length + // ); + + // // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // // Call fulfillAdvancedOrder + // seaport.fulfillAdvancedOrder({ + // advancedOrder: order, + // criteriaResolvers: criteriaResolvers, + // fulfillerConduitKey: conduitKey, + // recipient: address(0) + // }); + + // // Check that the two redeemable tokens have been burned + // assertEq(redeemableToken.ownerOf(burnTokenId0), _BURN_ADDRESS); + // assertEq(redeemableToken.ownerOf(burnTokenId1), _BURN_ADDRESS); + + // // Check that the redemption token has been minted to the test contract + // assertEq(redemptionToken.ownerOf(burnTokenId0), address(this)); + // } + // } + + // function testBurn1Redeem2WithSeaport() public { + // // Set the two tokenIds to be redeemed + // uint256 redemptionTokenId0 = 0; + // uint256 redemptionTokenId1 = 1; + + // // Set the tokenId to be burned to the first tokenId to be redeemed + // uint256 redeemableTokenId0 = redemptionTokenId0; + + // ERC721RedemptionMintableWithCounter redemptionTokenWithCounter = new ERC721RedemptionMintableWithCounter( + // address(offerer), + // address(redeemableToken) + // ); + + // // Mint a redeemableToken of tokenId redeemableTokenId0 to the test contract + // redeemableToken.mint(address(this), redeemableTokenId0); + + // // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + // redeemableToken.setApprovalForAll(address(conduit), true); + + // // Create a two-item OfferItem array with the 2 redemptionTokens the caller will receive + // OfferItem[] memory offer = new OfferItem[](2); + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionTokenWithCounter), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // offer[1] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionTokenWithCounter), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create a single-item ConsiderationItem array with the redeemableToken the caller will burn + // ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + // consideration[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Create the CampaignParams with the offer and consideration from above. + // { + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // // Call createCampaign on the offerer and pass in the CampaignParams + // offerer.createCampaign(params, ""); + // } + + // // uint256 campaignId = 1; + // // bytes32 redemptionHash = bytes32(0); + + // { + // // Create the offer we expect to be emitted in the event + // OfferItem[] memory offerFromEvent = new OfferItem[](2); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionTokenWithCounter), + // identifierOrCriteria: redemptionTokenId0, + // startAmount: 1, + // endAmount: 1 + // }); + + // offerFromEvent[1] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionTokenWithCounter), + // identifierOrCriteria: redemptionTokenId1, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create the consideration we expect to be emitted in the event + // ConsiderationItem[] + // memory considerationFromEvent = new ConsiderationItem[](1); + // considerationFromEvent[0] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableToken), + // identifierOrCriteria: redeemableTokenId0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + // assertEq(uint256(consideration[0].itemType), 4); + + // // Check that the consideration emitted in the event has itemType ERC721 + // assertEq(uint256(considerationFromEvent[0].itemType), 2); + + // // Create the extraData to be passed into fulfillAdvancedOrder + // bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // // TODO: validate OrderFulfilled event + + // // Create the OrderParameters to be passed into fulfillAdvancedOrder + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(considerationFromEvent) + // .withOffer(offer) + // .withConduitKey(conduitKey) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems( + // considerationFromEvent.length + // ); + + // // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // // Call fulfillAdvancedOrder + // seaport.fulfillAdvancedOrder({ + // advancedOrder: order, + // criteriaResolvers: criteriaResolvers, + // fulfillerConduitKey: conduitKey, + // recipient: address(0) + // }); + + // // Check that the redeemableToken has been burned + // assertEq( + // redeemableToken.ownerOf(redeemableTokenId0), + // _BURN_ADDRESS + // ); + + // // Check that the two redemptionTokens has been minted to the test contract + // assertEq( + // redemptionTokenWithCounter.ownerOf(redemptionTokenId0), + // address(this) + // ); + // assertEq( + // redemptionTokenWithCounter.ownerOf(redemptionTokenId1), + // address(this) + // ); + // } + // } + + // function testBurn2SeparateRedeemableTokensRedeem1ViaSeaport() public { + // // Set the tokenId to be burned + // uint256 burnTokenId0 = 2; + + // // Create the second redeemableToken to be burned + // TestERC721 redeemableTokenTwo = new TestERC721(); + + // // Mint one redeemableToken ane one redeemableTokenTwo of tokenId burnTokenId0 to the test contract + // redeemableToken.mint(address(this), burnTokenId0); + // redeemableTokenTwo.mint(address(this), burnTokenId0); + + // // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + // redeemableToken.setApprovalForAll(address(conduit), true); + // redeemableTokenTwo.setApprovalForAll(address(conduit), true); + + // // Create a single-item OfferItem array with the redemption token the caller will receive + // OfferItem[] memory offer = new OfferItem[](1); + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create a two-item ConsiderationItem array and require the caller to burn one redeemableToken and one redeemableTokenTwo (of any tokenId) + // ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + // consideration[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // consideration[1] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableTokenTwo), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Create the CampaignParams with the offer and consideration from above. + // { + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // // Call createCampaign on the offerer and pass in the CampaignParams + // offerer.createCampaign(params, ""); + // } + + // // uint256 campaignId = 1; + // // bytes32 redemptionHash = bytes32(0); + + // { + // // Create the offer we expect to be emitted in the event + // OfferItem[] memory offerFromEvent = new OfferItem[](1); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionToken), + // identifierOrCriteria: burnTokenId0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create the consideration we expect to be emitted in the event + // ConsiderationItem[] + // memory considerationFromEvent = new ConsiderationItem[](2); + // considerationFromEvent[0] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableToken), + // identifierOrCriteria: burnTokenId0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // considerationFromEvent[1] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableTokenTwo), + // identifierOrCriteria: burnTokenId0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + // assertEq(uint256(consideration[0].itemType), 4); + + // // Check that the consideration emitted in the event has itemType ERC721 + // assertEq(uint256(considerationFromEvent[0].itemType), 2); + // assertEq(uint256(considerationFromEvent[1].itemType), 2); + + // // Create the extraData to be passed into fulfillAdvancedOrder + // bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // // TODO: validate OrderFulfilled event + + // // Create the OrderParameters to be passed into fulfillAdvancedOrder + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(considerationFromEvent) + // .withOffer(offer) + // .withConduitKey(conduitKey) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems( + // considerationFromEvent.length + // ); + + // // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // // Call fulfillAdvancedOrder + // seaport.fulfillAdvancedOrder({ + // advancedOrder: order, + // criteriaResolvers: criteriaResolvers, + // fulfillerConduitKey: conduitKey, + // recipient: address(0) + // }); + + // // Check that one redeemableToken and one redeemableTokenTwo have been burned + // assertEq(redeemableToken.ownerOf(burnTokenId0), _BURN_ADDRESS); + // assertEq(redeemableTokenTwo.ownerOf(burnTokenId0), _BURN_ADDRESS); + + // // Check that the redemption token has been minted to the test contract + // assertEq(redemptionToken.ownerOf(burnTokenId0), address(this)); + // } + // } + + // // TODO: add multi-redeem file + + // function testBurn1Redeem2SeparateRedemptionTokensWithSeaport() public { + // // Set the tokenId to be redeemed + // uint256 redemptionTokenId = 2; + + // // Set the tokenId to be burned to the first tokenId to be redeemed + // uint256 redeemableTokenId = redemptionTokenId; + + // // Create a new ERC721RedemptionMintable redemptionTokenTwo + // ERC721RedemptionMintable redemptionTokenTwo = new ERC721RedemptionMintable( + // address(offerer), + // address(redeemableToken) + // ); + + // // Mint a redeemableToken of tokenId redeemableTokenId to the test contract + // redeemableToken.mint(address(this), redeemableTokenId); + + // // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + // redeemableToken.setApprovalForAll(address(conduit), true); + + // // Create a two-item OfferItem array with the one redemptionToken and one redemptionTokenTwo the caller will receive + // OfferItem[] memory offer = new OfferItem[](2); + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // offer[1] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionTokenTwo), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create a single-item ConsiderationItem array with the redeemableToken the caller will burn + // ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + // consideration[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Create the CampaignParams with the offer and consideration from above. + // { + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // // Call createCampaign on the offerer and pass in the CampaignParams + // offerer.createCampaign(params, ""); + // } + + // // uint256 campaignId = 1; + // // bytes32 redemptionHash = bytes32(0); + + // { + // // Create the offer we expect to be emitted in the event + // OfferItem[] memory offerFromEvent = new OfferItem[](2); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionToken), + // identifierOrCriteria: redemptionTokenId, + // startAmount: 1, + // endAmount: 1 + // }); + + // offerFromEvent[1] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionToken), + // identifierOrCriteria: redemptionTokenId, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create the consideration we expect to be emitted in the event + // ConsiderationItem[] + // memory considerationFromEvent = new ConsiderationItem[](1); + // considerationFromEvent[0] = ConsiderationItem({ + // itemType: ItemType.ERC721, + // token: address(redeemableToken), + // identifierOrCriteria: redeemableTokenId, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + // assertEq(uint256(consideration[0].itemType), 4); + + // // Check that the consideration emitted in the event has itemType ERC721 + // assertEq(uint256(considerationFromEvent[0].itemType), 2); + + // // Create the extraData to be passed into fulfillAdvancedOrder + // bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // // TODO: validate OrderFulfilled event + + // // Create the OrderParameters to be passed into fulfillAdvancedOrder + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(considerationFromEvent) + // .withOffer(offer) + // .withConduitKey(conduitKey) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems( + // considerationFromEvent.length + // ); + + // // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // // Call fulfillAdvancedOrder + // seaport.fulfillAdvancedOrder({ + // advancedOrder: order, + // criteriaResolvers: criteriaResolvers, + // fulfillerConduitKey: conduitKey, + // recipient: address(0) + // }); + + // // Check that the redeemableToken has been burned + // assertEq(redeemableToken.ownerOf(redeemableTokenId), _BURN_ADDRESS); + + // // Check that the two redemptionTokens has been minted to the test contract + // assertEq(redemptionToken.ownerOf(redemptionTokenId), address(this)); + // assertEq( + // redemptionTokenTwo.ownerOf(redemptionTokenId), + // address(this) + // ); + // } + // } + + // function xtestDynamicTraitRedemptionViaSeaport() public { + // // Set the tokenId to be redeemed + // uint256 redemptionTokenId0 = 2; + + // // Set the tokenId to be burned to the tokenId to be redeemed + // uint256 redeemableTokenId0 = redemptionTokenId0; + + // // Deploy the mock ERC721 with dynamic traits + // // Allow the contract offerer to set traits + // MockERC721DynamicTraits dynamicTraitsToken = new MockERC721DynamicTraits( + // address(offerer) + // ); + + // // Mint a dynamicTraitsToken of tokenId redeemableTokenId0 to the test contract + // dynamicTraitsToken.mint(address(this), redeemableTokenId0); + + // // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + // dynamicTraitsToken.setApprovalForAll(address(conduit), true); + + // // Create a single-item offer array with the redemptionToken the caller will receive + // OfferItem[] memory offer = new OfferItem[](1); + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Create an empty consideration array since the redeemable is a trait redemption + // ConsiderationItem[] memory consideration = new ConsiderationItem[](0); + + // // Create the CampaignParams with the offer and consideration from above. + // { + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // // Call createCampaign on the offerer and pass in the CampaignParams + // offerer.createCampaign(params, ""); + // } + + // // uint256 campaignId = 1; + // // bytes32 redemptionHash = bytes32(0); + + // { + // // Create the offer we expect to be emitted in the event + // OfferItem[] memory offerFromEvent = new OfferItem[](2); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721, + // token: address(redemptionToken), + // identifierOrCriteria: redemptionTokenId0, + // startAmount: 1, + // endAmount: 1 + // }); + + // // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + // assertEq(uint256(consideration[0].itemType), 4); + + // TraitRedemption memory traitRedemption = TraitRedemption({ + // substandard: 0, // set value to traitValue + // token: address(dynamicTraitsToken), + // identifier: redeemableTokenId0, + // traitKey: "isRedeemed", + // traitValue: bytes32(abi.encode(1)), + // substandardValue: bytes32(abi.encode(0)) + // }); + + // // Create the extraData to be passed into fulfillAdvancedOrder + // bytes memory extraData = abi.encode(1, bytes32(0), traitRedemption); // campaignId, redemptionHash + + // // TODO: validate OrderFulfilled event + + // // Create the OrderParameters to be passed into fulfillAdvancedOrder + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(consideration) + // .withOffer(offer) + // .withConduitKey(conduitKey) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems(consideration.length); + + // // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // // Call fulfillAdvancedOrder + // seaport.fulfillAdvancedOrder({ + // advancedOrder: order, + // criteriaResolvers: criteriaResolvers, + // fulfillerConduitKey: conduitKey, + // recipient: address(0) + // }); + + // // Check that the redeemableToken has been burned + // assertEq( + // dynamicTraitsToken.ownerOf(redeemableTokenId0), + // address(this) + // ); + + // // Check that the two redemptionTokens has been minted to the test contract + // assertEq( + // redemptionToken.ownerOf(redemptionTokenId0), + // address(this) + // ); + // } + // } + + // function xtestRedeemMultipleWithSeaport() public { + // uint256 tokenId; + // redeemableToken.setApprovalForAll(address(conduit), true); + + // AdvancedOrder[] memory orders = new AdvancedOrder[](5); + // OfferItem[] memory offer = new OfferItem[](1); + // ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + // offer[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // consideration[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // OrderParameters memory parameters = OrderParametersLib + // .empty() + // .withOfferer(address(offerer)) + // .withOrderType(OrderType.CONTRACT) + // .withConsideration(consideration) + // .withOffer(offer) + // .withStartTime(block.timestamp) + // .withEndTime(block.timestamp + 1) + // .withTotalOriginalConsiderationItems(1); + + // for (uint256 i; i < 5; i++) { + // tokenId = i; + // redeemableToken.mint(address(this), tokenId); + + // bytes memory extraData = abi.encode(campaignId, redemptionHash); + // AdvancedOrder memory order = AdvancedOrder({ + // parameters: parameters, + // numerator: 1, + // denominator: 1, + // signature: "", + // extraData: extraData + // }); + + // orders[i] = order; + // } + + // CampaignParams memory params = CampaignParams({ + // offer: offer, + // consideration: consideration, + // signer: address(0), + // startTime: uint32(block.timestamp), + // endTime: uint32(block.timestamp + 1000), + // maxCampaignRedemptions: 5, + // manager: address(this) + // }); + + // offerer.createCampaign(params, ""); + + // OfferItem[] memory offerFromEvent = new OfferItem[](1); + // offerFromEvent[0] = OfferItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redemptionToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1 + // }); + + // ConsiderationItem[] + // memory considerationFromEvent = new ConsiderationItem[](1); + // considerationFromEvent[0] = ConsiderationItem({ + // itemType: ItemType.ERC721_WITH_CRITERIA, + // token: address(redeemableToken), + // identifierOrCriteria: 0, + // startAmount: 1, + // endAmount: 1, + // recipient: payable(_BURN_ADDRESS) + // }); + + // ( + // FulfillmentComponent[][] memory offerFulfillmentComponents, + // FulfillmentComponent[][] memory considerationFulfillmentComponents + // ) = fulfill.getNaiveFulfillmentComponents(orders); + + // seaport.fulfillAvailableAdvancedOrders({ + // advancedOrders: orders, + // criteriaResolvers: criteriaResolvers, + // offerFulfillments: offerFulfillmentComponents, + // considerationFulfillments: considerationFulfillmentComponents, + // fulfillerConduitKey: conduitKey, + // recipient: address(0), + // maximumFulfilled: 10 + // }); + + // for (uint256 i; i < 5; i++) { + // tokenId = i; + // assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + // assertEq(redemptionToken.ownerOf(tokenId), address(this)); + // } + // } +} diff --git a/lib/redeemables/test/RedeemViaSeaport-721.t.sol b/lib/redeemables/test/RedeemViaSeaport-721.t.sol new file mode 100644 index 0000000..43636e9 --- /dev/null +++ b/lib/redeemables/test/RedeemViaSeaport-721.t.sol @@ -0,0 +1,1173 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Solarray} from "solarray/Solarray.sol"; +import {BaseOrderTest} from "./utils/BaseOrderTest.sol"; +import {TestERC20} from "./utils/mocks/TestERC20.sol"; +import {TestERC721} from "./utils/mocks/TestERC721.sol"; +import { + OfferItem, + ConsiderationItem, + SpentItem, + AdvancedOrder, + OrderParameters, + CriteriaResolver, + FulfillmentComponent +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {ItemType, OrderType, Side} from "seaport-sol/src/SeaportEnums.sol"; +import {MockERC721DynamicTraits} from "./utils/mocks/MockERC721DynamicTraits.sol"; +import {OfferItemLib, ConsiderationItemLib, OrderParametersLib} from "seaport-sol/src/SeaportSol.sol"; +import {RedeemableContractOfferer} from "../src/RedeemableContractOfferer.sol"; +import {CampaignParams, TraitRedemption} from "../src/lib/RedeemableStructs.sol"; +import {RedeemableErrorsAndEvents} from "../src/lib/RedeemableErrorsAndEvents.sol"; +import {ERC721RedemptionMintable} from "../src/lib/ERC721RedemptionMintable.sol"; +import {ERC721RedemptionMintableWithCounter} from "../src/lib/ERC721RedemptionMintableWithCounter.sol"; +import {Merkle} from "../lib/murky/src/Merkle.sol"; + +contract RedeemViaSeaport721 is BaseOrderTest, RedeemableErrorsAndEvents { + using OrderParametersLib for OrderParameters; + + error InvalidContractOrder(bytes32 orderHash); + + RedeemableContractOfferer offerer; + TestERC721 redeemableToken; + ERC721RedemptionMintable redemptionToken; + CriteriaResolver[] criteriaResolvers; + Merkle merkle = new Merkle(); + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function setUp() public override { + super.setUp(); + offerer = new RedeemableContractOfferer( + address(conduit), + conduitKey, + address(seaport) + ); + redeemableToken = new TestERC721(); + redemptionToken = new ERC721RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + vm.label(address(redeemableToken), "redeemableToken"); + vm.label(address(redemptionToken), "redemptionToken"); + } + + function testRedeemWithSeaport() public { + uint256 tokenId = 2; + redeemableToken.mint(address(this), tokenId); + redeemableToken.setApprovalForAll(address(conduit), true); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + consideration[0].identifierOrCriteria = tokenId; + + // TODO: validate OrderFulfilled event + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // { + // bytes + // vm.expectEmit(true, true, true, true); + // emit OrderFulfilled(); + // } + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + assertEq(redemptionToken.ownerOf(tokenId), address(this)); + } + } + + // TODO: write test with ETH redemption consideration + // TODO: 1155 tests with same tokenId (amount > 1), different tokenIds + // TODO: update erc20 amount to use decimals + + function testRedeemAndSendErc20ToThirdAddressViaSeaport() public { + uint256 tokenId = 2; + redeemableToken.mint(address(this), tokenId); + redeemableToken.setApprovalForAll(address(conduit), true); + + // Deploy the ERC20 + TestERC20 erc20 = new TestERC20(); + uint256 erc20Amount = 10; + + // Mint 100 tokens to the test contract + erc20.mint(address(this), 100); + + // Approve the conduit to spend tokens + erc20.approve(address(conduit), type(uint256).max); + + OfferItem[] memory campaignOffer = new OfferItem[](1); + campaignOffer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory campaignConsideration = new ConsiderationItem[](2); + campaignConsideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + campaignConsideration[1] = ConsiderationItem({ + itemType: ItemType.ERC20, + token: address(erc20), + identifierOrCriteria: 0, + startAmount: erc20Amount, + endAmount: erc20Amount, + recipient: payable(eve.addr) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: campaignOffer, + consideration: campaignConsideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(campaignConsideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + campaignConsideration[0].itemType = ItemType.ERC721; + campaignConsideration[0].identifierOrCriteria = tokenId; + + // TODO: validate OrderFulfilled event + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(campaignConsideration).withOffer(campaignOffer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(campaignConsideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + uint256 erc20BalanceBefore = erc20.balanceOf(address(this)); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + assertEq(redemptionToken.ownerOf(tokenId), address(this)); + assertEq(erc20BalanceBefore - erc20.balanceOf(address(this)), erc20Amount); + assertEq(erc20.balanceOf(eve.addr), erc20Amount); + } + } + + // TODO: add resolved tokenId to extradata + // TODO: fix redemptionToken being minted with merkle root + function testRedeemWithCriteriaResolversViaSeaport() public { + uint256 tokenId = 2; + redeemableToken.mint(address(this), tokenId); + redeemableToken.setApprovalForAll(address(conduit), true); + + ERC721RedemptionMintableWithCounter redemptionTokenWithCounter = new ERC721RedemptionMintableWithCounter( + address(offerer), + address(redeemableToken) + ); + + CriteriaResolver[] memory resolvers = new CriteriaResolver[](1); + + // Create an array of hashed identifiers (0-4) + // Only tokenIds 0-4 can be redeemed + bytes32[] memory hashedIdentifiers = new bytes32[](5); + for (uint256 i = 0; i < hashedIdentifiers.length; i++) { + hashedIdentifiers[i] = keccak256(abi.encode(i)); + } + bytes32 root = merkle.getRoot(hashedIdentifiers); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Contract offerer will only consider tokenIds 0-4 + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: uint256(root), + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(consideration).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + resolvers[0] = CriteriaResolver({ + orderIndex: 0, + side: Side.CONSIDERATION, + index: 0, + identifier: tokenId, + criteriaProof: merkle.getProof(hashedIdentifiers, 2) + }); + + // TODO: validate OrderFulfilled event + // vm.expectEmit(true, true, true, true); + // emit OrderFulfilled(); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: resolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // TODO: failing because redemptionToken tokenId is merkle root + assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + assertEq(redemptionTokenWithCounter.ownerOf(0), address(this)); + } + } + + // TODO: burn 1, send weth to third address, also redeem trait + // TODO: mock erc20 to third address or burn + // TODO: mock erc721 with tokenId counter + // TODO: make MockErc20RedemptionMintable with mintRedemption + // TODO: burn nft and send erc20 to third address, get nft and erc20 + // TODO: mintRedemption should return tokenIds array + // TODO: then add dynamic traits + // TODO: by EOW, have dynamic traits demo + + // notice: redemptionToken tokenId will be tokenId of first item in consideration + function testBurn2Redeem1ViaSeaport() public { + // Set the two tokenIds to be burned + uint256 burnTokenId0 = 2; + uint256 burnTokenId1 = 3; + + // Mint two redeemableTokens of tokenId burnTokenId0 and burnTokenId1 to the test contract + redeemableToken.mint(address(this), burnTokenId0); + redeemableToken.mint(address(this), burnTokenId1); + + // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + redeemableToken.setApprovalForAll(address(conduit), true); + + // Create a single-item OfferItem array with the redemption token the caller will receive + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Create a single-item ConsiderationItem array and require the caller to burn two redeemableTokens (of any tokenId) + ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + consideration[1] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Create the CampaignParams with the offer and consideration from above. + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + // Call createCampaign on the offerer and pass in the CampaignParams + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + // Create the offer we expect to be emitted in the event + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: burnTokenId0, + startAmount: 1, + endAmount: 1 + }); + + // Create the consideration we expect to be emitted in the event + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](2); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: burnTokenId0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + considerationFromEvent[1] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: burnTokenId1, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + assertEq(uint256(consideration[0].itemType), 4); + + // Check that the consideration emitted in the event has itemType ERC721 + assertEq(uint256(considerationFromEvent[0].itemType), 2); + assertEq(uint256(considerationFromEvent[1].itemType), 2); + + // Create the extraData to be passed into fulfillAdvancedOrder + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // TODO: validate OrderFulfilled event + + // Create the OrderParameters to be passed into fulfillAdvancedOrder + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(considerationFromEvent.length); + + // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // Call fulfillAdvancedOrder + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // Check that the two redeemable tokens have been burned + assertEq(redeemableToken.ownerOf(burnTokenId0), _BURN_ADDRESS); + assertEq(redeemableToken.ownerOf(burnTokenId1), _BURN_ADDRESS); + + // Check that the redemption token has been minted to the test contract + assertEq(redemptionToken.ownerOf(burnTokenId0), address(this)); + } + } + + function testBurn1Redeem2WithSeaport() public { + // Set the two tokenIds to be redeemed + uint256 redemptionTokenId0 = 0; + uint256 redemptionTokenId1 = 1; + + // Set the tokenId to be burned to the first tokenId to be redeemed + uint256 redeemableTokenId0 = redemptionTokenId0; + + ERC721RedemptionMintableWithCounter redemptionTokenWithCounter = new ERC721RedemptionMintableWithCounter( + address(offerer), + address(redeemableToken) + ); + + // Mint a redeemableToken of tokenId redeemableTokenId0 to the test contract + redeemableToken.mint(address(this), redeemableTokenId0); + + // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + redeemableToken.setApprovalForAll(address(conduit), true); + + // Create a two-item OfferItem array with the 2 redemptionTokens the caller will receive + OfferItem[] memory offer = new OfferItem[](2); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + offer[1] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Create a single-item ConsiderationItem array with the redeemableToken the caller will burn + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Create the CampaignParams with the offer and consideration from above. + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + // Call createCampaign on the offerer and pass in the CampaignParams + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + // Create the offer we expect to be emitted in the event + OfferItem[] memory offerFromEvent = new OfferItem[](2); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: redemptionTokenId0, + startAmount: 1, + endAmount: 1 + }); + + offerFromEvent[1] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionTokenWithCounter), + identifierOrCriteria: redemptionTokenId1, + startAmount: 1, + endAmount: 1 + }); + + // Create the consideration we expect to be emitted in the event + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: redeemableTokenId0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + assertEq(uint256(consideration[0].itemType), 4); + + // Check that the consideration emitted in the event has itemType ERC721 + assertEq(uint256(considerationFromEvent[0].itemType), 2); + + // Create the extraData to be passed into fulfillAdvancedOrder + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // TODO: validate OrderFulfilled event + + // Create the OrderParameters to be passed into fulfillAdvancedOrder + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(considerationFromEvent.length); + + // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // Call fulfillAdvancedOrder + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // Check that the redeemableToken has been burned + assertEq(redeemableToken.ownerOf(redeemableTokenId0), _BURN_ADDRESS); + + // Check that the two redemptionTokens has been minted to the test contract + assertEq(redemptionTokenWithCounter.ownerOf(redemptionTokenId0), address(this)); + assertEq(redemptionTokenWithCounter.ownerOf(redemptionTokenId1), address(this)); + } + } + + function testBurn2SeparateRedeemableTokensRedeem1ViaSeaport() public { + // Set the tokenId to be burned + uint256 burnTokenId0 = 2; + + // Create the second redeemableToken to be burned + TestERC721 redeemableTokenTwo = new TestERC721(); + + // Mint one redeemableToken ane one redeemableTokenTwo of tokenId burnTokenId0 to the test contract + redeemableToken.mint(address(this), burnTokenId0); + redeemableTokenTwo.mint(address(this), burnTokenId0); + + // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + redeemableToken.setApprovalForAll(address(conduit), true); + redeemableTokenTwo.setApprovalForAll(address(conduit), true); + + // Create a single-item OfferItem array with the redemption token the caller will receive + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Create a two-item ConsiderationItem array and require the caller to burn one redeemableToken and one redeemableTokenTwo (of any tokenId) + ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + consideration[1] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableTokenTwo), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Create the CampaignParams with the offer and consideration from above. + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + // Call createCampaign on the offerer and pass in the CampaignParams + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + // Create the offer we expect to be emitted in the event + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: burnTokenId0, + startAmount: 1, + endAmount: 1 + }); + + // Create the consideration we expect to be emitted in the event + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](2); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: burnTokenId0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + considerationFromEvent[1] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableTokenTwo), + identifierOrCriteria: burnTokenId0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + assertEq(uint256(consideration[0].itemType), 4); + + // Check that the consideration emitted in the event has itemType ERC721 + assertEq(uint256(considerationFromEvent[0].itemType), 2); + assertEq(uint256(considerationFromEvent[1].itemType), 2); + + // Create the extraData to be passed into fulfillAdvancedOrder + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // TODO: validate OrderFulfilled event + + // Create the OrderParameters to be passed into fulfillAdvancedOrder + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(considerationFromEvent.length); + + // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // Call fulfillAdvancedOrder + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // Check that one redeemableToken and one redeemableTokenTwo have been burned + assertEq(redeemableToken.ownerOf(burnTokenId0), _BURN_ADDRESS); + assertEq(redeemableTokenTwo.ownerOf(burnTokenId0), _BURN_ADDRESS); + + // Check that the redemption token has been minted to the test contract + assertEq(redemptionToken.ownerOf(burnTokenId0), address(this)); + } + } + + // TODO: add multi-redeem file + + function testBurn1Redeem2SeparateRedemptionTokensWithSeaport() public { + // Set the tokenId to be redeemed + uint256 redemptionTokenId = 2; + + // Set the tokenId to be burned to the first tokenId to be redeemed + uint256 redeemableTokenId = redemptionTokenId; + + // Create a new ERC721RedemptionMintable redemptionTokenTwo + ERC721RedemptionMintable redemptionTokenTwo = new ERC721RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + + // Mint a redeemableToken of tokenId redeemableTokenId to the test contract + redeemableToken.mint(address(this), redeemableTokenId); + + // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + redeemableToken.setApprovalForAll(address(conduit), true); + + // Create a two-item OfferItem array with the one redemptionToken and one redemptionTokenTwo the caller will receive + OfferItem[] memory offer = new OfferItem[](2); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + offer[1] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionTokenTwo), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Create a single-item ConsiderationItem array with the redeemableToken the caller will burn + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Create the CampaignParams with the offer and consideration from above. + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + // Call createCampaign on the offerer and pass in the CampaignParams + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + // Create the offer we expect to be emitted in the event + OfferItem[] memory offerFromEvent = new OfferItem[](2); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: redemptionTokenId, + startAmount: 1, + endAmount: 1 + }); + + offerFromEvent[1] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: redemptionTokenId, + startAmount: 1, + endAmount: 1 + }); + + // Create the consideration we expect to be emitted in the event + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: redeemableTokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + assertEq(uint256(consideration[0].itemType), 4); + + // Check that the consideration emitted in the event has itemType ERC721 + assertEq(uint256(considerationFromEvent[0].itemType), 2); + + // Create the extraData to be passed into fulfillAdvancedOrder + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + // TODO: validate OrderFulfilled event + + // Create the OrderParameters to be passed into fulfillAdvancedOrder + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(considerationFromEvent.length); + + // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // Call fulfillAdvancedOrder + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // Check that the redeemableToken has been burned + assertEq(redeemableToken.ownerOf(redeemableTokenId), _BURN_ADDRESS); + + // Check that the two redemptionTokens has been minted to the test contract + assertEq(redemptionToken.ownerOf(redemptionTokenId), address(this)); + assertEq(redemptionTokenTwo.ownerOf(redemptionTokenId), address(this)); + } + } + + function xtestDynamicTraitRedemptionViaSeaport() public { + // Set the tokenId to be redeemed + uint256 redemptionTokenId0 = 2; + + // Set the tokenId to be burned to the tokenId to be redeemed + uint256 redeemableTokenId0 = redemptionTokenId0; + + // Deploy the mock ERC721 with dynamic traits + // Allow the contract offerer to set traits + MockERC721DynamicTraits dynamicTraitsToken = new MockERC721DynamicTraits( + address(offerer) + ); + + // Mint a dynamicTraitsToken of tokenId redeemableTokenId0 to the test contract + dynamicTraitsToken.mint(address(this), redeemableTokenId0); + + // Approve the conduit to transfer the redeemableTokens on behalf of the test contract + dynamicTraitsToken.setApprovalForAll(address(conduit), true); + + // Create a single-item offer array with the redemptionToken the caller will receive + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Create an empty consideration array since the redeemable is a trait redemption + ConsiderationItem[] memory consideration = new ConsiderationItem[](0); + + // Create the CampaignParams with the offer and consideration from above. + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + // Call createCampaign on the offerer and pass in the CampaignParams + offerer.createCampaign(params, ""); + } + + // uint256 campaignId = 1; + // bytes32 redemptionHash = bytes32(0); + + { + // Create the offer we expect to be emitted in the event + OfferItem[] memory offerFromEvent = new OfferItem[](2); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: redemptionTokenId0, + startAmount: 1, + endAmount: 1 + }); + + // Check that the consideration passed into createCampaign has itemType ERC721_WITH_CRITERIA + assertEq(uint256(consideration[0].itemType), 4); + + TraitRedemption memory traitRedemption = TraitRedemption({ + substandard: 0, // set value to traitValue + token: address(dynamicTraitsToken), + identifier: redeemableTokenId0, + traitKey: "isRedeemed", + traitValue: bytes32(abi.encode(1)), + substandardValue: bytes32(abi.encode(0)) + }); + + // Create the extraData to be passed into fulfillAdvancedOrder + bytes memory extraData = abi.encode(1, bytes32(0), traitRedemption); // campaignId, redemptionHash + + // TODO: validate OrderFulfilled event + + // Create the OrderParameters to be passed into fulfillAdvancedOrder + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(consideration).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + + // Create the AdvancedOrder to be passed into fulfillAdvancedOrder + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + // Call fulfillAdvancedOrder + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + // Check that the redeemableToken has been burned + assertEq(dynamicTraitsToken.ownerOf(redeemableTokenId0), address(this)); + + // Check that the two redemptionTokens has been minted to the test contract + assertEq(redemptionToken.ownerOf(redemptionTokenId0), address(this)); + } + } + + function xtestRedeemMultipleWithSeaport() public { + uint256 tokenId; + redeemableToken.setApprovalForAll(address(conduit), true); + + AdvancedOrder[] memory orders = new AdvancedOrder[](5); + OfferItem[] memory offer = new OfferItem[](1); + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + + uint256 campaignId = 1; + bytes32 redemptionHash = bytes32(0); + + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(consideration).withOffer(offer).withStartTime(block.timestamp).withEndTime( + block.timestamp + 1 + ).withTotalOriginalConsiderationItems(1); + + for (uint256 i; i < 5; i++) { + tokenId = i; + redeemableToken.mint(address(this), tokenId); + + bytes memory extraData = abi.encode(campaignId, redemptionHash); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + orders[i] = order; + } + + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + ( + FulfillmentComponent[][] memory offerFulfillmentComponents, + FulfillmentComponent[][] memory considerationFulfillmentComponents + ) = fulfill.getNaiveFulfillmentComponents(orders); + + seaport.fulfillAvailableAdvancedOrders({ + advancedOrders: orders, + criteriaResolvers: criteriaResolvers, + offerFulfillments: offerFulfillmentComponents, + considerationFulfillments: considerationFulfillmentComponents, + fulfillerConduitKey: conduitKey, + recipient: address(0), + maximumFulfilled: 10 + }); + + for (uint256 i; i < 5; i++) { + tokenId = i; + assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + assertEq(redemptionToken.ownerOf(tokenId), address(this)); + } + } +} diff --git a/lib/redeemables/test/RedeemViaTransfer.t.sol b/lib/redeemables/test/RedeemViaTransfer.t.sol new file mode 100644 index 0000000..9155f66 --- /dev/null +++ b/lib/redeemables/test/RedeemViaTransfer.t.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Solarray} from "solarray/Solarray.sol"; +import {BaseOrderTest} from "./utils/BaseOrderTest.sol"; +import {TestERC20} from "./utils/mocks/TestERC20.sol"; +import {TestERC721} from "./utils/mocks/TestERC721.sol"; +import { + OfferItem, + ConsiderationItem, + SpentItem, + AdvancedOrder, + OrderParameters, + CriteriaResolver, + FulfillmentComponent +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +// import {CriteriaResolutionErrors} from "seaport-types/src/interfaces/CriteriaResolutionErrors.sol"; +import {ItemType, OrderType, Side} from "seaport-sol/src/SeaportEnums.sol"; +import {OfferItemLib, ConsiderationItemLib, OrderParametersLib} from "seaport-sol/src/SeaportSol.sol"; +import {RedeemableContractOfferer} from "../src/RedeemableContractOfferer.sol"; +import {CampaignParams} from "../src/lib/RedeemableStructs.sol"; +import {RedeemableErrorsAndEvents} from "../src/lib/RedeemableErrorsAndEvents.sol"; +import {ERC721RedemptionMintable} from "../src/lib/ERC721RedemptionMintable.sol"; +import {Merkle} from "../lib/murky/src/Merkle.sol"; + +contract TestRedeemableContractOfferer is BaseOrderTest, RedeemableErrorsAndEvents { + using OrderParametersLib for OrderParameters; + + error InvalidContractOrder(bytes32 orderHash); + + RedeemableContractOfferer offerer; + TestERC721 redeemableToken; + ERC721RedemptionMintable redemptionToken; + CriteriaResolver[] criteriaResolvers; + Merkle merkle = new Merkle(); + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function setUp() public override { + super.setUp(); + offerer = new RedeemableContractOfferer( + address(conduit), + conduitKey, + address(seaport) + ); + redeemableToken = new TestERC721(); + redemptionToken = new ERC721RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + vm.label(address(redeemableToken), "redeemableToken"); + vm.label(address(redemptionToken), "redemptionToken"); + } + + function testRedeemWith721SafeTransferFrom() public { + uint256 tokenId = 2; + redeemableToken.mint(address(this), tokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + uint256 campaignId = 1; + bytes32 redemptionHash = bytes32(0); + bytes memory extraData = abi.encode(campaignId, redemptionHash); + + // TODO: validate OrderFulfilled event + bytes memory data = abi.encode(campaignId, redemptionHash); + redeemableToken.safeTransferFrom(address(this), address(offerer), tokenId, extraData); + + assertEq(redeemableToken.ownerOf(tokenId), _BURN_ADDRESS); + assertEq(redemptionToken.ownerOf(tokenId), address(this)); + } +} diff --git a/lib/redeemables/test/Revert.t.sol b/lib/redeemables/test/Revert.t.sol new file mode 100644 index 0000000..60a736a --- /dev/null +++ b/lib/redeemables/test/Revert.t.sol @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Solarray} from "solarray/Solarray.sol"; +import {BaseOrderTest} from "./utils/BaseOrderTest.sol"; +import {TestERC20} from "./utils/mocks/TestERC20.sol"; +import {TestERC721} from "./utils/mocks/TestERC721.sol"; +import { + OfferItem, + ConsiderationItem, + SpentItem, + AdvancedOrder, + OrderParameters, + CriteriaResolver, + FulfillmentComponent +} from "seaport-types/src/lib/ConsiderationStructs.sol"; +// import {CriteriaResolutionErrors} from "seaport-types/src/interfaces/CriteriaResolutionErrors.sol"; +import {ItemType, OrderType, Side} from "seaport-sol/src/SeaportEnums.sol"; +import {OfferItemLib, ConsiderationItemLib, OrderParametersLib} from "seaport-sol/src/SeaportSol.sol"; +import {RedeemableContractOfferer} from "../src/RedeemableContractOfferer.sol"; +import {CampaignParams} from "../src/lib/RedeemableStructs.sol"; +import {RedeemableErrorsAndEvents} from "../src/lib/RedeemableErrorsAndEvents.sol"; +import {ERC721RedemptionMintable} from "../src/lib/ERC721RedemptionMintable.sol"; +import {Merkle} from "../lib/murky/src/Merkle.sol"; + +contract TestRedeemableContractOfferer is BaseOrderTest, RedeemableErrorsAndEvents { + using OrderParametersLib for OrderParameters; + + error InvalidContractOrder(bytes32 orderHash); + + RedeemableContractOfferer offerer; + TestERC721 redeemableToken; + ERC721RedemptionMintable redemptionToken; + CriteriaResolver[] criteriaResolvers; + Merkle merkle = new Merkle(); + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function setUp() public override { + super.setUp(); + offerer = new RedeemableContractOfferer( + address(conduit), + conduitKey, + address(seaport) + ); + redeemableToken = new TestERC721(); + redemptionToken = new ERC721RedemptionMintable( + address(offerer), + address(redeemableToken) + ); + vm.label(address(redeemableToken), "redeemableToken"); + vm.label(address(redemptionToken), "redemptionToken"); + } + + function testRevertRedeemWithCriteriaResolversViaSeaport() public { + uint256 tokenId = 7; + redeemableToken.mint(address(this), tokenId); + redeemableToken.setApprovalForAll(address(conduit), true); + + CriteriaResolver[] memory resolvers = new CriteriaResolver[](1); + + // Create an array of hashed identifiers (0-4) + // Get the merkle root of the hashed identifiers to pass into updateCampaign + // Only tokenIds 0-4 can be redeemed + bytes32[] memory hashedIdentifiers = new bytes32[](5); + for (uint256 i = 0; i < hashedIdentifiers.length; i++) { + hashedIdentifiers[i] = keccak256(abi.encode(i)); + } + bytes32 root = merkle.getRoot(hashedIdentifiers); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + // Contract offerer will only consider tokenIds 0-4 + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: uint256(root), + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + { + // Hash identifiers 5 - 9 and create invalid merkle root + // to pass into consideration + for (uint256 i = 0; i < hashedIdentifiers.length; i++) { + hashedIdentifiers[i] = keccak256(abi.encode(i + 5)); + } + root = merkle.getRoot(hashedIdentifiers); + consideration[0].identifierOrCriteria = uint256(root); + + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(consideration).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + resolvers[0] = CriteriaResolver({ + orderIndex: 0, + side: Side.CONSIDERATION, + index: 0, + identifier: tokenId, + criteriaProof: merkle.getProof(hashedIdentifiers, 2) + }); + + vm.expectRevert( + abi.encodeWithSelector( + InvalidContractOrder.selector, + (uint256(uint160(address(offerer))) << 96) + seaport.getContractOffererNonce(address(offerer)) + ) + ); + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: resolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + } + } + + function testRevertmaxCampaignRedemptionsReached() public { + redeemableToken.mint(address(this), 0); + redeemableToken.mint(address(this), 1); + redeemableToken.mint(address(this), 2); + redeemableToken.setApprovalForAll(address(conduit), true); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 2, + manager: address(this) + }); + + offerer.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(redemptionToken), + identifierOrCriteria: 1, + startAmount: 1, + endAmount: 1 + }); + + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemableToken), + identifierOrCriteria: 1, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + bytes memory extraData = abi.encode(1, bytes32(0)); // campaignId, redemptionHash + + considerationFromEvent[0].identifierOrCriteria = 0; + + OrderParameters memory parameters = OrderParametersLib.empty().withOfferer(address(offerer)).withOrderType( + OrderType.CONTRACT + ).withConsideration(considerationFromEvent).withOffer(offer).withConduitKey(conduitKey).withStartTime( + block.timestamp + ).withEndTime(block.timestamp + 1).withTotalOriginalConsiderationItems(consideration.length); + AdvancedOrder memory order = AdvancedOrder({ + parameters: parameters, + numerator: 1, + denominator: 1, + signature: "", + extraData: extraData + }); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + considerationFromEvent[0].identifierOrCriteria = 1; + + // vm.expectEmit(true, true, true, true); + // emit Or( + // address(this), + // campaignId, + // ConsiderationItemLib.toSpentItemArray(considerationFromEvent), + // OfferItemLib.toSpentItemArray(offerFromEvent), + // redemptionHash + // ); + + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + considerationFromEvent[0].identifierOrCriteria = 2; + + // Should revert on the third redemption + // The call to Seaport should revert with maxCampaignRedemptionsReached(3, 2) + // vm.expectRevert( + // abi.encodeWithSelector( + // maxCampaignRedemptionsReached.selector, + // 3, + // 2 + // ) + // ); + vm.expectRevert( + abi.encodeWithSelector( + InvalidContractOrder.selector, + (uint256(uint160(address(offerer))) << 96) + seaport.getContractOffererNonce(address(offerer)) + ) + ); + seaport.fulfillAdvancedOrder({ + advancedOrder: order, + criteriaResolvers: criteriaResolvers, + fulfillerConduitKey: conduitKey, + recipient: address(0) + }); + + assertEq(redeemableToken.ownerOf(0), _BURN_ADDRESS); + assertEq(redeemableToken.ownerOf(1), _BURN_ADDRESS); + assertEq(redemptionToken.ownerOf(0), address(this)); + assertEq(redemptionToken.ownerOf(1), address(this)); + } + } + + function testRevertConsiderationItemRecipientCannotBeZeroAddress() public { + uint256 tokenId = 2; + redeemableToken.mint(address(this), tokenId); + redeemableToken.setApprovalForAll(address(conduit), true); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redemptionToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemableToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(address(0)) + }); + + { + CampaignParams memory params = CampaignParams({ + offer: offer, + consideration: consideration, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + vm.expectRevert(abi.encodeWithSelector(ConsiderationItemRecipientCannotBeZeroAddress.selector)); + offerer.createCampaign(params, ""); + } + } +} diff --git a/lib/redeemables/test/utils/ArithmeticUtil.sol b/lib/redeemables/test/utils/ArithmeticUtil.sol new file mode 100644 index 0000000..c8d4372 --- /dev/null +++ b/lib/redeemables/test/utils/ArithmeticUtil.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +library ArithmeticUtil { + ///@dev utility function to avoid overflows when multiplying fuzzed uints with widths <256 + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + return a * b; + } + + ///@dev utility function to avoid overflows when adding fuzzed uints with widths <256 + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } + + ///@dev utility function to avoid overflows when subtracting fuzzed uints with widths <256 + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + return a - b; + } + + ///@dev utility function to avoid overflows when dividing fuzzed uints with widths <256 + function div(uint256 a, uint256 b) internal pure returns (uint256) { + return a / b; + } +} diff --git a/lib/redeemables/test/utils/BaseOrderTest.sol b/lib/redeemables/test/utils/BaseOrderTest.sol new file mode 100644 index 0000000..f038da8 --- /dev/null +++ b/lib/redeemables/test/utils/BaseOrderTest.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {LibString} from "solady/src/utils/LibString.sol"; +import {FulfillAvailableHelper} from "seaport-sol/src/fulfillments/available/FulfillAvailableHelper.sol"; +import {MatchFulfillmentHelper} from "seaport-sol/src/fulfillments/match/MatchFulfillmentHelper.sol"; +import { + AdvancedOrderLib, + ConsiderationItemLib, + FulfillmentComponentLib, + FulfillmentLib, + OfferItemLib, + OrderComponentsLib, + OrderLib, + OrderParametersLib, + SeaportArrays +} from "seaport-sol/src/SeaportSol.sol"; +import { + AdvancedOrder, + ConsiderationItem, + Fulfillment, + FulfillmentComponent, + OfferItem, + Order, + OrderComponents, + OrderParameters +} from "seaport-sol/src/SeaportStructs.sol"; +import {ItemType, OrderType} from "seaport-sol/src/SeaportEnums.sol"; +import {SeaportInterface} from "seaport-sol/src/SeaportInterface.sol"; +import {AmountDeriver} from "seaport-core/src/lib/AmountDeriver.sol"; +import {BaseSeaportTest} from "./BaseSeaportTest.sol"; +import {ArithmeticUtil} from "./ArithmeticUtil.sol"; +import {ERC1155Recipient} from "./mocks/ERC1155Recipient.sol"; +import {ERC721Recipient} from "./mocks/ERC721Recipient.sol"; +import {TestERC20} from "./mocks/TestERC20.sol"; +import {TestERC721} from "./mocks/TestERC721.sol"; +import {TestERC1155} from "./mocks/TestERC1155.sol"; + +/** + * @dev This is a base test class for cases that depend on pre-deployed token + * contracts. Note that it is different from the BaseOrderTest in the + * legacy test suite. + */ +contract BaseOrderTest is BaseSeaportTest, AmountDeriver, ERC721Recipient, ERC1155Recipient { + using ArithmeticUtil for *; + + using AdvancedOrderLib for AdvancedOrder; + using AdvancedOrderLib for AdvancedOrder[]; + using ConsiderationItemLib for ConsiderationItem; + using ConsiderationItemLib for ConsiderationItem[]; + using FulfillmentComponentLib for FulfillmentComponent; + using FulfillmentComponentLib for FulfillmentComponent[]; + using FulfillmentLib for Fulfillment; + using FulfillmentLib for Fulfillment[]; + using OfferItemLib for OfferItem; + using OfferItemLib for OfferItem[]; + using OrderComponentsLib for OrderComponents; + using OrderLib for Order; + using OrderLib for Order[]; + using OrderParametersLib for OrderParameters; + + event Transfer(address indexed from, address indexed to, uint256 value); + event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value); + + struct Context { + SeaportInterface seaport; + } + + // SeaportValidatorHelper validatorHelper; + // SeaportValidator validator; + FulfillAvailableHelper fulfill; + MatchFulfillmentHelper matcher; + + Account offerer1; + Account offerer2; + + Account dillon; + Account eve; + Account frank; + + TestERC20[] erc20s; + TestERC721[] erc721s; + TestERC1155[] erc1155s; + + // ExpectedBalances public balanceChecker; + + address[] preapprovals; + + string constant SINGLE_ERC721 = "single erc721"; + string constant STANDARD = "standard"; + string constant STANDARD_CONDUIT = "standard conduit"; + string constant FULL = "full"; + string constant FIRST_FIRST = "first first"; + string constant FIRST_SECOND = "first second"; + string constant SECOND_FIRST = "second first"; + string constant SECOND_SECOND = "second second"; + string constant FF_SF = "ff to sf"; + string constant SF_FF = "sf to ff"; + + function setUp() public virtual override { + super.setUp(); + + // balanceChecker = new ExpectedBalances(); + + // TODO: push to 24 if performance allows + // criteriaResolverHelper = new CriteriaResolverHelper(6); + + preapprovals = [address(seaport), address(conduit)]; + + _deployTestTokenContracts(); + + offerer1 = makeAndAllocateAccount("alice"); + offerer2 = makeAndAllocateAccount("bob"); + + dillon = makeAndAllocateAccount("dillon"); + eve = makeAndAllocateAccount("eve"); + frank = makeAndAllocateAccount("frank"); + + // allocate funds and tokens to test addresses + allocateTokensAndApprovals(address(this), type(uint128).max); + + _configureStructDefaults(); + + fulfill = new FulfillAvailableHelper(); + matcher = new MatchFulfillmentHelper(); + } + + /** + * @dev Creates a set of globally available default structs for use in + * tests. + */ + function _configureStructDefaults() internal { + OfferItemLib.empty().withItemType(ItemType.ERC721).withStartAmount(1).withEndAmount(1).saveDefault( + SINGLE_ERC721 + ); + ConsiderationItemLib.empty().withItemType(ItemType.ERC721).withStartAmount(1).withEndAmount(1).saveDefault( + SINGLE_ERC721 + ); + + OrderComponentsLib.empty().withOrderType(OrderType.FULL_OPEN).withStartTime(block.timestamp).withEndTime( + block.timestamp + 100 + ).saveDefault(STANDARD); + + OrderComponentsLib.fromDefault(STANDARD).withConduitKey(conduitKey).saveDefault(STANDARD_CONDUIT); + + AdvancedOrderLib.empty().withNumerator(1).withDenominator(1).saveDefault(FULL); + + FulfillmentComponentLib.empty().withOrderIndex(0).withItemIndex(0).saveDefault(FIRST_FIRST); + FulfillmentComponentLib.empty().withOrderIndex(0).withItemIndex(1).saveDefault(FIRST_SECOND); + FulfillmentComponentLib.empty().withOrderIndex(1).withItemIndex(0).saveDefault(SECOND_FIRST); + FulfillmentComponentLib.empty().withOrderIndex(1).withItemIndex(1).saveDefault(SECOND_SECOND); + + SeaportArrays.FulfillmentComponents(FulfillmentComponentLib.fromDefault(FIRST_FIRST)).saveDefaultMany( + FIRST_FIRST + ); + SeaportArrays.FulfillmentComponents(FulfillmentComponentLib.fromDefault(FIRST_SECOND)).saveDefaultMany( + FIRST_SECOND + ); + SeaportArrays.FulfillmentComponents(FulfillmentComponentLib.fromDefault(SECOND_FIRST)).saveDefaultMany( + SECOND_FIRST + ); + SeaportArrays.FulfillmentComponents(FulfillmentComponentLib.fromDefault(SECOND_SECOND)).saveDefaultMany( + SECOND_SECOND + ); + + FulfillmentLib.empty().withOfferComponents(FulfillmentComponentLib.fromDefaultMany(SECOND_FIRST)) + .withConsiderationComponents(FulfillmentComponentLib.fromDefaultMany(FIRST_FIRST)).saveDefault(SF_FF); + FulfillmentLib.empty().withOfferComponents(FulfillmentComponentLib.fromDefaultMany(FIRST_FIRST)) + .withConsiderationComponents(FulfillmentComponentLib.fromDefaultMany(SECOND_FIRST)).saveDefault(FF_SF); + } + + function test(function(Context memory) external fn, Context memory context) internal { + try fn(context) { + fail("Differential test should have reverted with failure status"); + } catch (bytes memory reason) { + assertPass(reason); + } + } + + /** + * @dev Wrapper for forge-std's makeAccount that has public visibility + * instead of internal visibility, so that we can access it in + * libraries. + */ + function makeAccountWrapper(string memory name) public returns (Account memory) { + return makeAccount(name); + } + + /** + * @dev Convenience wrapper for makeAddrAndKey that also allocates tokens, + * ether, and approvals. + */ + function makeAndAllocateAccount(string memory name) internal returns (Account memory) { + Account memory account = makeAccountWrapper(name); + allocateTokensAndApprovals(account.addr, type(uint128).max); + return account; + } + + /** + * @dev Sets up a new address and sets up token approvals for it. + */ + function makeAddrWithAllocationsAndApprovals(string memory label) internal returns (address) { + address addr = makeAddr(label); + allocateTokensAndApprovals(addr, type(uint128).max); + return addr; + } + + /** + * @dev Deploy test token contracts. + */ + function _deployTestTokenContracts() internal { + for (uint256 i; i < 3; i++) { + createErc20Token(); + createErc721Token(); + createErc1155Token(); + } + // preapproved721 = new PreapprovedERC721(preapprovals); + } + + /** + * @dev Creates a new ERC20 token contract and stores it in the erc20s + * array. + */ + function createErc20Token() internal returns (uint256 i) { + i = erc20s.length; + TestERC20 token = new TestERC20(); + erc20s.push(token); + vm.label(address(token), string.concat("ERC20", LibString.toString(i))); + } + + /** + * @dev Creates a new ERC721 token contract and stores it in the erc721s + * array. + */ + function createErc721Token() internal returns (uint256 i) { + i = erc721s.length; + TestERC721 token = new TestERC721(); + erc721s.push(token); + vm.label(address(token), string.concat("ERC721", LibString.toString(i))); + } + + /** + * @dev Creates a new ERC1155 token contract and stores it in the erc1155s + * array. + */ + function createErc1155Token() internal returns (uint256 i) { + i = erc1155s.length; + TestERC1155 token = new TestERC1155(); + erc1155s.push(token); + vm.label(address(token), string.concat("ERC1155", LibString.toString(i))); + } + + /** + * @dev Allocate amount of ether and each erc20 token; set approvals for all + * tokens. + */ + function allocateTokensAndApprovals(address _to, uint128 _amount) public { + vm.deal(_to, _amount); + for (uint256 i = 0; i < erc20s.length; ++i) { + erc20s[i].mint(_to, _amount); + } + _setApprovals(_to); + } + + /** + * @dev Set approvals for all tokens. + * + * @param _owner The address to set approvals for. + */ + function _setApprovals(address _owner) internal virtual { + vm.startPrank(_owner); + for (uint256 i = 0; i < erc20s.length; ++i) { + erc20s[i].approve(address(seaport), type(uint256).max); + erc20s[i].approve(address(conduit), type(uint256).max); + } + for (uint256 i = 0; i < erc721s.length; ++i) { + erc721s[i].setApprovalForAll(address(seaport), true); + erc721s[i].setApprovalForAll(address(conduit), true); + } + for (uint256 i = 0; i < erc1155s.length; ++i) { + erc1155s[i].setApprovalForAll(address(seaport), true); + erc1155s[i].setApprovalForAll(address(conduit), true); + } + + vm.stopPrank(); + } + + receive() external payable virtual {} +} diff --git a/lib/redeemables/test/utils/BaseSeaportTest.sol b/lib/redeemables/test/utils/BaseSeaportTest.sol new file mode 100644 index 0000000..5fe8313 --- /dev/null +++ b/lib/redeemables/test/utils/BaseSeaportTest.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {stdStorage, StdStorage} from "forge-std/Test.sol"; +import {DifferentialTest} from "./DifferentialTest.sol"; +import {ConduitControllerInterface} from "seaport-sol/src/ConduitControllerInterface.sol"; +import {ConduitController} from "seaport-core/src/conduit/ConduitController.sol"; +import {ConsiderationInterface} from "seaport-types/src/interfaces/ConsiderationInterface.sol"; +import {Consideration} from "seaport-core/src/lib/Consideration.sol"; +import {Conduit} from "seaport-core/src/conduit/Conduit.sol"; + +/// @dev Base test case that deploys Consideration and its dependencies. +contract BaseSeaportTest is DifferentialTest { + using stdStorage for StdStorage; + + bool coverage_or_debug; + bytes32 conduitKey; + + Conduit conduit; + Conduit referenceConduit; + ConduitControllerInterface conduitController; + ConsiderationInterface seaport; + + function stringEq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)); + } + + function debugEnabled() internal returns (bool) { + return vm.envOr("SEAPORT_COVERAGE", false) || debugProfileEnabled(); + } + + function debugProfileEnabled() internal returns (bool) { + string memory env = vm.envOr("FOUNDRY_PROFILE", string("")); + return stringEq(env, "debug") || stringEq(env, "moat_debug"); + } + + function setUp() public virtual { + // Conditionally deploy contracts normally or from precompiled source + // deploys normally when SEAPORT_COVERAGE is true for coverage analysis + // or when FOUNDRY_PROFILE is "debug" for debugging with source maps + // deploys from precompiled source when both are false. + coverage_or_debug = debugEnabled(); + + conduitKey = bytes32(uint256(uint160(address(this))) << 96); + _deployAndConfigurePrecompiledOptimizedConsideration(); + + vm.label(address(conduitController), "conduitController"); + vm.label(address(seaport), "seaport"); + vm.label(address(conduit), "conduit"); + vm.label(address(this), "testContract"); + } + + /** + * @dev Get the configured preferred Seaport + */ + function getSeaport() internal view returns (ConsiderationInterface seaport_) { + seaport_ = seaport; + } + + /** + * @dev Get the configured preferred ConduitController + */ + function getConduitController() internal view returns (ConduitControllerInterface conduitController_) { + conduitController_ = conduitController; + } + + ///@dev deploy optimized consideration contracts from pre-compiled source + // (solc-0.8.19, IR pipeline enabled, unless running coverage or debug) + function _deployAndConfigurePrecompiledOptimizedConsideration() public { + conduitController = new ConduitController(); + seaport = new Consideration(address(conduitController)); + + //create conduit, update channel + conduit = Conduit(conduitController.createConduit(conduitKey, address(this))); + conduitController.updateChannel(address(conduit), address(seaport), true); + } + + function signOrder(ConsiderationInterface _consideration, uint256 _pkOfSigner, bytes32 _orderHash) + internal + view + returns (bytes memory) + { + (bytes32 r, bytes32 s, uint8 v) = getSignatureComponents(_consideration, _pkOfSigner, _orderHash); + return abi.encodePacked(r, s, v); + } + + function getSignatureComponents(ConsiderationInterface _consideration, uint256 _pkOfSigner, bytes32 _orderHash) + internal + view + returns (bytes32, bytes32, uint8) + { + (, bytes32 domainSeparator,) = _consideration.information(); + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(_pkOfSigner, keccak256(abi.encodePacked(bytes2(0x1901), domainSeparator, _orderHash))); + return (r, s, v); + } +} diff --git a/lib/redeemables/test/utils/DifferentialTest.sol b/lib/redeemables/test/utils/DifferentialTest.sol new file mode 100644 index 0000000..8de88cf --- /dev/null +++ b/lib/redeemables/test/utils/DifferentialTest.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; + +contract DifferentialTest is Test { + ///@dev error to supply + error RevertWithFailureStatus(bool status); + error DifferentialTestAssertionFailed(); + + // slot where HEVM stores a bool representing whether or not an assertion has failed + bytes32 HEVM_FAILED_SLOT = bytes32("failed"); + + // hash of the bytes surfaced by `revert RevertWithFailureStatus(false)` + bytes32 PASSING_HASH = keccak256(abi.encodeWithSelector(RevertWithFailureStatus.selector, false)); + + ///@dev reverts after function body with HEVM failure status, which clears all state changes + /// but still surfaces assertion failure status. + modifier stateless() { + _; + revert RevertWithFailureStatus(readHevmFailureSlot()); + } + + ///@dev revert if the supplied bytes do not match the expected "passing" revert bytes + function assertPass(bytes memory reason) internal view { + // hash the reason and compare to the hash of the passing revert bytes + if (keccak256(reason) != PASSING_HASH) { + revert DifferentialTestAssertionFailed(); + } + } + + ///@dev read the failure slot of the HEVM using the vm.load cheatcode + /// Returns true if there was an assertion failure. recorded. + function readHevmFailureSlot() internal view returns (bool) { + return vm.load(address(vm), HEVM_FAILED_SLOT) == bytes32(uint256(1)); + } +} diff --git a/lib/redeemables/test/utils/mocks/ERC1155Recipient.sol b/lib/redeemables/test/utils/mocks/ERC1155Recipient.sol new file mode 100644 index 0000000..593927a --- /dev/null +++ b/lib/redeemables/test/utils/mocks/ERC1155Recipient.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.19; + +import {IERC1155Receiver} from "../../../src/interfaces/IERC1155Receiver.sol"; + +contract ERC1155Recipient is IERC1155Receiver { + function onERC1155Received(address, address, uint256, uint256, bytes calldata) + public + virtual + override + returns (bytes4) + { + return IERC1155Receiver.onERC1155Received.selector; + } + + function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata) + external + virtual + override + returns (bytes4) + { + return IERC1155Receiver.onERC1155BatchReceived.selector; + } +} diff --git a/lib/redeemables/test/utils/mocks/ERC721Recipient.sol b/lib/redeemables/test/utils/mocks/ERC721Recipient.sol new file mode 100644 index 0000000..363e61f --- /dev/null +++ b/lib/redeemables/test/utils/mocks/ERC721Recipient.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.19; + +import {IERC721Receiver} from "seaport-types/src/interfaces/IERC721Receiver.sol"; + +contract ERC721Recipient is IERC721Receiver { + function onERC721Received(address, address, uint256, bytes calldata) public virtual override returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } +} diff --git a/lib/redeemables/test/utils/mocks/MockERC1271Wallet.sol b/lib/redeemables/test/utils/mocks/MockERC1271Wallet.sol new file mode 100644 index 0000000..044b909 --- /dev/null +++ b/lib/redeemables/test/utils/mocks/MockERC1271Wallet.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "solady/src/utils/ECDSA.sol"; + +contract MockERC1271Wallet { + address signer; + + constructor(address signer_) { + signer = signer_; + } + + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + return ECDSA.recover(hash, signature) == signer ? bytes4(0x1626ba7e) : bytes4(0); + } +} diff --git a/lib/redeemables/test/utils/mocks/MockERC721DynamicTraits.sol b/lib/redeemables/test/utils/mocks/MockERC721DynamicTraits.sol new file mode 100644 index 0000000..ca96ebe --- /dev/null +++ b/lib/redeemables/test/utils/mocks/MockERC721DynamicTraits.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.19; + +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {IERC7XXX} from "../../../src/interfaces/IERC7XXX.sol"; + +contract MockERC721DynamicTraits is ERC721, IERC7XXX { + error InvalidCaller(); + + // The manager account that can set traits + address public _manager; + + // The dynamic traits + mapping(bytes32 traitKey => mapping(uint256 tokenId => bytes32 value)) public traits; + + // Set the manager at construction + constructor(address manager) { + _manager = manager; + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + function tokenURI(uint256) public pure override returns (string memory) { + return "tokenURI"; + } + + function name() public view virtual override returns (string memory) { + return "TestERC721"; + } + + function symbol() public view virtual override returns (string memory) { + return "TST721"; + } + + function getTrait(bytes32 traitKey, uint256 tokenId) public view returns (bytes32) { + return traits[traitKey][tokenId]; + } + + function getTraits(bytes32 traitKey, uint256[] calldata tokenIds) public view returns (bytes32[] memory) { + bytes32[] memory values = new bytes32[](tokenIds.length); + for (uint256 i = 0; i < tokenIds.length; i++) { + values[i] = traits[traitKey][tokenIds[i]]; + } + return values; + } + + function setTrait(uint256 tokenId, bytes32 traitKey, bytes32 value) public { + if (msg.sender != _manager) { + revert InvalidCaller(); + } + traits[traitKey][tokenId] = value; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, IERC7XXX) returns (bool) { + return interfaceId == type(IERC7XXX).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/lib/redeemables/test/utils/mocks/TestERC1155.sol b/lib/redeemables/test/utils/mocks/TestERC1155.sol new file mode 100644 index 0000000..7eb4e86 --- /dev/null +++ b/lib/redeemables/test/utils/mocks/TestERC1155.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.19; + +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; + +// Used for minting test ERC1155s in our tests +contract TestERC1155 is ERC1155 { + function mint(address to, uint256 tokenId, uint256 amount) public returns (bool) { + _mint(to, tokenId, amount, ""); + return true; + } + + function uri(uint256) public pure override returns (string memory) { + return "uri"; + } +} diff --git a/lib/redeemables/test/utils/mocks/TestERC20.sol b/lib/redeemables/test/utils/mocks/TestERC20.sol new file mode 100644 index 0000000..bb00468 --- /dev/null +++ b/lib/redeemables/test/utils/mocks/TestERC20.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.19; + +import {ERC20} from "solady/src/tokens/ERC20.sol"; + +// Used for minting test ERC20s in our tests +contract TestERC20 is ERC20 { + bool public blocked; + + bool public noReturnData; + + constructor() { + blocked = false; + noReturnData = false; + } + + function blockTransfer(bool blocking) external { + blocked = blocking; + } + + function setNoReturnData(bool noReturn) external { + noReturnData = noReturn; + } + + function mint(address to, uint256 amount) external returns (bool) { + _mint(to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool ok) { + if (blocked) { + return false; + } + + uint256 allowed = allowance(from, msg.sender); + + if (amount > allowed) { + revert("NOT_AUTHORIZED"); + } + + super.transferFrom(from, to, amount); + + if (noReturnData) { + assembly { + return(0, 0) + } + } + + ok = true; + } + + function name() public view virtual override returns (string memory) { + return "TestERC20"; + } + + function symbol() public view virtual override returns (string memory) { + return "TST20"; + } +} diff --git a/lib/redeemables/test/utils/mocks/TestERC721.sol b/lib/redeemables/test/utils/mocks/TestERC721.sol new file mode 100644 index 0000000..50c5865 --- /dev/null +++ b/lib/redeemables/test/utils/mocks/TestERC721.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.19; + +import {ERC721} from "solady/src/tokens/ERC721.sol"; + +// Used for minting test ERC721s in our tests +contract TestERC721 is ERC721 { + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + function tokenURI(uint256) public pure override returns (string memory) { + return "tokenURI"; + } + + function name() public view virtual override returns (string memory) { + return "TestERC721"; + } + + function symbol() public view virtual override returns (string memory) { + return "TST721"; + } +} diff --git a/src/IOwnable.sol b/src/IOwnable.sol new file mode 100644 index 0000000..4fa4f01 --- /dev/null +++ b/src/IOwnable.sol @@ -0,0 +1,6 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IOwnable { + function owner() external view returns (address); +} diff --git a/src/TwoStepAdministered.sol b/src/TwoStepAdministered.sol new file mode 100644 index 0000000..873021e --- /dev/null +++ b/src/TwoStepAdministered.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {IOwnable} from "./IOwnable.sol"; + +abstract contract TwoStepAdministered is IOwnable { + event AdministratorUpdated(address indexed previousAdministrator, address indexed newAdministrator); + event PotentialAdministratorUpdated(address newPotentialAdministrator); + + error OnlyAdministrator(); + error OnlyOwnerOrAdministrator(); + error NotNextAdministrator(); + error AlreadyInitialized(); + error NewAdministratorIsZeroAddress(); + + address public administrator; + address public potentialAdministrator; + + modifier onlyAdministrator() virtual { + if (msg.sender != administrator) { + revert OnlyAdministrator(); + } + + _; + } + + modifier onlyOwnerOrAdministrator() virtual { + if (msg.sender != owner()) { + if (msg.sender != administrator) { + revert OnlyOwnerOrAdministrator(); + } + } + _; + } + + modifier onlyConstructor() { + if (address(this).code.length > 0) { + revert AlreadyInitialized(); + } + _; + } + + constructor(address _administrator) { + _initialize(_administrator); + } + + function _initialize(address _administrator) private onlyConstructor { + administrator = _administrator; + emit AdministratorUpdated(address(0), _administrator); + } + + function transferAdministration(address newAdministrator) public virtual onlyAdministrator { + if (newAdministrator == address(0)) { + revert NewAdministratorIsZeroAddress(); + } + potentialAdministrator = newAdministrator; + emit PotentialAdministratorUpdated(newAdministrator); + } + + function _transferAdministration(address newAdministrator) internal virtual { + administrator = newAdministrator; + + emit AdministratorUpdated(msg.sender, newAdministrator); + } + + ///@notice Acept administration of smart contract, after the current administrator has initiated the process with transferAdministration + function acceptAdministration() public virtual { + address _potentialAdministrator = potentialAdministrator; + if (msg.sender != _potentialAdministrator) { + revert NotNextAdministrator(); + } + _transferAdministration(_potentialAdministrator); + delete potentialAdministrator; + } + + ///@notice cancel administration transfer + function cancelAdministrationTransfer() public virtual onlyAdministrator { + delete potentialAdministrator; + emit PotentialAdministratorUpdated(address(0)); + } + + function renounceAdministration() public virtual onlyAdministrator { + delete administrator; + emit AdministratorUpdated(msg.sender, address(0)); + } + + function owner() public view virtual returns (address); +} diff --git a/src/dynamic-traits/ERC721DynamicTraits.sol b/src/dynamic-traits/AbstractDynamicTraits.sol similarity index 59% rename from src/dynamic-traits/ERC721DynamicTraits.sol rename to src/dynamic-traits/AbstractDynamicTraits.sol index bd8988b..a62ea6c 100644 --- a/src/dynamic-traits/ERC721DynamicTraits.sol +++ b/src/dynamic-traits/AbstractDynamicTraits.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; -import {ERC721} from "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; -import {Ownable} from "openzeppelin-contracts/access/Ownable.sol"; +import {TwoStepOwnable} from "utility-contracts/TwoStepOwnable.sol"; import {DynamicTraits} from "./DynamicTraits.sol"; -contract ERC721DynamicTraits is DynamicTraits, Ownable, ERC721 { - constructor() Ownable(msg.sender) ERC721("ERC721DynamicTraits", "ERC721DT") { +abstract contract AbstractDynamicTraits is DynamicTraits, TwoStepOwnable { + constructor() TwoStepOwnable() { _traitLabelsURI = "https://example.com"; } @@ -22,7 +21,7 @@ contract ERC721DynamicTraits is DynamicTraits, Ownable, ERC721 { _setTraitLabelsURI(uri); } - function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721, DynamicTraits) returns (bool) { - return ERC721.supportsInterface(interfaceId) || DynamicTraits.supportsInterface(interfaceId); + function supportsInterface(bytes4 interfaceId) public view virtual override(DynamicTraits) returns (bool) { + return DynamicTraits.supportsInterface(interfaceId); } } diff --git a/src/redeemables/ERC721RedemptionMintable.sol b/src/redeemables/ERC721RedemptionMintable.sol new file mode 100644 index 0000000..edae169 --- /dev/null +++ b/src/redeemables/ERC721RedemptionMintable.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC721} from "solady/tokens/ERC721.sol"; +import {IERC721RedemptionMintable} from "./interfaces/IERC721RedemptionMintable.sol"; +import {SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; + +contract ERC721RedemptionMintable is ERC721, IERC721RedemptionMintable { + address internal _MINTER; + + /// @dev Revert if the sender of mintRedemption is not the redeemable contract offerer. + error InvalidSender(); + + /// @dev Revert if the redemption spent is not the required token. + error InvalidRedemption(); + + constructor(address minter) { + _MINTER = minter; + } + + function mintRedemption(address to, uint256 tokenId) external returns (uint256) { + if (msg.sender != _MINTER) revert InvalidSender(); + + // Mint the same token ID redeemed. + _mint(to, tokenId); + + return tokenId; + } + + function name() public pure override returns (string memory) { + return "ERC721RedemptionMintable"; + } + + function symbol() public pure override returns (string memory) { + return "721RM"; + } + + function tokenURI(uint256 tokenId) public pure override returns (string memory) { + return string(abi.encodePacked("https://example.com/", tokenId)); + } + + function setMinter(address newMinter) external { + if (msg.sender != _MINTER) revert InvalidSender(); + + _setMinter(newMinter); + } + + function _setMinter(address newMinter) internal { + _MINTER = newMinter; + } +} diff --git a/src/redeemables/ERC7498NFTRedeemables.sol b/src/redeemables/ERC7498NFTRedeemables.sol new file mode 100644 index 0000000..53e1473 --- /dev/null +++ b/src/redeemables/ERC7498NFTRedeemables.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {AbstractDynamicTraits} from "../dynamic-traits/AbstractDynamicTraits.sol"; +import {ERC20} from "solady/tokens/ERC20.sol"; +import {ERC721} from "solady/tokens/ERC721.sol"; +import {ERC721SeaDrop} from "seadrop/ERC721SeaDrop.sol"; +import {ERC721SeaDropContractOfferer} from "seadrop/lib/ERC721SeaDropContractOfferer.sol"; +import {IERC7498} from "./interfaces/IERC7498.sol"; +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; +import {ItemType} from "seaport-types/lib/ConsiderationEnums.sol"; +import {IRedemptionMintable} from "./interfaces/IRedemptionMintable.sol"; +import {CampaignParams, TraitRedemption} from "./lib/RedeemablesStructs.sol"; +import {RedeemablesErrorsAndEvents} from "./lib/RedeemablesErrorsAndEvents.sol"; + +contract ERC7498NFTRedeemables is AbstractDynamicTraits, ERC721SeaDrop, IERC7498, RedeemablesErrorsAndEvents { + /// @dev Counter for next campaign id. + uint256 private _nextCampaignId = 1; + + /// @dev The campaign parameters by campaign id. + mapping(uint256 campaignId => CampaignParams params) private _campaignParams; + + /// @dev The campaign URIs by campaign id. + mapping(uint256 campaignId => string campaignURI) private _campaignURIs; + + /// @dev The total current redemptions by campaign id. + mapping(uint256 campaignId => uint256 count) private _totalRedemptions; + + constructor( + address allowedConfigurer, + address allowedConduit, + address allowedSeaport, + string memory _name, + string memory _symbol + ) ERC721SeaDrop(allowedConfigurer, allowedConduit, allowedSeaport, _name, _symbol) {} + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + return "https://example.com/"; + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + // at 64 will be pointer to array + // mload pointer, pointer points to length + // next word is start of array + function redeem(uint256[] calldata tokenIds, address recipient, bytes calldata extraData) public virtual override { + // Get the campaign id from extraData. + uint256 campaignId = uint256(bytes32(extraData[0:32])); + + // Get the campaign params. + CampaignParams storage params = _campaignParams[campaignId]; + + // Revert if campaign is inactive. + if (_isInactive(params.startTime, params.endTime)) { + revert NotActive(block.timestamp, params.startTime, params.endTime); + } + + // Revert if max total redemptions would be exceeded. + if (_totalRedemptions[campaignId] + tokenIds.length > params.maxCampaignRedemptions) { + revert MaxCampaignRedemptionsReached( + _totalRedemptions[campaignId] + tokenIds.length, params.maxCampaignRedemptions + ); + } + + // Get the campaign consideration. + ConsiderationItem[] memory consideration = params.consideration; + + TraitRedemption[] calldata traitRedemptions; + + // calldata array is two vars on stack (length, ptr to start of array) + assembly { + // Get the pointer to the length of the trait redemptions array by adding 0x40 to the extraData offset. + let traitRedemptionsLengthPtr := calldataload(add(0x40, extraData.offset)) + + // Set the length of the trait redeptions array to the value at the array length pointer. + traitRedemptions.length := calldataload(traitRedemptionsLengthPtr) + + // Set the pointer to the start of the trait redemptions array to the word after the length. + traitRedemptions.offset := add(0x20, traitRedemptionsLengthPtr) + } + + // Iterate over the trait redemptions and set traits on the tokens. + for (uint256 i; i < traitRedemptions.length;) { + // Get the trait redemption token address and place on the stack. + address token = traitRedemptions[i].token; + + uint256 identifier = traitRedemptions[i].identifier; + + // Revert if the trait redemption token is not this token contract. + if (token != address(this)) { + revert InvalidToken(token); + } + + // Revert if the trait redemption identifier is not owned by the caller. + if (ERC721(token).ownerOf(identifier) != msg.sender) { + revert InvalidCaller(token); + } + + // Declare a new block to manage stack depth. + { + // Get the substandard and place on the stack. + uint8 substandard = traitRedemptions[i].substandard; + + // Get the substandard value and place on the stack. + bytes32 substandardValue = traitRedemptions[i].substandardValue; + + // Get the trait key and place on the stack. + bytes32 traitKey = traitRedemptions[i].traitKey; + + bytes32 traitValue = traitRedemptions[i].traitValue; + + // Get the current trait value and place on the stack. + bytes32 currentTraitValue = getTraitValue(traitKey, identifier); + + // If substandard is 1, set trait to traitValue. + if (substandard == 1) { + // Revert if the current trait value does not match the substandard value. + if (currentTraitValue != substandardValue) { + revert InvalidRequiredValue(currentTraitValue, substandardValue); + } + + // Set the trait to the trait value. + _setTrait(traitRedemptions[i].traitKey, identifier, traitValue); + // If substandard is 2, increment trait by traitValue. + } else if (substandard == 2) { + // Revert if the current trait value is greater than the substandard value. + if (currentTraitValue > substandardValue) { + revert InvalidRequiredValue(currentTraitValue, substandardValue); + } + + // Increment the trait by the trait value. + uint256 newTraitValue = uint256(currentTraitValue) + uint256(traitValue); + + _setTrait(traitRedemptions[i].traitKey, identifier, bytes32(newTraitValue)); + } else if (substandard == 3) { + // Revert if the current trait value is less than the substandard value. + if (currentTraitValue < substandardValue) { + revert InvalidRequiredValue(currentTraitValue, substandardValue); + } + + uint256 newTraitValue = uint256(currentTraitValue) - uint256(traitValue); + + // Decrement the trait by the trait value. + _setTrait(traitRedemptions[i].traitKey, traitRedemptions[i].identifier, bytes32(newTraitValue)); + } + } + unchecked { + ++i; + } + } + + // Iterate over the token IDs and check if caller is the owner or approved operator. + // Redeem the token if the caller is valid. + for (uint256 i; i < tokenIds.length;) { + // Get the identifier. + uint256 identifier = tokenIds[i]; + + // Get the token owner. + address owner = ownerOf(identifier); + + // Check the caller is either the owner or approved operator. + if (msg.sender != owner && !isApprovedForAll(owner, msg.sender)) { + revert InvalidCaller(msg.sender); + } + + // Burn or transfer the token to the consideration recipient. + if (consideration[0].recipient == payable(address(0x000000000000000000000000000000000000dEaD))) { + _burn(identifier); + } else { + ERC721(consideration[0].token).safeTransferFrom(owner, consideration[0].recipient, identifier); + } + + // Mint the redemption token. + IRedemptionMintable(params.offer[0].token).mintRedemption(campaignId, recipient, consideration); + + unchecked { + ++i; + } + } + } + + function getCampaign(uint256 campaignId) + external + view + override + returns (CampaignParams memory params, string memory uri, uint256 totalRedemptions) + { + // Revert if campaign id is invalid. + if (campaignId >= _nextCampaignId) revert InvalidCampaignId(); + + // Get the campaign params. + params = _campaignParams[campaignId]; + + // Get the campaign URI. + uri = _campaignURIs[campaignId]; + + // Get the total redemptions. + totalRedemptions = _totalRedemptions[campaignId]; + } + + function createCampaign(CampaignParams calldata params, string calldata uri) + external + override + returns (uint256 campaignId) + { + // Revert if there are no consideration items, since the redemption should require at least something. + if (params.consideration.length == 0) revert NoConsiderationItems(); + + // Revert if startTime is past endTime. + if (params.startTime > params.endTime) revert InvalidTime(); + + for (uint256 i = 0; i < params.consideration.length;) { + // Revert if any of the consideration items is not this token contract. + if (params.consideration[i].token != address(this)) { + revert InvalidConsiderationItem(params.consideration[i].token, address(this)); + } + + // Revert if any of the consideration item recipients is the zero address. + // The 0xdead address should be used instead. + if (params.consideration[i].recipient == address(0)) { + revert ConsiderationItemRecipientCannotBeZeroAddress(); + } + unchecked { + ++i; + } + } + + // Set the campaign params for the next campaignId. + _campaignParams[_nextCampaignId] = params; + + // Set the campaign URI for the next campaignId. + _campaignURIs[_nextCampaignId] = uri; + + // Set the correct current campaignId to return before incrementing + // the next campaignId. + campaignId = _nextCampaignId; + + // Increment the next campaignId. + _nextCampaignId++; + + emit CampaignUpdated(campaignId, params, _campaignURIs[campaignId]); + } + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) + external + override + { + // Revert if campaign id is invalid. + if (campaignId == 0 || campaignId >= _nextCampaignId) { + revert InvalidCampaignId(); + } + + // Revert if there are no consideration items, since the redemption should require at least something. + if (params.consideration.length == 0) revert NoConsiderationItems(); + + // Revert if startTime is past endTime. + if (params.startTime > params.endTime) revert InvalidTime(); + + // Revert if msg.sender is not the manager. + address existingManager = _campaignParams[campaignId].manager; + if (params.manager != msg.sender && (existingManager != address(0) && existingManager != params.manager)) { + revert NotManager(); + } + + // Revert if any of the consideration item recipients is the zero address. The 0xdead address should be used instead. + for (uint256 i = 0; i < params.consideration.length;) { + if (params.consideration[i].recipient == address(0)) { + revert ConsiderationItemRecipientCannotBeZeroAddress(); + } + unchecked { + ++i; + } + } + + // Set the campaign params for the given campaignId. + _campaignParams[campaignId] = params; + + // Update campaign uri if it was provided. + if (bytes(uri).length != 0) { + _campaignURIs[campaignId] = uri; + } + + emit CampaignUpdated(campaignId, params, _campaignURIs[campaignId]); + } + + function _isInactive(uint256 startTime, uint256 endTime) internal view returns (bool inactive) { + // Using the same check for time boundary from Seaport. + // startTime <= block.timestamp < endTime + assembly { + inactive := or(iszero(gt(endTime, timestamp())), gt(startTime, timestamp())) + } + } + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(AbstractDynamicTraits, ERC721SeaDropContractOfferer) + returns (bool) + { + return interfaceId == type(IERC7498).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/src/redeemables/interfaces/IERC7498.sol b/src/redeemables/interfaces/IERC7498.sol new file mode 100644 index 0000000..18346ed --- /dev/null +++ b/src/redeemables/interfaces/IERC7498.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; +import {CampaignParams, TraitRedemption} from "../lib/RedeemablesStructs.sol"; + +interface IERC7498 { + /* Events */ + // event CampaignUpdated( + // uint256 indexed campaignId, + // CampaignParams params, + // string URI + // ); + // event Redemption( + // uint256 indexed campaignId, + // bytes32 redemptionHash, + // uint256[] tokenIds, + // address redeemedBy + // ); + + /* Structs */ + // struct CampaignParams { + // uint32 startTime; + // uint32 endTime; + // uint32 maxCampaignRedemptions; + // address manager; // the address that can modify the campaign + // address signer; // null address means no EIP-712 signature required + // OfferItem[] offer; // items to be minted, can be empty for offchain redeemable + // ConsiderationItem[] consideration; // the items you are transferring to recipient + // } + + // struct TraitRedemption { + // uint8 substandard; + // address token; + // uint256 identifier; + // bytes32 traitKey; + // bytes32 traitValue; + // bytes32 substandardValue; + // } + + /* Getters */ + function getCampaign(uint256 campaignId) + external + view + returns (CampaignParams memory params, string memory uri, uint256 totalRedemptions); + + /* Setters */ + function createCampaign(CampaignParams calldata params, string calldata uri) + external + returns (uint256 campaignId); + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) external; + + function redeem(uint256[] calldata tokenIds, address recipient, bytes calldata extraData) external; +} + +/* Seaport structs, for reference, used in offer/consideration above */ +// enum ItemType { +// NATIVE, +// ERC20, +// ERC721, +// ERC1155 +// } + +// struct OfferItem { +// ItemType itemType; +// address token; +// uint256 identifierOrCriteria; +// uint256 startAmount; +// uint256 endAmount; +// } + +// struct ConsiderationItem { +// ItemType itemType; +// address token; +// uint256 identifierOrCriteria; +// uint256 startAmount; +// uint256 endAmount; +// address payable recipient; +// } + +// struct SpentItem { +// ItemType itemType; +// address token; +// uint256 identifier; +// uint256 amount; +// } diff --git a/src/redeemables/interfaces/IRedemptionMintable.sol b/src/redeemables/interfaces/IRedemptionMintable.sol new file mode 100644 index 0000000..1aa984a --- /dev/null +++ b/src/redeemables/interfaces/IRedemptionMintable.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ConsiderationItem} from "seaport-types/lib/ConsiderationStructs.sol"; + +interface IRedemptionMintable { + function mintRedemption(uint256 campaignId, address recipient, ConsiderationItem[] memory consideration) external; +} diff --git a/src/redeemables/lib/RedeemablesErrorsAndEvents.sol b/src/redeemables/lib/RedeemablesErrorsAndEvents.sol new file mode 100644 index 0000000..6f79c4e --- /dev/null +++ b/src/redeemables/lib/RedeemablesErrorsAndEvents.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; +import {CampaignParams} from "./RedeemablesStructs.sol"; + +interface RedeemablesErrorsAndEvents { + /// Configuration errors + error NotManager(); + error InvalidTime(); + error NoConsiderationItems(); + error ConsiderationItemRecipientCannotBeZeroAddress(); + + /// Redemption errors + error InvalidCampaignId(); + error CampaignAlreadyExists(); + error InvalidCaller(address caller); + // error NotActive(uint256 currentTimestamp, uint256 startTime, uint256 endTime); + error MaxRedemptionsReached(uint256 total, uint256 max); + error MaxCampaignRedemptionsReached(uint256 total, uint256 max); + error NativeTransferFailed(); + error RedeemMismatchedLengths(); + // error TraitValueUnchanged(); + error InvalidConsiderationLength(uint256 got, uint256 want); + error InvalidConsiderationItem(address got, address want); + error InvalidOfferLength(uint256 got, uint256 want); + error InvalidNativeOfferItem(); + error InvalidOwner(); + error InvalidRequiredValue(bytes32 got, bytes32 want); + // error InvalidSubstandard(uint256 substandard); + error InvalidToken(address token); + error InvalidTraitRedemption(); + error InvalidTraitRedemptionToken(address token); + error ConsiderationRecipientNotFound(address token); + error RedemptionValuesAreImmutable(); + + /// Events + event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string uri); + event Redemption(uint256 indexed campaignId, bytes32 redemptionHash); +} diff --git a/src/redeemables/lib/RedeemablesStructs.sol b/src/redeemables/lib/RedeemablesStructs.sol new file mode 100644 index 0000000..dab79c8 --- /dev/null +++ b/src/redeemables/lib/RedeemablesStructs.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/lib/ConsiderationStructs.sol"; + +struct CampaignParams { + uint32 startTime; + uint32 endTime; + uint32 maxCampaignRedemptions; + address manager; + address signer; + OfferItem[] offer; + ConsiderationItem[] consideration; +} + +struct TraitRedemption { + uint8 substandard; + address token; + uint256 identifier; + bytes32 traitKey; + bytes32 traitValue; + bytes32 substandardValue; +} diff --git a/test/dynamic-traits/ERC721DynamicTraits.t.sol b/test/dynamic-traits/ERC721DynamicTraits.t.sol index 2fa576e..d102a89 100644 --- a/test/dynamic-traits/ERC721DynamicTraits.t.sol +++ b/test/dynamic-traits/ERC721DynamicTraits.t.sol @@ -2,13 +2,32 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; import {IERC7496} from "src/dynamic-traits/interfaces/IERC7496.sol"; -import {ERC721DynamicTraits, DynamicTraits} from "src/dynamic-traits/ERC721DynamicTraits.sol"; +import {AbstractDynamicTraits, DynamicTraits} from "src/dynamic-traits/AbstractDynamicTraits.sol"; +import {ERC721} from "solady/tokens/ERC721.sol"; import {Solarray} from "solarray/Solarray.sol"; +contract ERC721DynamicTraits is AbstractDynamicTraits, ERC721 { + function name() public pure override returns (string memory) { + return "ERC721DynamicTraits"; + } + + function symbol() public pure override returns (string memory) { + return "ERC721DT"; + } + + function tokenURI(uint256) public pure override returns (string memory) { + return "tokenURI"; + } + + function supportsInterface(bytes4 interfaceId) public view override(AbstractDynamicTraits, ERC721) returns (bool) { + return AbstractDynamicTraits.supportsInterface(interfaceId) || ERC721.supportsInterface(interfaceId); + } +} + contract ERC721DynamicTraitsTest is Test { - ERC721DynamicTraits token; + AbstractDynamicTraits token; /* Events */ event TraitUpdated(bytes32 indexed traitKey, uint256 indexed tokenId, bytes32 value); @@ -40,7 +59,7 @@ contract ERC721DynamicTraitsTest is Test { function testOnlyOwnerCanSetValues() public { address alice = makeAddr("alice"); vm.prank(alice); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)); + vm.expectRevert(abi.encodeWithSelector(Ownable.Unauthorized.selector)); token.setTrait(bytes32("test"), 0, bytes32("test")); } @@ -124,7 +143,7 @@ contract ERC721DynamicTraitsTest is Test { assertEq(token.getTraitLabelsURI(), uri); vm.prank(address(0x1234)); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(0x1234))); + vm.expectRevert(abi.encodeWithSelector(Ownable.Unauthorized.selector)); token.setTraitLabelsURI(uri); }