From 3028bd6af2facf3a53cd3ee988e258eb6ac4ed76 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Thu, 26 Oct 2023 10:21:30 -0700 Subject: [PATCH 01/13] Add ERC --- ERCS/erc-7498.md | 322 +++++++++++ assets/erc-7498/ERC721ShipyardRedeemable.sol | 53 ++ .../erc-7498/ERC721ShipyardRedeemable.t.sol | 499 ++++++++++++++++++ assets/erc-7498/ERC7498NFTRedeemables.sol | 394 ++++++++++++++ assets/erc-7498/IERC7498.sol | 32 ++ assets/erc-7498/IRedemptionMintable.sol | 14 + assets/erc-7498/RedeemablesErrors.sol | 38 ++ assets/erc-7498/RedeemablesStructs.sol | 28 + 8 files changed, 1380 insertions(+) create mode 100644 ERCS/erc-7498.md create mode 100644 assets/erc-7498/ERC721ShipyardRedeemable.sol create mode 100644 assets/erc-7498/ERC721ShipyardRedeemable.t.sol create mode 100644 assets/erc-7498/ERC7498NFTRedeemables.sol create mode 100644 assets/erc-7498/IERC7498.sol create mode 100644 assets/erc-7498/IRedemptionMintable.sol create mode 100644 assets/erc-7498/RedeemablesErrors.sol create mode 100644 assets/erc-7498/RedeemablesStructs.sol diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md new file mode 100644 index 0000000000..45549411b6 --- /dev/null +++ b/ERCS/erc-7498.md @@ -0,0 +1,322 @@ +--- +eip: 7498 +title: NFT Redeemables +description: Extension to ERC-721 and ERC-1155 for onchain and offchain redeemables +author: Ryan Ghods (@ryanio), 0age (@0age), Adam Montgomery (@montasaurus), Stephan Min (@stephankmin) +discussions-to: https://ethereum-magicians.org/t/erc-7498-nft-redeemables/15485 +status: Draft +type: Standards Track +category: ERC +created: 2023-07-28 +requires: 165, 712, 721, 1155, 1271 +--- + +## Abstract + +This specification introduces a new interface that extends [ERC-721](./eip-721.md) and [ERC-1155](./eip-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. + +## Motivation + +Creators frequently use NFTs to create redeemable entitlements for digital and physical goods. However, without a standard interface, it is challenging for users and apps to discover and interact with these NFTs in a predictable and standard way. This standard aims to encompass enabling broad functionality for: + +- discovery: events and getters that provide information about the requirements of a redemption campaign +- onchain: token mints with context of items spent +- offchain: the ability to associate with ecommerce orders (through `redemptionHash`) +- trait redemptions: improving the burn-to-redeem experience with E RC-7496 Dynamic Traits. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +The token MUST have the following interface and MUST return `true` for [ERC-165](./eip-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. + +```solidity +interface IERC7501 { + /* Events */ + event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string URI); + event Redemption(uint256 indexed campaignId, uint256 requirementsIndex, bytes32 redemptionHash, uint256[] considerationTokenIds, uint256[] traitRedemptionTokenIds, 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 + CampaignRequirements[] requirements; // one requirement must be fully satisfied for a successful redemption + } + struct Campaign { + OfferItem[] offer; // items to be minted, can be empty for offchain redeemable + ConsiderationItem[] consideration; // items transferring to recipient + TraitRedemption[] traitRedemptions; // the trait redemptions to process + } + 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 considerationTokenIds, address recipient, bytes calldata extraData) external payable; +} + +--- + +/* 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 extends OfferItem { + address payable recipient; + // (note: psuedocode above, as of this writing can't extend structs in solidity) +} +struct SpentItem { + ItemType itemType; + address token; + uint256 identifier; + uint256 amount; +} +``` + +### Creating campaigns + +When creating a new campaign, `createCampaign` MUST be used and MUST return the newly created `campaignId` along with the `CampaignUpdated` event. The `campaignId` MUST be an incrementing counter starting at `1`. + +### Updating campaigns + +Updates to campaigns MUST use `updateCampaign` and MUST emit the `CampaignUpdated` event. If an address other than the `manager` tries to update the campaign, it MUST revert with `NotManager()`. If the manager wishes to make the campaign immutable, the `manager` MAY be set to the null address. + +### Offer + +If tokens are set in the params `offer`, the tokens MUST implement the `IRedemptionMintable` interface in order to support minting new items. The implementation SHOULD be however the token mechanics are desired. The implementing token MUST return true for ERC-165 `supportsInterface` for the interfaceId of `IRedemptionMintable`, `0xf38dd076`. + +```solidify +interface IRedemptionMintable { + function mintRedemption(uint256 campaignId, address recipient, ConsiderationItem[] calldata consideration, TraitRedemptions[] calldata traitRedemptions) external; +} +``` + +When `mintRedemption` is called, it is RECOMMENDED to replace the token identifiers in the consideration items and trait redemptions with the items actually being redeemed. + +### Consideration + +Any token may be specified in the campaign requirement `consideration`. This will ensure the token is transferred to the `recipient`. If the token is meant to be burned, the recipient SHOULD be `0x000000000000000000000000000000000000dEaD`. If the token can internally handle burning its own tokens and reducing totalSupply, the token MAY burn the token instead of transferring to the recipient `0x000000000000000000000000000000000000dEaD`. + +### Dynamic traits + +Including trait redemptions is optional, but if the token would like to enable trait redemptions the token MUST include E RC-7496 Dynamic Traits. + +### Signer + +A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](./eip-712.md) or [ERC-1271](./eip-1271.md). + +The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,uint256[] considerationTokenIds,uint256[] traitRedemptionTokenIds,uint256 campaignId,uint256 requirementsIndex, bytes32 redemptionHash, uint256 salt)"` + +### Redeem function + +The `redeem` function MUST use the `consideration`, `offer`, and `traitRedemptions` specified by the `requirements` determined by the `campaignId` and `requirementsIndex`: + +- Execute the transfers in the `consideration` +- Mutate the traits specified by `traitRedemptions` according to ERC -7496 Dynamic Traits +- Call `mintRedemption()` on every `offer` item + +The `Redemption` event MUST be emitted for every valid redemption that occurs. + +#### Redemption extraData + +The extraData layout MUST conform to the below: + +| bytes | value | description / notes | +| -------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| 0-32 | campaignId | | +| 32-64 | requirementsIndex | index of the campaign requirements met | +| 64-96 | redemptionHash | hash of offchain order ids | +| 96-\* | uint256[] traitRedemptionTokenIds | token ids for trait redemptions, MUST be in same order of campaign TraitRedemption[] | +| \*-(+32) | salt | if signer != address(0) | +| \*-(+\*) | signature | if signer != address(0). can be for EIP-712 or ERC-1271 | + +The `requirementsIndex` MUST be the index in the `requirements` array that satisfies the redemption. This helps reduce gas to find the requirement met. + +The `traitRedemptionTokenIds` specifies the token IDs required for the trait redemptions in the requirements array. The order MUST be the same order of the token addresses expected in the array of `TraitRedemption` structs in the campaign requirement used. + +If the campaign `signer` is the null address the `salt` and `signature` MUST be omitted. + +The `redemptionHash` is designated for offchain redemptions to reference offchain order identifiers to track the redemption to. + +The function MUST check that the campaign is active (using the same boundary check as Seaport, `startTime <= block.timestamp < endTime`). If it is not active, it MUST revert with `NotActive()`. + +### Trait redemptions + +The token MUST respect the TraitRedemption substandards as follows: + +| substandard ID | description | substandard value | +| -------------- | ------------------------------- | ------------------------------------------------------------------ | +| 1 | set value to `traitValue` | prior required value. if blank, cannot be the `traitValue` already | +| 2 | increment trait by `traitValue` | max value | +| 3 | decrement trait by `traitValue` | min value | +| 4 | check value is `traitValue` | n/a | + +### Max campaign redemptions + +The token MUST check that the `maxCampaignRedemptions` is not exceeded. If the redemption does exceed `maxCampaignRedemptions`, it MUST revert with `MaxCampaignRedemptionsReached(uint256 total, uint256 max)` + +### Metadata URI + +The metadata URI MUST conform to the below JSON schema: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "A one-line summary of the redeemable. Markdown is not supported." + }, + "details": { + "type": "string", + "description": "A multi-line or multi-paragraph description of the details of the redeemable. Markdown is supported." + }, + "imageUrls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of image URLs for the redeemable. The first image will be used as the thumbnail. Will rotate in a carousel if multiple images are provided. Maximum 5 images." + }, + "bannerUrl": { + "type": "string", + "description": "The banner image for the redeemable." + }, + "faq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "answer": { + "type": "string" + }, + "required": ["question", "answer"] + } + } + }, + "contentLocale": { + "type": "string", + "description": "The language tag for the content provided by this metadata. https://www.rfc-editor.org/rfc/rfc9110.html#name-language-tags" + }, + "maxRedemptionsPerToken": { + "type": "string", + "description": "The maximum number of redemptions per token. When isBurn is true should be 1, else can be a number based on the trait redemptions limit." + }, + "isBurn": { + "type": "string", + "description": "If the redemption burns the token." + }, + "uuid": { + "type": "string", + "description": "An optional unique identifier for the campaign, for backends to identify when draft campaigns are published onchain." + }, + "productLimitForRedemption": { + "type": "number", + "description": "The number of products which are able to be chosen from the products array for a single redemption." + }, + "products": { + "type": "object", + "properties": "https://schema.org/Product", + "required": ["name", "url", "description"] + }, + "traitRedemptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "substandard": { + "type": "number" + }, + "token": { + "type": "string", + "description": "The token address" + }, + "traitKey": { + "type": "string" + }, + "traitValue": { + "type": "string" + }, + "substandardValue": { + "type": "string" + } + }, + "required": [ + "substandard", + "token", + "traitKey", + "traitValue", + "substandardValue" + ] + } + } + }, + "required": ["name", "description", "isBurn"] +} +``` + +Future EIPs MAY inherit this one and add to the above metadata to add more features and functionality. + +### ERC-1155 (Semi-fungibles) + +This standard MAY be applied to ERC-1155 but the redemptions would apply to all token amounts for specific token identifiers. If the ERC-1155 contract only has tokens with amount of 1, then this specification MAY be used as written. + +## Rationale + +The "offer" and "consideration" structs from Seaport were used to create a similar language for redeemable campaigns. The "offer" is what is being offered, e.g. a new onchain token, and the "consideration" is what must be satisfied to complete the redemption. The "consideration" field has a "recipient", who the token should be transferred to. For trait updates that do not require moving of a token, `traitRedemptionTokenIds` is specified instead. + +The "salt" and "signature" fields are provided primarily for offchain redemptions where a provider would want to sign approval for a redemption before it is conducted onchain, to prevent the need for irregular state changes. For example, if a user lives outside a region supported by the shipping of an offchain redeemable, during the offchain order creation process the signature would not be provided for the onchain redemption when seeing that the user's shipping country is unsupported. This prevents the user from redeeming the NFT, then later finding out the shipping isn't supported after their NFT is already burned or trait is mutated. + +E RC-7496 Dynamic Traits is used for trait redemptions to support onchain enforcement of trait values for secondary market orders. + +## Backwards Compatibility + +As a new EIP, no backwards compatibility issues are present. + +## Test Cases + +Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/eip-7498/ERC721ShipyardRedeemable.t.sol). + +## Reference Implementation + +Authors have included reference implementations of the specification in the [assets folder](../assets/eip-7498/ERC7498NFTRedeemables.sol). + +## Security Considerations + +If trait redemptions are desired, tokens implementing this EIP must properly implement E RC-7496 Dynamic Traits. + +For tokens to be minted as part of the params `offer`, the `mintRedemption` function contained as part of `IRedemptionMintable` MUST be permissioned and ONLY allowed to be called by specified addresses. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/erc-7498/ERC721ShipyardRedeemable.sol b/assets/erc-7498/ERC721ShipyardRedeemable.sol new file mode 100644 index 0000000000..96b4fd734a --- /dev/null +++ b/assets/erc-7498/ERC721ShipyardRedeemable.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {ERC721ConduitPreapproved_Solady} from "shipyard-core/src/tokens/erc721/ERC721ConduitPreapproved_Solady.sol"; +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {ERC7498NFTRedeemables} from "./lib/ERC7498NFTRedeemables.sol"; +import {CampaignParams} from "./lib/RedeemablesStructs.sol"; + +contract ERC721ShipyardRedeemable is ERC721ConduitPreapproved_Solady, ERC7498NFTRedeemables, Ownable { + constructor() ERC721ConduitPreapproved_Solady() { + _initializeOwner(msg.sender); + } + + function name() public pure override returns (string memory) { + return "ERC721ShipyardRedeemable"; + } + + function symbol() public pure override returns (string memory) { + return "SY-RDM"; + } + + function tokenURI(uint256 /* tokenId */ ) public pure override returns (string memory) { + return "https://example.com/"; + } + + function createCampaign(CampaignParams calldata params, string calldata uri) + public + override + onlyOwner + returns (uint256 campaignId) + { + campaignId = ERC7498NFTRedeemables.createCampaign(params, uri); + } + + function _useInternalBurn() internal pure virtual override returns (bool) { + return true; + } + + function _internalBurn(uint256 id, uint256 /* amount */ ) internal virtual override { + _burn(id); + } + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC721, ERC7498NFTRedeemables) + returns (bool) + { + return ERC721.supportsInterface(interfaceId) || ERC7498NFTRedeemables.supportsInterface(interfaceId); + } +} diff --git a/assets/erc-7498/ERC721ShipyardRedeemable.t.sol b/assets/erc-7498/ERC721ShipyardRedeemable.t.sol new file mode 100644 index 0000000000..a589b983d7 --- /dev/null +++ b/assets/erc-7498/ERC721ShipyardRedeemable.t.sol @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {Solarray} from "solarray/Solarray.sol"; +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {TestERC721} from "./utils/mocks/TestERC721.sol"; +import {OfferItem, ConsiderationItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {ItemType, OrderType, Side} from "seaport-sol/src/SeaportEnums.sol"; +import {CampaignParams, CampaignRequirements, TraitRedemption} from "../src/lib/RedeemablesStructs.sol"; +import {RedeemablesErrors} from "../src/lib/RedeemablesErrors.sol"; +import {ERC721RedemptionMintable} from "../src/extensions/ERC721RedemptionMintable.sol"; +import {ERC721ShipyardRedeemableOwnerMintable} from "../src/test/ERC721ShipyardRedeemableOwnerMintable.sol"; + +contract TestERC721ShipyardRedeemable is RedeemablesErrors, Test { + event Redemption( + uint256 indexed campaignId, + uint256 requirementsIndex, + bytes32 redemptionHash, + uint256[] considerationTokenIds, + uint256[] traitRedemptionTokenIds, + address redeemedBy + ); + + ERC721ShipyardRedeemableOwnerMintable redeemToken; + ERC721RedemptionMintable receiveToken; + address alice; + + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + function setUp() public { + redeemToken = new ERC721ShipyardRedeemableOwnerMintable(); + receiveToken = new ERC721RedemptionMintable(address(redeemToken)); + alice = makeAddr("alice"); + + vm.label(address(redeemToken), "redeemToken"); + vm.label(address(receiveToken), "receiveToken"); + vm.label(alice, "alice"); + } + + function testBurnInternalToken() public { + uint256 tokenId = 2; + redeemToken.mint(address(this), tokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(receiveToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignRequirements[] memory requirements = new CampaignRequirements[]( + 1 + ); + requirements[0].offer = offer; + requirements[0].consideration = consideration; + + { + CampaignParams memory params = CampaignParams({ + requirements: requirements, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + redeemToken.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(receiveToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + // campaignId: 1 + // requirementsIndex: 0 + // redemptionHash: bytes32(0) + bytes memory extraData = abi.encode(1, 0, bytes32(0)); + consideration[0].identifierOrCriteria = tokenId; + + uint256[] memory considerationTokenIds = Solarray.uint256s(tokenId); + uint256[] memory traitRedemptionTokenIds; + + vm.expectEmit(true, true, true, true); + emit Redemption(1, 0, bytes32(0), considerationTokenIds, traitRedemptionTokenIds, address(this)); + redeemToken.redeem(considerationTokenIds, address(this), extraData); + + vm.expectRevert(ERC721.TokenDoesNotExist.selector); + redeemToken.ownerOf(tokenId); + + assertEq(receiveToken.ownerOf(1), address(this)); + } + } + + function testRevert721ConsiderationItemInsufficientBalance() public { + uint256 tokenId = 2; + uint256 invalidTokenId = tokenId + 1; + redeemToken.mint(address(this), tokenId); + redeemToken.mint(alice, invalidTokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(receiveToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignRequirements[] memory requirements = new CampaignRequirements[]( + 1 + ); + requirements[0].offer = offer; + requirements[0].consideration = consideration; + + { + CampaignParams memory params = CampaignParams({ + requirements: requirements, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + redeemToken.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(receiveToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + // campaignId: 1 + // requirementsIndex: 0 + // redemptionHash: bytes32(0) + bytes memory extraData = abi.encode(1, 0, bytes32(0)); + consideration[0].identifierOrCriteria = tokenId; + + uint256[] memory tokenIds = Solarray.uint256s(invalidTokenId); + + vm.expectRevert( + abi.encodeWithSelector( + ConsiderationItemInsufficientBalance.selector, + requirements[0].consideration[0].token, + 0, + requirements[0].consideration[0].startAmount + ) + ); + redeemToken.redeem(tokenIds, address(this), extraData); + + assertEq(redeemToken.ownerOf(tokenId), address(this)); + + vm.expectRevert(ERC721.TokenDoesNotExist.selector); + receiveToken.ownerOf(1); + } + } + + function testRevertConsiderationLengthNotMet() public { + ERC721ShipyardRedeemableOwnerMintable secondRedeemToken = new ERC721ShipyardRedeemableOwnerMintable(); + + uint256 tokenId = 2; + redeemToken.mint(address(this), tokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(receiveToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + consideration[1] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(secondRedeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignRequirements[] memory requirements = new CampaignRequirements[]( + 1 + ); + requirements[0].offer = offer; + requirements[0].consideration = consideration; + + { + CampaignParams memory params = CampaignParams({ + requirements: requirements, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + redeemToken.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(receiveToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + // campaignId: 1 + // requirementsIndex: 0 + // redemptionHash: bytes32(0) + bytes memory extraData = abi.encode(1, 0, bytes32(0)); + consideration[0].identifierOrCriteria = tokenId; + + uint256[] memory tokenIds = Solarray.uint256s(tokenId); + + vm.expectRevert(abi.encodeWithSelector(TokenIdsDontMatchConsiderationLength.selector, 2, 1)); + + redeemToken.redeem(tokenIds, address(this), extraData); + + assertEq(redeemToken.ownerOf(tokenId), address(this)); + + vm.expectRevert(ERC721.TokenDoesNotExist.selector); + receiveToken.ownerOf(1); + } + } + + function testBurnWithSecondConsiderationItem() public { + ERC721ShipyardRedeemableOwnerMintable secondRedeemToken = new ERC721ShipyardRedeemableOwnerMintable(); + vm.label(address(secondRedeemToken), "secondRedeemToken"); + secondRedeemToken.setApprovalForAll(address(redeemToken), true); + + uint256 tokenId = 2; + redeemToken.mint(address(this), tokenId); + secondRedeemToken.mint(address(this), tokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(receiveToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](2); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + consideration[1] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(secondRedeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignRequirements[] memory requirements = new CampaignRequirements[]( + 1 + ); + requirements[0].offer = offer; + requirements[0].consideration = consideration; + + { + CampaignParams memory params = CampaignParams({ + requirements: requirements, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + redeemToken.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(receiveToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + // campaignId: 1 + // requirementsIndex: 0 + // redemptionHash: bytes32(0) + bytes memory extraData = abi.encode(1, 0, bytes32(0)); + consideration[0].identifierOrCriteria = tokenId; + + uint256[] memory tokenIds = Solarray.uint256s(tokenId, tokenId); + + redeemToken.redeem(tokenIds, address(this), extraData); + + vm.expectRevert(ERC721.TokenDoesNotExist.selector); + redeemToken.ownerOf(tokenId); + + assertEq(secondRedeemToken.ownerOf(tokenId), _BURN_ADDRESS); + + assertEq(receiveToken.ownerOf(1), address(this)); + } + } + + function testBurnWithSecondRequirementsIndex() public { + ERC721ShipyardRedeemableOwnerMintable secondRedeemToken = new ERC721ShipyardRedeemableOwnerMintable(); + vm.label(address(secondRedeemToken), "secondRedeemToken"); + secondRedeemToken.setApprovalForAll(address(redeemToken), true); + + uint256 tokenId = 2; + redeemToken.mint(address(this), tokenId); + secondRedeemToken.mint(address(this), tokenId); + + OfferItem[] memory offer = new OfferItem[](1); + offer[0] = OfferItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(receiveToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1 + }); + + ConsiderationItem[] memory consideration = new ConsiderationItem[](1); + consideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(redeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + ConsiderationItem[] memory secondRequirementConsideration = new ConsiderationItem[](1); + secondRequirementConsideration[0] = ConsiderationItem({ + itemType: ItemType.ERC721_WITH_CRITERIA, + token: address(secondRedeemToken), + identifierOrCriteria: 0, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + CampaignRequirements[] memory requirements = new CampaignRequirements[]( + 2 + ); + requirements[0].offer = offer; + requirements[0].consideration = consideration; + + requirements[1].offer = offer; + requirements[1].consideration = secondRequirementConsideration; + + { + CampaignParams memory params = CampaignParams({ + requirements: requirements, + signer: address(0), + startTime: uint32(block.timestamp), + endTime: uint32(block.timestamp + 1000), + maxCampaignRedemptions: 5, + manager: address(this) + }); + + redeemToken.createCampaign(params, ""); + } + + { + OfferItem[] memory offerFromEvent = new OfferItem[](1); + offerFromEvent[0] = OfferItem({ + itemType: ItemType.ERC721, + token: address(receiveToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1 + }); + ConsiderationItem[] memory considerationFromEvent = new ConsiderationItem[](1); + considerationFromEvent[0] = ConsiderationItem({ + itemType: ItemType.ERC721, + token: address(redeemToken), + identifierOrCriteria: tokenId, + startAmount: 1, + endAmount: 1, + recipient: payable(_BURN_ADDRESS) + }); + + assertGt(uint256(consideration[0].itemType), uint256(considerationFromEvent[0].itemType)); + + // campaignId: 1 + // requirementsIndex: 0 + // redemptionHash: bytes32(0) + bytes memory extraData = abi.encode(1, 1, bytes32(0)); + consideration[0].identifierOrCriteria = tokenId; + + uint256[] memory tokenIds = Solarray.uint256s(tokenId); + + redeemToken.redeem(tokenIds, address(this), extraData); + + assertEq(redeemToken.ownerOf(tokenId), address(this)); + + assertEq(secondRedeemToken.ownerOf(tokenId), _BURN_ADDRESS); + + assertEq(receiveToken.ownerOf(1), address(this)); + } + } +} diff --git a/assets/erc-7498/ERC7498NFTRedeemables.sol b/assets/erc-7498/ERC7498NFTRedeemables.sol new file mode 100644 index 0000000000..fb854b3b19 --- /dev/null +++ b/assets/erc-7498/ERC7498NFTRedeemables.sol @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {IERC721} from "openzeppelin-contracts/contracts/interfaces/IERC721.sol"; +import {IERC1155} from "openzeppelin-contracts/contracts/interfaces/IERC1155.sol"; +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {ItemType} from "seaport-types/src/lib/ConsiderationEnums.sol"; +import {DynamicTraits} from "shipyard-core/src/dynamic-traits/DynamicTraits.sol"; +import {IERC7498} from "./IERC7498.sol"; +import {IRedemptionMintable} from "./IRedemptionMintable.sol"; +import {RedeemablesErrors} from "./RedeemablesErrors.sol"; +import {CampaignParams, CampaignRequirements, TraitRedemption} from "./RedeemablesStructs.sol"; + +contract ERC7498NFTRedeemables is IERC7498, RedeemablesErrors { + /// @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; + + /// @dev The burn address. + address constant _BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + + struct RedemptionParams { + uint256[] considerationTokenIds; + address recipient; + bytes extraData; + } + + function multiRedeem(RedemptionParams[] calldata params) external payable { + for (uint256 i; i < params.length;) { + redeem(params[i].considerationTokenIds, params[i].recipient, params[i].extraData); + unchecked { + ++i; + } + } + } + + function redeem(uint256[] calldata considerationTokenIds, address recipient, bytes calldata extraData) + public + payable + { + // Get the campaign id and requirementsIndex from extraData. + uint256 campaignId = uint256(bytes32(extraData[0:32])); + uint256 requirementsIndex = uint256(bytes32(extraData[32:64])); + + // Get the campaign params. + CampaignParams storage params = _campaignParams[campaignId]; + + // Validate the campaign time and total redemptions. + _validateRedemption(campaignId, params); + + // Increment totalRedemptions. + ++_totalRedemptions[campaignId]; + + // Get the campaign requirements. + if (requirementsIndex >= params.requirements.length) { + revert RequirementsIndexOutOfBounds(); + } + CampaignRequirements storage requirements = params.requirements[requirementsIndex]; + + // Process the redemption. + _processRedemption(campaignId, requirements, considerationTokenIds, recipient); + + // TODO: decode traitRedemptionTokenIds from extraData. + uint256[] memory traitRedemptionTokenIds; + + // Emit the Redemption event. + emit Redemption( + campaignId, requirementsIndex, bytes32(0), considerationTokenIds, traitRedemptionTokenIds, msg.sender + ); + } + + 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) + public + virtual + returns (uint256 campaignId) + { + // Validate the campaign params, reverts if invalid. + _validateCampaignParams(params); + + // Set the campaignId and increment the next one. + campaignId = _nextCampaignId; + ++_nextCampaignId; + + // Set the campaign params. + _campaignParams[campaignId] = params; + + // Set the campaign URI. + _campaignURIs[campaignId] = uri; + + emit CampaignUpdated(campaignId, params, uri); + } + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) external { + // Revert if the campaign id is invalid. + if (campaignId == 0 || campaignId >= _nextCampaignId) { + revert InvalidCampaignId(); + } + + // 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(); + } + + // Validate the campaign params and revert if invalid. + _validateCampaignParams(params); + + // Set the campaign params. + _campaignParams[campaignId] = params; + + // Update the campaign uri if it was provided. + if (bytes(uri).length != 0) { + _campaignURIs[campaignId] = uri; + } + + emit CampaignUpdated(campaignId, params, _campaignURIs[campaignId]); + } + + function _validateCampaignParams(CampaignParams memory params) internal pure { + // Revert if startTime is past endTime. + if (params.startTime > params.endTime) { + revert InvalidTime(); + } + + // Iterate over the requirements. + for (uint256 i = 0; i < params.requirements.length;) { + CampaignRequirements memory requirements = params.requirements[i]; + + // Validate each consideration item. + for (uint256 j = 0; j < requirements.consideration.length;) { + ConsiderationItem memory c = requirements.consideration[j]; + + // Revert if any of the consideration item recipients is the zero address. + // 0xdead address should be used instead. + // For internal burn, override _internalBurn and set _useInternalBurn to true. + if (c.recipient == address(0)) { + revert ConsiderationItemRecipientCannotBeZeroAddress(); + } + + if (c.startAmount == 0) { + revert ConsiderationItemAmountCannotBeZero(); + } + + // Revert if startAmount != endAmount, as this requires more complex logic. + if (c.startAmount != c.endAmount) { + revert NonMatchingConsiderationItemAmounts(i, c.startAmount, c.endAmount); + } + + unchecked { + ++j; + } + } + + unchecked { + ++i; + } + } + } + + function _validateRedemption(uint256 campaignId, CampaignParams memory params) internal view { + 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] + 1 > params.maxCampaignRedemptions) { + revert MaxCampaignRedemptionsReached(_totalRedemptions[campaignId] + 1, params.maxCampaignRedemptions); + } + } + + function _transferConsiderationItem(uint256 id, ConsiderationItem memory c) internal { + // If consideration item is this contract, recipient is burn address, and _useInternalBurn() fn returns true, + // call the internal burn function and return. + if (c.token == address(this) && c.recipient == payable(_BURN_ADDRESS) && _useInternalBurn()) { + _internalBurn(id, c.startAmount); + return; + } + + // Transfer the token to the consideration recipient. + if (c.itemType == ItemType.ERC721 || c.itemType == ItemType.ERC721_WITH_CRITERIA) { + // ERC721_WITH_CRITERIA with identifier 0 is wildcard: any id is valid. + // Criteria is not yet implemented, for that functionality use the contract offerer. + if (c.itemType == ItemType.ERC721 && id != c.identifierOrCriteria) { + revert InvalidConsiderationTokenIdSupplied(c.token, id, c.identifierOrCriteria); + } + IERC721(c.token).safeTransferFrom(msg.sender, c.recipient, id); + } else if ((c.itemType == ItemType.ERC1155 || c.itemType == ItemType.ERC1155_WITH_CRITERIA)) { + // ERC1155_WITH_CRITERIA with identifier 0 is wildcard: any id is valid. + // Criteria is not yet implemented, for that functionality use the contract offerer. + if (c.itemType == ItemType.ERC1155 && id != c.identifierOrCriteria) { + revert InvalidConsiderationTokenIdSupplied(c.token, id, c.identifierOrCriteria); + } + IERC1155(c.token).safeTransferFrom(msg.sender, c.recipient, id, c.startAmount, ""); + } else if (c.itemType == ItemType.ERC20) { + IERC20(c.token).transferFrom(msg.sender, c.recipient, c.startAmount); + } else { + // ItemType.NATIVE + (bool success,) = c.recipient.call{value: msg.value}(""); + if (!success) revert EtherTransferFailed(); + } + } + + /// @dev Override this function to return true if `_internalBurn` is used. + function _useInternalBurn() internal pure virtual returns (bool) { + return false; + } + + /// @dev Function that is called to burn amounts of a token internal to this inherited contract. + /// Override with token implementation calling internal burn. + function _internalBurn(uint256 id, uint256 amount) internal virtual { + // Override with your token implementation calling internal burn. + } + + 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 _processRedemption( + uint256 campaignId, + CampaignRequirements memory requirements, + uint256[] memory tokenIds, + address recipient + ) internal { + // Get the campaign consideration. + ConsiderationItem[] memory consideration = requirements.consideration; + + // Revert if the tokenIds length does not match the consideration length. + if (consideration.length != tokenIds.length) { + revert TokenIdsDontMatchConsiderationLength(consideration.length, tokenIds.length); + } + + // Keep track of the total native value to validate. + uint256 totalNativeValue; + + // Iterate over the consideration items. + for (uint256 j; j < consideration.length;) { + // Get the consideration item. + ConsiderationItem memory c = consideration[j]; + + // Get the identifier. + uint256 id = tokenIds[j]; + + // Get the token balance. + uint256 balance; + if (c.itemType == ItemType.ERC721 || c.itemType == ItemType.ERC721_WITH_CRITERIA) { + balance = IERC721(c.token).ownerOf(id) == msg.sender ? 1 : 0; + } else if (c.itemType == ItemType.ERC1155 || c.itemType == ItemType.ERC1155_WITH_CRITERIA) { + balance = IERC1155(c.token).balanceOf(msg.sender, id); + } else if (c.itemType == ItemType.ERC20) { + balance = IERC20(c.token).balanceOf(msg.sender); + } else { + // ItemType.NATIVE + totalNativeValue += c.startAmount; + // Total native value is validated after the loop. + } + + // Ensure the balance is sufficient. + if (balance < c.startAmount) { + revert ConsiderationItemInsufficientBalance(c.token, balance, c.startAmount); + } + + // Transfer the consideration item. + _transferConsiderationItem(id, c); + + // Get the campaign offer. + OfferItem[] memory offer = requirements.offer; + + // Mint the new tokens. + for (uint256 k; k < offer.length;) { + IRedemptionMintable(offer[k].token).mintRedemption( + campaignId, recipient, requirements.consideration, requirements.traitRedemptions + ); + + unchecked { + ++k; + } + } + + unchecked { + ++j; + } + } + + // Validate the correct native value is sent with the transaction. + if (msg.value != totalNativeValue) { + revert InvalidTxValue(msg.value, totalNativeValue); + } + + // Process trait redemptions. + // TraitRedemption[] memory traitRedemptions = requirements.traitRedemptions; + // _setTraits(traitRedemptions); + } + + function _setTraits(TraitRedemption[] calldata traitRedemptions) internal { + /* + // 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; + + // 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; + } + } + */ + } + + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC7498).interfaceId; + } +} diff --git a/assets/erc-7498/IERC7498.sol b/assets/erc-7498/IERC7498.sol new file mode 100644 index 0000000000..e8bfbe8565 --- /dev/null +++ b/assets/erc-7498/IERC7498.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {CampaignParams, TraitRedemption} from "./RedeemablesStructs.sol"; + +interface IERC7498 { + event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string uri); + event Redemption( + uint256 indexed campaignId, + uint256 requirementsIndex, + bytes32 redemptionHash, + uint256[] considerationTokenIds, + uint256[] traitRedemptionTokenIds, + address redeemedBy + ); + + function createCampaign(CampaignParams calldata params, string calldata uri) + external + returns (uint256 campaignId); + + function updateCampaign(uint256 campaignId, CampaignParams calldata params, string calldata uri) external; + + function getCampaign(uint256 campaignId) + external + view + returns (CampaignParams memory params, string memory uri, uint256 totalRedemptions); + + function redeem(uint256[] calldata considerationTokenIds, address recipient, bytes calldata extraData) + external + payable; +} diff --git a/assets/erc-7498/IRedemptionMintable.sol b/assets/erc-7498/IRedemptionMintable.sol new file mode 100644 index 0000000000..f79bf5caae --- /dev/null +++ b/assets/erc-7498/IRedemptionMintable.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {ConsiderationItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {TraitRedemption} from "./RedeemablesStructs.sol"; + +interface IRedemptionMintable { + function mintRedemption( + uint256 campaignId, + address recipient, + ConsiderationItem[] calldata consideration, + TraitRedemption[] calldata traitRedemptions + ) external; +} diff --git a/assets/erc-7498/RedeemablesErrors.sol b/assets/erc-7498/RedeemablesErrors.sol new file mode 100644 index 0000000000..653db7486c --- /dev/null +++ b/assets/erc-7498/RedeemablesErrors.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; +import {CampaignParams} from "./RedeemablesStructs.sol"; + +interface RedeemablesErrors { + /// Configuration errors + error NotManager(); + error InvalidTime(); + error ConsiderationItemRecipientCannotBeZeroAddress(); + error ConsiderationItemAmountCannotBeZero(); + error NonMatchingConsiderationItemAmounts(uint256 itemIndex, uint256 startAmount, uint256 endAmount); + + /// 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 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(); + error RequirementsIndexOutOfBounds(); + error ConsiderationItemInsufficientBalance(address token, uint256 balance, uint256 amount); + error EtherTransferFailed(); + error InvalidTxValue(uint256 got, uint256 want); + error InvalidConsiderationTokenIdSupplied(address token, uint256 got, uint256 want); + error TokenIdsDontMatchConsiderationLength(uint256 considerationLength, uint256 tokenIdsLength); +} diff --git a/assets/erc-7498/RedeemablesStructs.sol b/assets/erc-7498/RedeemablesStructs.sol new file mode 100644 index 0000000000..4b43223c59 --- /dev/null +++ b/assets/erc-7498/RedeemablesStructs.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.19; + +import {OfferItem, ConsiderationItem, SpentItem} from "seaport-types/src/lib/ConsiderationStructs.sol"; + +struct CampaignParams { + uint32 startTime; + uint32 endTime; + uint32 maxCampaignRedemptions; + address manager; + address signer; + CampaignRequirements[] requirements; +} + +struct CampaignRequirements { + OfferItem[] offer; + ConsiderationItem[] consideration; + TraitRedemption[] traitRedemptions; +} + +struct TraitRedemption { + uint8 substandard; + address token; + uint256 identifier; + bytes32 traitKey; + bytes32 traitValue; + bytes32 substandardValue; +} From 77eecf51a8b736ec6805b30e9fdb3a4330caf4d6 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Thu, 26 Oct 2023 10:28:28 -0700 Subject: [PATCH 02/13] fix eip -> erc links --- ERCS/erc-7498.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 45549411b6..ded5572000 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -13,7 +13,7 @@ requires: 165, 712, 721, 1155, 1271 ## Abstract -This specification introduces a new interface that extends [ERC-721](./eip-721.md) and [ERC-1155](./eip-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. +This specification introduces a new interface that extends [ERC-721](./erc-721.md) and [ERC-1155](./erc-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. ## Motivation @@ -28,7 +28,7 @@ Creators frequently use NFTs to create redeemable entitlements for digital and p The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. -The token MUST have the following interface and MUST return `true` for [ERC-165](./eip-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. +The token MUST have the following interface and MUST return `true` for [ERC-165](./erc-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. ```solidity interface IERC7501 { @@ -126,7 +126,7 @@ Including trait redemptions is optional, but if the token would like to enable t ### Signer -A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](./eip-712.md) or [ERC-1271](./eip-1271.md). +A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](https://eips.ethereum.org/EIPS/eip-712) or [ERC-1271](./erc-1271.md). The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,uint256[] considerationTokenIds,uint256[] traitRedemptionTokenIds,uint256 campaignId,uint256 requirementsIndex, bytes32 redemptionHash, uint256 salt)"` @@ -305,11 +305,11 @@ As a new EIP, no backwards compatibility issues are present. ## Test Cases -Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/eip-7498/ERC721ShipyardRedeemable.t.sol). +Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/erc-7498/ERC721ShipyardRedeemable.t.sol). ## Reference Implementation -Authors have included reference implementations of the specification in the [assets folder](../assets/eip-7498/ERC7498NFTRedeemables.sol). +Authors have included reference implementations of the specification in the [assets folder](../assets/erc-7498/ERC7498NFTRedeemables.sol). ## Security Considerations From 47959aff2673f1494ab72f9c6c4bc6384b9e64a8 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Fri, 3 Nov 2023 12:53:09 -0700 Subject: [PATCH 03/13] fix links --- ERCS/erc-7498.md | 10 +++++----- .../ERC721ShipyardRedeemable.sol | 0 .../ERC721ShipyardRedeemable.t.sol | 0 .../{erc-7498 => eip-7498}/ERC7498NFTRedeemables.sol | 0 assets/{erc-7498 => eip-7498}/IERC7498.sol | 0 assets/{erc-7498 => eip-7498}/IRedemptionMintable.sol | 0 assets/{erc-7498 => eip-7498}/RedeemablesErrors.sol | 0 assets/{erc-7498 => eip-7498}/RedeemablesStructs.sol | 0 8 files changed, 5 insertions(+), 5 deletions(-) rename assets/{erc-7498 => eip-7498}/ERC721ShipyardRedeemable.sol (100%) rename assets/{erc-7498 => eip-7498}/ERC721ShipyardRedeemable.t.sol (100%) rename assets/{erc-7498 => eip-7498}/ERC7498NFTRedeemables.sol (100%) rename assets/{erc-7498 => eip-7498}/IERC7498.sol (100%) rename assets/{erc-7498 => eip-7498}/IRedemptionMintable.sol (100%) rename assets/{erc-7498 => eip-7498}/RedeemablesErrors.sol (100%) rename assets/{erc-7498 => eip-7498}/RedeemablesStructs.sol (100%) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index ded5572000..47c250a5bf 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -13,7 +13,7 @@ requires: 165, 712, 721, 1155, 1271 ## Abstract -This specification introduces a new interface that extends [ERC-721](./erc-721.md) and [ERC-1155](./erc-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. +This specification introduces a new interface that extends [ERC-721](./eip-721.md) and [ERC-1155](./eip-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. ## Motivation @@ -28,7 +28,7 @@ Creators frequently use NFTs to create redeemable entitlements for digital and p The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. -The token MUST have the following interface and MUST return `true` for [ERC-165](./erc-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. +The token MUST have the following interface and MUST return `true` for [ERC-165](./eip-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. ```solidity interface IERC7501 { @@ -126,7 +126,7 @@ Including trait redemptions is optional, but if the token would like to enable t ### Signer -A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](https://eips.ethereum.org/EIPS/eip-712) or [ERC-1271](./erc-1271.md). +A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](https://eips.ethereum.org/EIPS/eip-712) or [ERC-1271](./eip-1271.md). The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,uint256[] considerationTokenIds,uint256[] traitRedemptionTokenIds,uint256 campaignId,uint256 requirementsIndex, bytes32 redemptionHash, uint256 salt)"` @@ -305,11 +305,11 @@ As a new EIP, no backwards compatibility issues are present. ## Test Cases -Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/erc-7498/ERC721ShipyardRedeemable.t.sol). +Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/eip-7498/ERC721ShipyardRedeemable.t.sol). ## Reference Implementation -Authors have included reference implementations of the specification in the [assets folder](../assets/erc-7498/ERC7498NFTRedeemables.sol). +Authors have included reference implementations of the specification in the [assets folder](../assets/eip-7498/ERC7498NFTRedeemables.sol). ## Security Considerations diff --git a/assets/erc-7498/ERC721ShipyardRedeemable.sol b/assets/eip-7498/ERC721ShipyardRedeemable.sol similarity index 100% rename from assets/erc-7498/ERC721ShipyardRedeemable.sol rename to assets/eip-7498/ERC721ShipyardRedeemable.sol diff --git a/assets/erc-7498/ERC721ShipyardRedeemable.t.sol b/assets/eip-7498/ERC721ShipyardRedeemable.t.sol similarity index 100% rename from assets/erc-7498/ERC721ShipyardRedeemable.t.sol rename to assets/eip-7498/ERC721ShipyardRedeemable.t.sol diff --git a/assets/erc-7498/ERC7498NFTRedeemables.sol b/assets/eip-7498/ERC7498NFTRedeemables.sol similarity index 100% rename from assets/erc-7498/ERC7498NFTRedeemables.sol rename to assets/eip-7498/ERC7498NFTRedeemables.sol diff --git a/assets/erc-7498/IERC7498.sol b/assets/eip-7498/IERC7498.sol similarity index 100% rename from assets/erc-7498/IERC7498.sol rename to assets/eip-7498/IERC7498.sol diff --git a/assets/erc-7498/IRedemptionMintable.sol b/assets/eip-7498/IRedemptionMintable.sol similarity index 100% rename from assets/erc-7498/IRedemptionMintable.sol rename to assets/eip-7498/IRedemptionMintable.sol diff --git a/assets/erc-7498/RedeemablesErrors.sol b/assets/eip-7498/RedeemablesErrors.sol similarity index 100% rename from assets/erc-7498/RedeemablesErrors.sol rename to assets/eip-7498/RedeemablesErrors.sol diff --git a/assets/erc-7498/RedeemablesStructs.sol b/assets/eip-7498/RedeemablesStructs.sol similarity index 100% rename from assets/erc-7498/RedeemablesStructs.sol rename to assets/eip-7498/RedeemablesStructs.sol From 2b15355c4865658066832ac729e546557f53876e Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Fri, 3 Nov 2023 12:58:32 -0700 Subject: [PATCH 04/13] ci fixes --- ERCS/erc-7498.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 47c250a5bf..05a797d4da 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -8,7 +8,7 @@ status: Draft type: Standards Track category: ERC created: 2023-07-28 -requires: 165, 712, 721, 1155, 1271 +requires: 165, 721, 1155, 1271 --- ## Abstract @@ -126,7 +126,7 @@ Including trait redemptions is optional, but if the token would like to enable t ### Signer -A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](https://eips.ethereum.org/EIPS/eip-712) or [ERC-1271](./eip-1271.md). +A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](./eip-712) or [ERC-1271](./eip-1271.md). The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,uint256[] considerationTokenIds,uint256[] traitRedemptionTokenIds,uint256 campaignId,uint256 requirementsIndex, bytes32 redemptionHash, uint256 salt)"` From 1e69eb678dca8eed42f94597884dd7b45794580e Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Fri, 3 Nov 2023 13:09:28 -0700 Subject: [PATCH 05/13] fix ci --- ERCS/erc-7498.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 05a797d4da..52888c9086 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -126,7 +126,7 @@ Including trait redemptions is optional, but if the token would like to enable t ### Signer -A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](./eip-712) or [ERC-1271](./eip-1271.md). +A signer MAY be specified to provide a signature to process the redemption. If the signer is not the null address, the signature MUST recover to the signer address via [EIP-712](./eip-712.md) or [ERC-1271](./eip-1271.md). The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,uint256[] considerationTokenIds,uint256[] traitRedemptionTokenIds,uint256 campaignId,uint256 requirementsIndex, bytes32 redemptionHash, uint256 salt)"` From 379aeaefcfa3661394c939d6c70c834c3439f4e9 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Fri, 3 Nov 2023 13:15:26 -0700 Subject: [PATCH 06/13] re-add requires --- ERCS/erc-7498.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 52888c9086..45549411b6 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -8,7 +8,7 @@ status: Draft type: Standards Track category: ERC created: 2023-07-28 -requires: 165, 721, 1155, 1271 +requires: 165, 712, 721, 1155, 1271 --- ## Abstract From d995392b7253cc936374f1afbd97b663450caee8 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Thu, 16 Nov 2023 13:07:27 -0800 Subject: [PATCH 07/13] spec updates --- ERCS/erc-7498.md | 172 +++++++++++++++++++++-------------------------- 1 file changed, 77 insertions(+), 95 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 45549411b6..0da1aa7bce 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -31,40 +31,42 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S The token MUST have the following interface and MUST return `true` for [ERC-165](./eip-165.md) supportsInterface for `0x1ac61e13`, the 4 byte interfaceId of the below. ```solidity -interface IERC7501 { +interface IERC7498 { /* Events */ - event CampaignUpdated(uint256 indexed campaignId, CampaignParams params, string URI); + event CampaignUpdated(uint256 indexed campaignId, Campaign campaign, string metadataURI); event Redemption(uint256 indexed campaignId, uint256 requirementsIndex, bytes32 redemptionHash, uint256[] considerationTokenIds, uint256[] traitRedemptionTokenIds, address redeemedBy); /* Structs */ + struct Campaign { + CampaignParams params; + CampaignRequirements[] requirements; // one requirement must be fully satisfied for a successful redemption + } 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 - CampaignRequirements[] requirements; // one requirement must be fully satisfied for a successful redemption + 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 } - struct Campaign { - OfferItem[] offer; // items to be minted, can be empty for offchain redeemable - ConsiderationItem[] consideration; // items transferring to recipient - TraitRedemption[] traitRedemptions; // the trait redemptions to process + struct CampaignRequirements { + OfferItem[] offer; + ConsiderationItem[] consideration; + TraitRedemption[] traitRedemptions; } 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); + function getCampaign(uint256 campaignId) external view returns (Campaign memory campaign, string memory metadataURI, 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 createCampaign(Campaign calldata campaign, string calldata metadataURI) external returns (uint256 campaignId); + function updateCampaign(uint256 campaignId, Campaign calldata campaign, string calldata metadataURI) external; function redeem(uint256[] calldata considerationTokenIds, address recipient, bytes calldata extraData) external payable; } @@ -187,101 +189,81 @@ The metadata URI MUST conform to the below JSON schema: "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "A one-line summary of the redeemable. Markdown is not supported." - }, - "details": { - "type": "string", - "description": "A multi-line or multi-paragraph description of the details of the redeemable. Markdown is supported." - }, - "imageUrls": { - "type": "array", - "items": { - "type": "string" - }, - "description": "A list of image URLs for the redeemable. The first image will be used as the thumbnail. Will rotate in a carousel if multiple images are provided. Maximum 5 images." - }, - "bannerUrl": { - "type": "string", - "description": "The banner image for the redeemable." - }, - "faq": { + "campaigns": { "type": "array", "items": { "type": "object", "properties": { - "question": { - "type": "string" + "campaignId": { + "type": "number" }, - "answer": { + "name": { "type": "string" }, - "required": ["question", "answer"] - } - } - }, - "contentLocale": { - "type": "string", - "description": "The language tag for the content provided by this metadata. https://www.rfc-editor.org/rfc/rfc9110.html#name-language-tags" - }, - "maxRedemptionsPerToken": { - "type": "string", - "description": "The maximum number of redemptions per token. When isBurn is true should be 1, else can be a number based on the trait redemptions limit." - }, - "isBurn": { - "type": "string", - "description": "If the redemption burns the token." - }, - "uuid": { - "type": "string", - "description": "An optional unique identifier for the campaign, for backends to identify when draft campaigns are published onchain." - }, - "productLimitForRedemption": { - "type": "number", - "description": "The number of products which are able to be chosen from the products array for a single redemption." - }, - "products": { - "type": "object", - "properties": "https://schema.org/Product", - "required": ["name", "url", "description"] - }, - "traitRedemptions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "substandard": { - "type": "number" + "description": { + "type": "string", + "description": "A one-line summary of the redeemable. Markdown is not supported." }, - "token": { + "details": { "type": "string", - "description": "The token address" + "description": "A multi-line or multi-paragraph description of the details of the redeemable. Markdown is supported." }, - "traitKey": { - "type": "string" + "imageUrls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of image URLs for the redeemable. The first image will be used as the thumbnail. Will rotate in a carousel if multiple images are provided. Maximum 5 images." }, - "traitValue": { - "type": "string" + "bannerUrl": { + "type": "string", + "description": "The banner image for the redeemable." }, - "substandardValue": { - "type": "string" + "faq": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": { + "type": "string" + }, + "answer": { + "type": "string" + }, + "required": ["question", "answer"] + } + } + }, + "contentLocale": { + "type": "string", + "description": "The language tag for the content provided by this metadata. https://www.rfc-editor.org/rfc/rfc9110.html#name-language-tags" + }, + "maxRedemptionsPerToken": { + "type": "string", + "description": "The maximum number of redemptions per token. When isBurn is true should be 1, else can be a number based on the trait redemptions limit." + }, + "isBurn": { + "type": "string", + "description": "If the redemption burns the token." + }, + "uuid": { + "type": "string", + "description": "An optional unique identifier for the campaign, for backends to identify when draft campaigns are published onchain." + }, + "productLimitForRedemption": { + "type": "number", + "description": "The number of products which are able to be chosen from the products array for a single redemption." + }, + "products": { + "type": "object", + "properties": "https://schema.org/Product", + "required": ["name", "url", "description"] } }, - "required": [ - "substandard", - "token", - "traitKey", - "traitValue", - "substandardValue" - ] + "required": ["campaignId", "name", "description", "imageUrls", "isBurn"] } } - }, - "required": ["name", "description", "isBurn"] + } } ``` From 861c47e197ebe4c02959c25239e70614b4c90f57 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Thu, 16 Nov 2023 16:28:41 -0800 Subject: [PATCH 08/13] add offer item to IRedemptionMintable --- ERCS/erc-7498.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 0da1aa7bce..cb18ddb257 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -108,11 +108,17 @@ Updates to campaigns MUST use `updateCampaign` and MUST emit the `CampaignUpdate ### Offer -If tokens are set in the params `offer`, the tokens MUST implement the `IRedemptionMintable` interface in order to support minting new items. The implementation SHOULD be however the token mechanics are desired. The implementing token MUST return true for ERC-165 `supportsInterface` for the interfaceId of `IRedemptionMintable`, `0xf38dd076`. +If tokens are set in the params `offer`, the tokens MUST implement the `IRedemptionMintable` interface in order to support minting new items. The implementation SHOULD be however the token mechanics are desired. The implementing token MUST return true for ERC-165 `supportsInterface` for the interfaceId of `IRedemptionMintable`, `0x81fe13c2`. -```solidify +```solidity interface IRedemptionMintable { - function mintRedemption(uint256 campaignId, address recipient, ConsiderationItem[] calldata consideration, TraitRedemptions[] calldata traitRedemptions) external; + function mintRedemption( + uint256 campaignId, + address recipient, + OfferItem calldata offer, + ConsiderationItem[] calldata consideration, + TraitRedemption[] calldata traitRedemptions + ) external; } ``` From 74e5f6e464ec675c2945afb510dde216b6c14887 Mon Sep 17 00:00:00 2001 From: Sam Wilson <57262657+SamWilsn@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:10:42 -0500 Subject: [PATCH 09/13] Update erc-7498.md --- ERCS/erc-7498.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index cb18ddb257..ee575103e0 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -22,7 +22,7 @@ Creators frequently use NFTs to create redeemable entitlements for digital and p - discovery: events and getters that provide information about the requirements of a redemption campaign - onchain: token mints with context of items spent - offchain: the ability to associate with ecommerce orders (through `redemptionHash`) -- trait redemptions: improving the burn-to-redeem experience with E RC-7496 Dynamic Traits. +- trait redemptions: improving the burn-to-redeem experience with [ERC-7496](./eip-7496.md) Dynamic Traits. ## Specification @@ -130,7 +130,7 @@ Any token may be specified in the campaign requirement `consideration`. This wil ### Dynamic traits -Including trait redemptions is optional, but if the token would like to enable trait redemptions the token MUST include E RC-7496 Dynamic Traits. +Including trait redemptions is optional, but if the token would like to enable trait redemptions the token MUST include [ERC-7496](./eip-7496.md) Dynamic Traits. ### Signer @@ -143,7 +143,7 @@ The EIP-712 struct for signing MUST be as follows: `SignedRedeem(address owner,u The `redeem` function MUST use the `consideration`, `offer`, and `traitRedemptions` specified by the `requirements` determined by the `campaignId` and `requirementsIndex`: - Execute the transfers in the `consideration` -- Mutate the traits specified by `traitRedemptions` according to ERC -7496 Dynamic Traits +- Mutate the traits specified by `traitRedemptions` according to ERC-7496 Dynamic Traits - Call `mintRedemption()` on every `offer` item The `Redemption` event MUST be emitted for every valid redemption that occurs. @@ -285,7 +285,7 @@ The "offer" and "consideration" structs from Seaport were used to create a simil The "salt" and "signature" fields are provided primarily for offchain redemptions where a provider would want to sign approval for a redemption before it is conducted onchain, to prevent the need for irregular state changes. For example, if a user lives outside a region supported by the shipping of an offchain redeemable, during the offchain order creation process the signature would not be provided for the onchain redemption when seeing that the user's shipping country is unsupported. This prevents the user from redeeming the NFT, then later finding out the shipping isn't supported after their NFT is already burned or trait is mutated. -E RC-7496 Dynamic Traits is used for trait redemptions to support onchain enforcement of trait values for secondary market orders. +ERC-7496](./eip-7496.md) Dynamic Traits is used for trait redemptions to support onchain enforcement of trait values for secondary market orders. ## Backwards Compatibility @@ -301,7 +301,7 @@ Authors have included reference implementations of the specification in the [ass ## Security Considerations -If trait redemptions are desired, tokens implementing this EIP must properly implement E RC-7496 Dynamic Traits. +If trait redemptions are desired, tokens implementing this EIP must properly implement [ERC-7496](./eip-7496.md) Dynamic Traits. For tokens to be minted as part of the params `offer`, the `mintRedemption` function contained as part of `IRedemptionMintable` MUST be permissioned and ONLY allowed to be called by specified addresses. From 153445a8ae2d3c0fe05f398a39c61c51f794bc21 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Tue, 28 Nov 2023 07:49:54 -0800 Subject: [PATCH 10/13] update name for assets directory --- ERCS/erc-7498.md | 4 ++-- assets/{eip-7498 => erc-7498}/ERC721ShipyardRedeemable.sol | 0 assets/{eip-7498 => erc-7498}/ERC721ShipyardRedeemable.t.sol | 0 assets/{eip-7498 => erc-7498}/ERC7498NFTRedeemables.sol | 0 assets/{eip-7498 => erc-7498}/IERC7498.sol | 0 assets/{eip-7498 => erc-7498}/IRedemptionMintable.sol | 0 assets/{eip-7498 => erc-7498}/RedeemablesErrors.sol | 0 assets/{eip-7498 => erc-7498}/RedeemablesStructs.sol | 0 8 files changed, 2 insertions(+), 2 deletions(-) rename assets/{eip-7498 => erc-7498}/ERC721ShipyardRedeemable.sol (100%) rename assets/{eip-7498 => erc-7498}/ERC721ShipyardRedeemable.t.sol (100%) rename assets/{eip-7498 => erc-7498}/ERC7498NFTRedeemables.sol (100%) rename assets/{eip-7498 => erc-7498}/IERC7498.sol (100%) rename assets/{eip-7498 => erc-7498}/IRedemptionMintable.sol (100%) rename assets/{eip-7498 => erc-7498}/RedeemablesErrors.sol (100%) rename assets/{eip-7498 => erc-7498}/RedeemablesStructs.sol (100%) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index ee575103e0..512e39ba30 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -293,11 +293,11 @@ As a new EIP, no backwards compatibility issues are present. ## Test Cases -Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/eip-7498/ERC721ShipyardRedeemable.t.sol). +Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/erc-7498/ERC721ShipyardRedeemable.t.sol). ## Reference Implementation -Authors have included reference implementations of the specification in the [assets folder](../assets/eip-7498/ERC7498NFTRedeemables.sol). +Authors have included reference implementations of the specification in the [assets folder](../assets/erc-7498/ERC7498NFTRedeemables.sol). ## Security Considerations diff --git a/assets/eip-7498/ERC721ShipyardRedeemable.sol b/assets/erc-7498/ERC721ShipyardRedeemable.sol similarity index 100% rename from assets/eip-7498/ERC721ShipyardRedeemable.sol rename to assets/erc-7498/ERC721ShipyardRedeemable.sol diff --git a/assets/eip-7498/ERC721ShipyardRedeemable.t.sol b/assets/erc-7498/ERC721ShipyardRedeemable.t.sol similarity index 100% rename from assets/eip-7498/ERC721ShipyardRedeemable.t.sol rename to assets/erc-7498/ERC721ShipyardRedeemable.t.sol diff --git a/assets/eip-7498/ERC7498NFTRedeemables.sol b/assets/erc-7498/ERC7498NFTRedeemables.sol similarity index 100% rename from assets/eip-7498/ERC7498NFTRedeemables.sol rename to assets/erc-7498/ERC7498NFTRedeemables.sol diff --git a/assets/eip-7498/IERC7498.sol b/assets/erc-7498/IERC7498.sol similarity index 100% rename from assets/eip-7498/IERC7498.sol rename to assets/erc-7498/IERC7498.sol diff --git a/assets/eip-7498/IRedemptionMintable.sol b/assets/erc-7498/IRedemptionMintable.sol similarity index 100% rename from assets/eip-7498/IRedemptionMintable.sol rename to assets/erc-7498/IRedemptionMintable.sol diff --git a/assets/eip-7498/RedeemablesErrors.sol b/assets/erc-7498/RedeemablesErrors.sol similarity index 100% rename from assets/eip-7498/RedeemablesErrors.sol rename to assets/erc-7498/RedeemablesErrors.sol diff --git a/assets/eip-7498/RedeemablesStructs.sol b/assets/erc-7498/RedeemablesStructs.sol similarity index 100% rename from assets/eip-7498/RedeemablesStructs.sol rename to assets/erc-7498/RedeemablesStructs.sol From 27ae1680f19e0f6f0814b1f3fc52377ec141f260 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Tue, 28 Nov 2023 08:12:40 -0800 Subject: [PATCH 11/13] address review comments: - improve abstract - clarify campaign id incrementing counter - change MUST to MAY for using `updateCampaign` --- ERCS/erc-7498.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 512e39ba30..87c3b0126f 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -13,7 +13,7 @@ requires: 165, 712, 721, 1155, 1271 ## Abstract -This specification introduces a new interface that extends [ERC-721](./eip-721.md) and [ERC-1155](./eip-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. +This specification introduces a new interface that extends [ERC-721](./eip-721.md) and [ERC-1155](./eip-1155.md) to enable the discovery and use of onchain and offchain redeemables for NFTs. Onchain getters and events facilitate discovery of redeemable campaigns and their requirements. New onchain mints use an interface that gives context to the minting contract of what was redeemed. For redeeming physical products and goods (offchain redeemables) a `redemptionHash` and `signer` can tie onchain redemptions with offchain order identifiers that contain chosen product and shipping information. ## Motivation @@ -100,11 +100,11 @@ struct SpentItem { ### Creating campaigns -When creating a new campaign, `createCampaign` MUST be used and MUST return the newly created `campaignId` along with the `CampaignUpdated` event. The `campaignId` MUST be an incrementing counter starting at `1`. +When creating a new campaign, `createCampaign` MUST be used and MUST return the newly created `campaignId` along with the `CampaignUpdated` event. The `campaignId` MUST be a counter incremented with each new campaign. The first campaign MUST have an id of `1`. ### Updating campaigns -Updates to campaigns MUST use `updateCampaign` and MUST emit the `CampaignUpdated` event. If an address other than the `manager` tries to update the campaign, it MUST revert with `NotManager()`. If the manager wishes to make the campaign immutable, the `manager` MAY be set to the null address. +Updates to campaigns MAY use `updateCampaign` and MUST emit the `CampaignUpdated` event. If an address other than the `manager` tries to update the campaign, it MUST revert with `NotManager()`. If the manager wishes to make the campaign immutable, the `manager` MAY be set to the null address. ### Offer From d78b0b7c73ed8cde792dfa55fb1f50425cf622e5 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Tue, 28 Nov 2023 08:14:49 -0800 Subject: [PATCH 12/13] still link to assets/eip- even though folder is named assetc/erc- --- ERCS/erc-7498.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 87c3b0126f..2c1ebb6936 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -293,11 +293,11 @@ As a new EIP, no backwards compatibility issues are present. ## Test Cases -Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/erc-7498/ERC721ShipyardRedeemable.t.sol). +Authors have included Foundry tests covering functionality of the specification in the [assets folder](../assets/eip-7498/ERC721ShipyardRedeemable.t.sol). ## Reference Implementation -Authors have included reference implementations of the specification in the [assets folder](../assets/erc-7498/ERC7498NFTRedeemables.sol). +Authors have included reference implementations of the specification in the [assets folder](../assets/eip-7498/ERC7498NFTRedeemables.sol). ## Security Considerations From 6201b47a48c1f4627da65628a7af294420c52352 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Wed, 29 Nov 2023 10:02:23 -0800 Subject: [PATCH 13/13] fix formatting typo --- ERCS/erc-7498.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ERCS/erc-7498.md b/ERCS/erc-7498.md index 2c1ebb6936..3789c4b3e0 100644 --- a/ERCS/erc-7498.md +++ b/ERCS/erc-7498.md @@ -285,7 +285,7 @@ The "offer" and "consideration" structs from Seaport were used to create a simil The "salt" and "signature" fields are provided primarily for offchain redemptions where a provider would want to sign approval for a redemption before it is conducted onchain, to prevent the need for irregular state changes. For example, if a user lives outside a region supported by the shipping of an offchain redeemable, during the offchain order creation process the signature would not be provided for the onchain redemption when seeing that the user's shipping country is unsupported. This prevents the user from redeeming the NFT, then later finding out the shipping isn't supported after their NFT is already burned or trait is mutated. -ERC-7496](./eip-7496.md) Dynamic Traits is used for trait redemptions to support onchain enforcement of trait values for secondary market orders. +[ERC-7496](./eip-7496.md) Dynamic Traits is used for trait redemptions to support onchain enforcement of trait values for secondary market orders. ## Backwards Compatibility