diff --git a/.gitmodules b/.gitmodules index 974254b..2386822 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "evm/lib/forge-std"] path = evm/lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "evm/lib/openzeppelin-contracts"] + path = evm/lib/openzeppelin-contracts + url = https://github.com/Openzeppelin/openzeppelin-contracts +[submodule "evm/lib/ERC721A"] + path = evm/lib/ERC721A + url = https://github.com/chiru-labs/ERC721A diff --git a/evm/foundry.toml b/evm/foundry.toml index 14a5117..cc90b82 100644 --- a/evm/foundry.toml +++ b/evm/foundry.toml @@ -3,6 +3,11 @@ solc_version = "0.8.23" optimizer = false via_ir = true +remappings = [ + "@escrin/=lib/escrin", + "erc721a/=lib/ERC721A", +] + [fmt] line_length = 100 diff --git a/evm/lib/ERC721A b/evm/lib/ERC721A new file mode 160000 index 0000000..6f8a82a --- /dev/null +++ b/evm/lib/ERC721A @@ -0,0 +1 @@ +Subproject commit 6f8a82a7b2833ad8b2fc7b54349281143a731fdd diff --git a/evm/lib/escrin b/evm/lib/escrin new file mode 160000 index 0000000..d7b551a --- /dev/null +++ b/evm/lib/escrin @@ -0,0 +1 @@ +Subproject commit d7b551ad25a54a2bee01651c5f2c48e1ee30a5c7 diff --git a/evm/lib/openzeppelin-contracts b/evm/lib/openzeppelin-contracts new file mode 160000 index 0000000..dbb6104 --- /dev/null +++ b/evm/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 diff --git a/evm/src/NFTrout.sol b/evm/src/NFTrout.sol new file mode 100644 index 0000000..a15f370 --- /dev/null +++ b/evm/src/NFTrout.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import { + ITaskAcceptor, TaskAcceptor +} from "@escrin/evm/contracts/tasks/v1/acceptors/TaskAcceptor.sol"; +import {TimelockedDelegatedTaskAcceptor} from + "@escrin/evm/contracts/tasks/v1/acceptors/DelegatedTaskAcceptor.sol"; +import {TimelockedDelegatedTaskAcceptor} from + "@escrin/evm/contracts/tasks/v1/acceptors/DelegatedTaskAcceptor.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import { + IERC165, ERC165Checker +} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; + +import {IERC721A, ERC721A} from "erc721a/contracts/ERC721A.sol"; +import {ERC721ABurnable} from "erc721a/contracts/extensions/ERC721ABurnable.sol"; +import {ERC721ABurnable} from "erc721a/contracts/extensions/ERC721ABurnable.sol"; +import {ERC721AQueryable} from "erc721a/contracts/extensions/ERC721AQueryable.sol"; + +contract NFTrout is + ERC721A, + ERC721ABurnable, + ERC721AQueryable, + TimelockedDelegatedTaskAcceptor, + Ownable, + Pausable +{ + using SafeERC20 for IERC20; + + type TokenId is uint256; + + /// The token does not exist; + error NoSuchToken(TokenId id); // 08ff8e94 CP+OlA== + + /// The trout is no longer breedable. + event Delisted(); + event TasksAccepted(); + + struct ClaimRange { + uint256 startTokenId; + uint256 quantity; + } + + bytes32 private immutable claimantsRoot; + string private urlPrefix; + string private urlSuffix; + + IERC20 public immutable paymentToken; + uint256 public mintFee; + mapping(address => uint256) public earnings; + mapping(TokenId => uint256) public studFees; + + constructor( + address upstreamAcceptor, + uint64 initialAcceptorTimelock, + address paymentTokenAddr, + uint256 numClaimants, + bytes32 claimantsMerkleRoot, + uint256 initialMintFee + ) + ERC721A("NFTrout", "TROUT") + TimelockedDelegatedTaskAcceptor(upstreamAcceptor, initialAcceptorTimelock) + Ownable(msg.sender) + { + require( + ERC165Checker.supportsInterface(paymentTokenAddr, type(IERC20).interfaceId), + "bad payment token" + ); + + mintFee = initialMintFee; + claimantsRoot = claimantsMerkleRoot; + + uint256 divisor = 9; + uint256 batches = numClaimants >> divisor; + uint256 remainder = numClaimants - (batches << divisor); + for (uint256 i; i < batches; i++) { + _mintERC2309(address(this), 1 << divisor); + } + if (remainder > 0) { + _mintERC2309(address(this), remainder); + } + } + + function claim(address claimant, ClaimRange[] calldata ranges, bytes32[] calldata proof) + external + { + bytes32 leaf = keccak256(abi.encode(claimant, ranges)); + if (!MerkleProof.verifyCalldata(proof, claimantsRoot, leaf)) revert Unauthorized(); + for (uint256 i; i < ranges.length; i++) { + for (uint256 j; i < ranges[i].quantity; j++) { + safeTransferFrom(address(this), claimant, ranges[i].startTokenId + j); + } + } + } + + function mint(uint256 quantity) external whenNotPaused { + uint256 fee = mintFee * quantity; + _earn(owner(), fee); + _safeMint(msg.sender, quantity); + paymentToken.safeTransferFrom(msg.sender, address(this), fee); + } + + /// Breeds any two trout to produce a third trout that will be owned by the caller. + /// This method must be called with enough value to pay for the two trouts' fees and the minting fee. + function breed(TokenId[] calldata lefts, TokenId[] calldata rights) external whenNotPaused { + require(lefts.length == rights.length, "mismatched lengths"); + uint256 quantity = lefts.length; + + uint256 fee = _earn(owner(), quantity * mintFee); + for (uint256 i; i < quantity; i++) { + (TokenId left, TokenId right) = (lefts[i], rights[i]); + require(TokenId.unwrap(left) != TokenId.unwrap(right), "cannot self-breed"); + if (!_exists(left)) revert NoSuchToken(left); + if (!_exists(right)) revert NoSuchToken(right); + fee += _earn(_ownerOf(left), getBreedingFee(msg.sender, left)); + fee += _earn(_ownerOf(right), getBreedingFee(msg.sender, right)); + } + + _safeMint(msg.sender, quantity); + + paymentToken.safeTransferFrom(msg.sender, address(this), fee); + } + + /// Makes a trout not breedable. + function delist(TokenId tokenId) external { + if (msg.sender != _ownerOf(tokenId)) revert Unauthorized(); + studFees[tokenId] = 0; + emit Delisted(); + } + + function withdraw() external { + uint256 stack = earnings[msg.sender]; + earnings[msg.sender] = 0; + paymentToken.safeTransferFrom(address(this), msg.sender, stack); + } + + function setUrlComponents(string calldata prefix, string calldata suffix) external onlyOwner { + (urlPrefix, urlSuffix) = (prefix, suffix); + } + + function setMintFee(uint256 fee) external onlyOwner { + mintFee = fee; + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + /// Returns a cost for the payer to breed the trout that is no larger than the list price. + function getBreedingFee(address breeder, TokenId tokenId) public view returns (uint256) { + if (TokenId.unwrap(tokenId) == 0 || breeder == _ownerOf(tokenId)) return 0; + uint256 fee = studFees[tokenId]; + if (fee == 0) revert Unauthorized(); + return fee; + } + + function tokenURI(uint256 tokenId) + public + view + override(IERC721A, ERC721A) + returns (string memory) + { + return string.concat(urlPrefix, Strings.toString(tokenId)); + } + + function supportsInterface(bytes4 interfaceId) + public + pure + override(IERC721A, ERC721A, TaskAcceptor) + returns (bool) + { + return interfaceId == type(IERC165).interfaceId + || interfaceId == type(ITaskAcceptor).interfaceId + || interfaceId == type(IERC721A).interfaceId; + } + + struct Task { + TaskKind kind; + bytes payload; + } + + enum TaskKind { + Unknown, + Mint, + Burn, + List + } + + struct MintTask { + RecipentQuantity[] outputs; + } + + struct RecipentQuantity { + address recipient; + uint256 quantity; + } + + struct BurnTask { + TokenId[] tokens; + } + + struct ListTask { + StudFee[] listings; + } + + struct StudFee { + TokenId stud; + uint256 fee; + } + + function _afterTaskResultsAccepted( + uint256[] calldata, + bytes calldata report, + TaskIdSelector memory + ) internal override { + Task[] memory tasks = abi.decode(report, (Task[])); + for (uint256 i; i < tasks.length; i++) { + Task memory task = tasks[i]; + + if (task.kind == TaskKind.Mint) { + MintTask memory mintTask = abi.decode(task.payload, (MintTask)); + for (uint256 j; j < mintTask.outputs.length; j++) { + _safeMint(mintTask.outputs[j].recipient, mintTask.outputs[j].quantity); + } + return; + } + + if (task.kind == TaskKind.Burn) { + BurnTask memory burnTask = abi.decode(task.payload, (BurnTask)); + for (uint256 j; j < burnTask.tokens.length; j++) { + _burn(TokenId.unwrap(burnTask.tokens[j])); + } + return; + } + + if (task.kind == TaskKind.List) { + ListTask memory listTask = abi.decode(task.payload, (ListTask)); + for (uint256 j; j < listTask.listings.length; j++) { + studFees[listTask.listings[j].stud] = listTask.listings[j].fee; + } + return; + } + + revert("unknown task kind"); + } + emit TasksAccepted(); + } + + function _afterTokenTransfers(address from, address, uint256 startTokenId, uint256 quantity) + internal + override + { + if (from == address(0)) return; + for (uint256 i = startTokenId; i < quantity; i++) { + studFees[TokenId.wrap(i)] = 0; + } + } + + function _earn(address payee, uint256 amount) internal returns (uint256) { + earnings[payee] += amount; + return amount; + } + + function _ownerOf(TokenId id) internal view returns (address) { + return ownerOf(TokenId.unwrap(id)); + } + + function _exists(TokenId id) internal view returns (bool) { + return _exists(TokenId.unwrap(id)); + } + + function _startTokenId() internal pure override returns (uint256) { + return 1; + } +}