diff --git a/contracts/src/ERC404NullOwnerCappedUpgradeable.sol b/contracts/src/ERC404NullOwnerCappedUpgradeable.sol index ee66299..58db54a 100644 --- a/contracts/src/ERC404NullOwnerCappedUpgradeable.sol +++ b/contracts/src/ERC404NullOwnerCappedUpgradeable.sol @@ -3,13 +3,10 @@ pragma solidity 0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {IERC721Receiver} from "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol"; import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; -import "./interfaces/IERC404.sol"; -import "./lib/DoubleEndedQueue.sol"; /// @title ERC404NullOwnerCappedUpgradeable /// @notice Hybrid ERC20/ERC721 implementation with null owner support, supply cap, and upgradeability @@ -20,11 +17,8 @@ abstract contract ERC404NullOwnerCappedUpgradeable is IERC165, IERC20, IERC20Metadata, - IERC20Errors, - IERC404 + IERC20Errors { - using DoubleEndedQueue for DoubleEndedQueue.Uint256Deque; - struct TokenData { address owner; // current owner (can be address(0) for null-owner) uint88 index; // position in owned[owner] array @@ -43,32 +37,18 @@ abstract contract ERC404NullOwnerCappedUpgradeable is uint256 totalSupply; uint256 cap; - // === ERC404 NFT State === - DoubleEndedQueue.Uint256Deque storedERC721Ids; mapping(address => uint256[]) owned; mapping(uint256 => TokenData) tokens; mapping(uint256 => address) getApproved; mapping(address => mapping(address => bool)) isApprovedForAll; - mapping(address => bool) erc721TransferExempt; uint256 minted; // Number of NFTs minted uint256 units; // Units for NFT minting (e.g., 1000 * 10^18) - uint256 initialChainId; - bytes32 initialDomainSeparator; - mapping(address => uint256) nonces; // === Metadata === string name; string symbol; } - - // ============================================================= - // CONSTANTS - // ============================================================= - - /// @dev Unique storage slot for EIP-7201 namespaced storage - /// keccak256(abi.encode(uint256(keccak256("ethscriptions.storage.ERC404NullOwnerCapped")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant STORAGE_LOCATION = 0x8a0c9d8e5f7b3a2c1d4e6f8a9b7c5d3e2f1a4b6c8d9e7f5a3b2c1d4e6f8a9b00; - + // ============================================================= // EVENTS // ============================================================= @@ -76,11 +56,7 @@ abstract contract ERC404NullOwnerCappedUpgradeable is // ERC20 Events are inherited from IERC20 (Transfer, Approval) // ERC721 Events (using different names to avoid conflicts with ERC20) - // event Transfer(address indexed from, address indexed to, uint256 value); - event ERC20Transfer(address indexed from, address indexed to, uint256 value); event ERC721Transfer(address indexed from, address indexed to, uint256 indexed id); - event ERC721Approval(address indexed owner, address indexed spender, uint256 indexed id); - event ApprovalForAll(address indexed owner, address indexed operator, bool approved); // ============================================================= // CUSTOM ERRORS @@ -91,14 +67,22 @@ abstract contract ERC404NullOwnerCappedUpgradeable is error ERC20InvalidCap(uint256 cap); error InvalidUnits(uint256 units); error NotImplemented(); + error NotFound(); + error InvalidTokenId(); + error AlreadyExists(); + error InvalidRecipient(); + error Unauthorized(); + error OwnedIndexOverflow(); // ============================================================= // STORAGE ACCESSOR // ============================================================= function _getS() internal pure returns (TokenStorage storage $) { + bytes32 slot = keccak256("ethscriptions.storage.ERC404NullOwnerCapped"); + assembly { - $.slot := STORAGE_LOCATION + $.slot := slot } } @@ -132,25 +116,23 @@ abstract contract ERC404NullOwnerCappedUpgradeable is $.symbol = symbol_; $.cap = cap_; $.units = units_; - $.initialChainId = block.chainid; - $.initialDomainSeparator = _computeDomainSeparator(); } // ============================================================= // ERC20 METADATA VIEWS // ============================================================= - function name() public view virtual override(IERC404, IERC20Metadata) returns (string memory) { + function name() public view virtual override(IERC20Metadata) returns (string memory) { TokenStorage storage $ = _getS(); return $.name; } - function symbol() public view virtual override(IERC404, IERC20Metadata) returns (string memory) { + function symbol() public view virtual override(IERC20Metadata) returns (string memory) { TokenStorage storage $ = _getS(); return $.symbol; } - function decimals() public pure override(IERC404, IERC20Metadata) returns (uint8) { + function decimals() public pure override(IERC20Metadata) returns (uint8) { return 18; } @@ -158,12 +140,12 @@ abstract contract ERC404NullOwnerCappedUpgradeable is // ERC20 VIEWS // ============================================================= - function totalSupply() public view virtual override(IERC404, IERC20) returns (uint256) { + function totalSupply() public view virtual override returns (uint256) { TokenStorage storage $ = _getS(); return $.totalSupply; } - function balanceOf(address account) public view virtual override(IERC404, IERC20) returns (uint256) { + function balanceOf(address account) public view virtual override returns (uint256) { TokenStorage storage $ = _getS(); return $.balances[account]; } @@ -179,7 +161,7 @@ abstract contract ERC404NullOwnerCappedUpgradeable is return t.owner == owner_ ? 1 : 0; } - function allowance(address owner, address spender) public view virtual override(IERC404, IERC20) returns (uint256) { + function allowance(address owner, address spender) public view virtual override returns (uint256) { TokenStorage storage $ = _getS(); return $.allowances[owner][spender]; } @@ -196,17 +178,17 @@ abstract contract ERC404NullOwnerCappedUpgradeable is // ERC721 VIEWS // ============================================================= - function erc721TotalSupply() public view virtual override(IERC404) returns (uint256) { + function erc721TotalSupply() public view virtual returns (uint256) { TokenStorage storage $ = _getS(); return $.minted; } - function erc721BalanceOf(address owner_) public view virtual override(IERC404) returns (uint256) { + function erc721BalanceOf(address owner_) public view virtual returns (uint256) { TokenStorage storage $ = _getS(); return $.owned[owner_].length; } - function ownerOf(uint256 id_) public view virtual override(IERC404) returns (address) { + function ownerOf(uint256 id_) public view virtual returns (address) { _validateTokenId(id_); TokenStorage storage $ = _getS(); TokenData storage t = $.tokens[id_]; @@ -216,7 +198,7 @@ abstract contract ERC404NullOwnerCappedUpgradeable is return t.owner; } - function owned(address owner_) public view virtual override(IERC404) returns (uint256[] memory) { + function owned(address owner_) public view virtual returns (uint256[] memory) { TokenStorage storage $ = _getS(); return $.owned[owner_]; } @@ -229,42 +211,11 @@ abstract contract ERC404NullOwnerCappedUpgradeable is return $.getApproved[id_]; } - function isApprovedForAll(address owner_, address operator_) public view virtual override(IERC404) returns (bool) { + function isApprovedForAll(address owner_, address operator_) public view virtual returns (bool) { TokenStorage storage $ = _getS(); return $.isApprovedForAll[owner_][operator_]; } - function erc721TransferExempt(address account_) public view virtual override returns (bool) { - TokenStorage storage $ = _getS(); - return $.erc721TransferExempt[account_]; - } - - // ============================================================= - // QUEUE VIEWS - // ============================================================= - - function getERC721QueueLength() public view virtual override returns (uint256) { - TokenStorage storage $ = _getS(); - return $.storedERC721Ids.length(); - } - - function getERC721TokensInQueue( - uint256 start_, - uint256 count_ - ) public view virtual override returns (uint256[] memory) { - TokenStorage storage $ = _getS(); - uint256[] memory tokensInQueue = new uint256[](count_); - - for (uint256 i = start_; i < start_ + count_;) { - tokensInQueue[i - start_] = $.storedERC721Ids.at(i); - unchecked { - ++i; - } - } - - return tokensInQueue; - } - // ============================================================= // OTHER VIEWS // ============================================================= @@ -285,29 +236,29 @@ abstract contract ERC404NullOwnerCappedUpgradeable is } /// @notice tokenURI must be implemented by child contract - function tokenURI(uint256 id_) public view virtual override(IERC404) returns (string memory); + function tokenURI(uint256 id_) public view virtual returns (string memory); // ============================================================= // ERC20 OPERATIONS // ============================================================= - function transfer(address, uint256) public pure virtual override(IERC404, IERC20) returns (bool) { + function transfer(address, uint256) public pure virtual override returns (bool) { revert NotImplemented(); } - function approve(address, uint256) public pure virtual override(IERC404, IERC20) returns (bool) { + function approve(address, uint256) public pure virtual override returns (bool) { revert NotImplemented(); } - function transferFrom(address, address, uint256) public pure virtual override(IERC404, IERC20) returns (bool) { + function transferFrom(address, address, uint256) public pure virtual override returns (bool) { revert NotImplemented(); } - function erc20Approve(address, uint256) public pure virtual override returns (bool) { + function erc20Approve(address, uint256) public pure virtual returns (bool) { revert NotImplemented(); } - function erc20TransferFrom(address, address, uint256) public pure virtual override returns (bool) { + function erc20TransferFrom(address, address, uint256) public pure virtual returns (bool) { revert NotImplemented(); } @@ -315,27 +266,23 @@ abstract contract ERC404NullOwnerCappedUpgradeable is // ERC721 OPERATIONS // ============================================================= - function erc721Approve(address, uint256) public pure virtual override { + function erc721Approve(address, uint256) public pure virtual { revert NotImplemented(); } - function erc721TransferFrom(address, address, uint256) public pure virtual override { + function erc721TransferFrom(address, address, uint256) public pure virtual { revert NotImplemented(); } - function setApprovalForAll(address, bool) public pure virtual override { + function setApprovalForAll(address, bool) public pure virtual { revert NotImplemented(); } - function safeTransferFrom(address, address, uint256) public pure virtual override { + function safeTransferFrom(address, address, uint256) public pure virtual { revert NotImplemented(); } - function safeTransferFrom(address, address, uint256, bytes memory) public pure virtual override { - revert NotImplemented(); - } - - function setSelfERC721TransferExempt(bool) public pure virtual override { + function safeTransferFrom(address, address, uint256, bytes memory) public pure virtual { revert NotImplemented(); } @@ -367,7 +314,6 @@ abstract contract ERC404NullOwnerCappedUpgradeable is } emit Transfer(from_, to_, value_); - // emit ERC20Transfer(from_, to_, value_); } /// @notice Transfer an ERC721 token @@ -454,44 +400,6 @@ abstract contract ERC404NullOwnerCappedUpgradeable is return interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC20).interfaceId || - interfaceId == type(IERC20Metadata).interfaceId || - interfaceId == type(IERC404).interfaceId; - } - - /// @notice Internal function to compute domain separator for EIP-2612 permits - function _computeDomainSeparator() internal view virtual returns (bytes32) { - return - keccak256( - abi.encode( - keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ), - keccak256(bytes(name())), - keccak256("1"), - block.chainid, - address(this) - ) - ); - } - - function permit( - address owner_, - address spender_, - uint256 value_, - uint256 deadline_, - uint8 v_, - bytes32 r_, - bytes32 s_ - ) public virtual { - revert NotImplemented(); - } - - /// @notice EIP-2612 domain separator - function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { - TokenStorage storage $ = _getS(); - return - block.chainid == $.initialChainId - ? $.initialDomainSeparator - : _computeDomainSeparator(); + interfaceId == type(IERC20Metadata).interfaceId; } } diff --git a/contracts/src/interfaces/IERC404.sol b/contracts/src/interfaces/IERC404.sol deleted file mode 100644 index 13be5cc..0000000 --- a/contracts/src/interfaces/IERC404.sol +++ /dev/null @@ -1,94 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -interface IERC404 is IERC165, IERC20, IERC20Metadata { - error NotFound(); - error InvalidTokenId(); - error AlreadyExists(); - error InvalidRecipient(); - error InvalidSender(); - error InvalidSpender(); - error InvalidOperator(); - error UnsafeRecipient(); - error RecipientIsERC721TransferExempt(); - error Unauthorized(); - error InsufficientAllowance(); - error DecimalsTooLow(); - error PermitDeadlineExpired(); - error InvalidSigner(); - error InvalidApproval(); - error OwnedIndexOverflow(); - error MintLimitReached(); - error InvalidExemption(); - - function name() external view returns (string memory); - function symbol() external view returns (string memory); - function decimals() external view returns (uint8); - function totalSupply() external view returns (uint256); - function erc20TotalSupply() external view returns (uint256); - function erc721TotalSupply() external view returns (uint256); - function balanceOf(address owner_) external view returns (uint256); - function erc721BalanceOf(address owner_) external view returns (uint256); - function erc20BalanceOf(address owner_) external view returns (uint256); - function erc721TransferExempt(address account_) external view returns (bool); - function isApprovedForAll( - address owner_, - address operator_ - ) external view returns (bool); - function allowance( - address owner_, - address spender_ - ) external view returns (uint256); - function owned(address owner_) external view returns (uint256[] memory); - function ownerOf(uint256 id_) external view returns (address erc721Owner); - function tokenURI(uint256 id_) external view returns (string memory); - function approve( - address spender_, - uint256 valueOrId_ - ) external returns (bool); - function erc20Approve( - address spender_, - uint256 value_ - ) external returns (bool); - function erc721Approve(address spender_, uint256 id_) external; - function setApprovalForAll(address operator_, bool approved_) external; - function transferFrom( - address from_, - address to_, - uint256 valueOrId_ - ) external returns (bool); - function erc20TransferFrom( - address from_, - address to_, - uint256 value_ - ) external returns (bool); - function erc721TransferFrom(address from_, address to_, uint256 id_) external; - function transfer(address to_, uint256 amount_) external returns (bool); - function getERC721QueueLength() external view returns (uint256); - function getERC721TokensInQueue( - uint256 start_, - uint256 count_ - ) external view returns (uint256[] memory); - function setSelfERC721TransferExempt(bool state_) external; - function safeTransferFrom(address from_, address to_, uint256 id_) external; - function safeTransferFrom( - address from_, - address to_, - uint256 id_, - bytes calldata data_ - ) external; - function DOMAIN_SEPARATOR() external view returns (bytes32); - function permit( - address owner_, - address spender_, - uint256 value_, - uint256 deadline_, - uint8 v_, - bytes32 r_, - bytes32 s_ - ) external; -} diff --git a/contracts/src/lib/DoubleEndedQueue.sol b/contracts/src/lib/DoubleEndedQueue.sol deleted file mode 100644 index 54e2b6e..0000000 --- a/contracts/src/lib/DoubleEndedQueue.sol +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.0.0) (utils/structs/DoubleEndedQueue.sol) -// Modified by Pandora Labs to support native uint256 operations -pragma solidity ^0.8.20; - -/** - * @dev A sequence of items with the ability to efficiently push and pop items (i.e. insert and remove) on both ends of - * the sequence (called front and back). Among other access patterns, it can be used to implement efficient LIFO and - * FIFO queues. Storage use is optimized, and all operations are O(1) constant time. This includes {clear}, given that - * the existing queue contents are left in storage. - * - * The struct is called `Uint256Deque`. This data structure can only be used in storage, and not in memory. - * - * ```solidity - * DoubleEndedQueue.Uint256Deque queue; - * ``` - */ -library DoubleEndedQueue { - /** - * @dev An operation (e.g. {front}) couldn't be completed due to the queue being empty. - */ - error QueueEmpty(); - - /** - * @dev A push operation couldn't be completed due to the queue being full. - */ - error QueueFull(); - - /** - * @dev An operation (e.g. {at}) couldn't be completed due to an index being out of bounds. - */ - error QueueOutOfBounds(); - - /** - * @dev Indices are 128 bits so begin and end are packed in a single storage slot for efficient access. - * - * Struct members have an underscore prefix indicating that they are "private" and should not be read or written to - * directly. Use the functions provided below instead. Modifying the struct manually may violate assumptions and - * lead to unexpected behavior. - * - * The first item is at data[begin] and the last item is at data[end - 1]. This range can wrap around. - */ - struct Uint256Deque { - uint128 _begin; - uint128 _end; - mapping(uint128 index => uint256) _data; - } - - /** - * @dev Inserts an item at the end of the queue. - * - * Reverts with {QueueFull} if the queue is full. - */ - function pushBack(Uint256Deque storage deque, uint256 value) internal { - unchecked { - uint128 backIndex = deque._end; - if (backIndex + 1 == deque._begin) revert QueueFull(); - deque._data[backIndex] = value; - deque._end = backIndex + 1; - } - } - - /** - * @dev Removes the item at the end of the queue and returns it. - * - * Reverts with {QueueEmpty} if the queue is empty. - */ - function popBack( - Uint256Deque storage deque - ) internal returns (uint256 value) { - unchecked { - uint128 backIndex = deque._end; - if (backIndex == deque._begin) revert QueueEmpty(); - --backIndex; - value = deque._data[backIndex]; - delete deque._data[backIndex]; - deque._end = backIndex; - } - } - - /** - * @dev Inserts an item at the beginning of the queue. - * - * Reverts with {QueueFull} if the queue is full. - */ - function pushFront(Uint256Deque storage deque, uint256 value) internal { - unchecked { - uint128 frontIndex = deque._begin - 1; - if (frontIndex == deque._end) revert QueueFull(); - deque._data[frontIndex] = value; - deque._begin = frontIndex; - } - } - - /** - * @dev Removes the item at the beginning of the queue and returns it. - * - * Reverts with `QueueEmpty` if the queue is empty. - */ - function popFront( - Uint256Deque storage deque - ) internal returns (uint256 value) { - unchecked { - uint128 frontIndex = deque._begin; - if (frontIndex == deque._end) revert QueueEmpty(); - value = deque._data[frontIndex]; - delete deque._data[frontIndex]; - deque._begin = frontIndex + 1; - } - } - - /** - * @dev Returns the item at the beginning of the queue. - * - * Reverts with `QueueEmpty` if the queue is empty. - */ - function front( - Uint256Deque storage deque - ) internal view returns (uint256 value) { - if (empty(deque)) revert QueueEmpty(); - return deque._data[deque._begin]; - } - - /** - * @dev Returns the item at the end of the queue. - * - * Reverts with `QueueEmpty` if the queue is empty. - */ - function back( - Uint256Deque storage deque - ) internal view returns (uint256 value) { - if (empty(deque)) revert QueueEmpty(); - unchecked { - return deque._data[deque._end - 1]; - } - } - - /** - * @dev Return the item at a position in the queue given by `index`, with the first item at 0 and last item at - * `length(deque) - 1`. - * - * Reverts with `QueueOutOfBounds` if the index is out of bounds. - */ - function at( - Uint256Deque storage deque, - uint256 index - ) internal view returns (uint256 value) { - if (index >= length(deque)) revert QueueOutOfBounds(); - // By construction, length is a uint128, so the check above ensures that index can be safely downcast to uint128 - unchecked { - return deque._data[deque._begin + uint128(index)]; - } - } - - /** - * @dev Resets the queue back to being empty. - * - * NOTE: The current items are left behind in storage. This does not affect the functioning of the queue, but misses - * out on potential gas refunds. - */ - function clear(Uint256Deque storage deque) internal { - deque._begin = 0; - deque._end = 0; - } - - /** - * @dev Returns the number of items in the queue. - */ - function length(Uint256Deque storage deque) internal view returns (uint256) { - unchecked { - return uint256(deque._end - deque._begin); - } - } - - /** - * @dev Returns true if the queue is empty. - */ - function empty(Uint256Deque storage deque) internal view returns (bool) { - return deque._end == deque._begin; - } -} \ No newline at end of file diff --git a/spec/integration/collections_header_protocol_spec.rb b/spec/integration/collections_header_protocol_spec.rb index 79268a6..793c4e4 100644 --- a/spec/integration/collections_header_protocol_spec.rb +++ b/spec/integration/collections_header_protocol_spec.rb @@ -9,6 +9,8 @@ let(:bob) { valid_address("bob") } let(:carol) { valid_address("carol") } let(:media_type) { 'image/png' } + let(:force_merkle_sender) { "0x0000000000000000000000000000000000000042" } + let(:zero_merkle_root) { '0x' + '0' * 64 } # Small "image" payloads live in the data URI body; protocol data lives in headers let(:items_manifest) do @@ -133,7 +135,6 @@ content_base64: forged_item[:base64_content] ) # Use the hard-coded force-merkle sender so enforcement applies even in import mode - force_merkle_sender = "0x0000000000000000000000000000000000000042" forged_results = import_l1_block( [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: forged_uri)], esip_overrides: { esip6_is_enabled: true } @@ -147,9 +148,254 @@ expect(get_collection_state(collection_id)[:currentSize]).to eq(3) end - def metadata_payload(merkle_root:, initial_owner:) + context 'unhappy paths' do + describe 'content hash mismatch' do + it 'rejects when actual image differs from merkle leaf content' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Use correct proof and metadata for items_manifest[1], but WRONG image content + tampered_content = Base64.strict_encode64("tampered-image-not-header-bob-image") + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: tampered_content + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + end + + describe 'collection validation' do + it 'rejects add to non-existent collection' do + fake_collection_id = '0x' + 'dead' * 16 + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(fake_collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Collection does not exist/i) + end + + it 'rejects add to locked collection' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Lock the collection + lock_uri = json_data_uri({ + "p" => "erc-721-ethscriptions-collection", + "op" => "lock_collection", + "collection_id" => collection_id + }) + import_l1_block( + [create_input(creator: alice, to: alice, data_uri: lock_uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + # Verify collection is locked + expect(get_collection_state(collection_id)[:locked]).to eq(true) + + # Try to add item - should fail + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Collection is locked/i) + end + end + + describe 'supply limits' do + it 'rejects when exceeding max_supply' do + # Create collection with max_supply of 1 (item 0 already added via create_collection_and_add_self) + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root, max_supply: "1") + + # Collection already has 1 item (item 0), try to add item 1 - should fail + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[1]), + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Exceeds max supply/i) + end + end + + describe 'item slot conflicts' do + it 'rejects duplicate item_index' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Item 0 is already added via create_header_collection + # Try to add another item at index 0 (different content) + different_item_at_index_0 = { + item_index: 0, + name: "Different Item", + background_color: "#999999", + description: "Trying to overwrite slot 0", + attributes: [{"trait_type" => "Test", "value" => "Duplicate"}], + base64_content: Base64.strict_encode64("different-content-for-slot-0") + } + + # Build a single-item merkle tree for this new item (so proof is valid) + single_plan = build_merkle_plan([different_item_at_index_0]) + + # First, we need to update collection's merkle root to accept this item + # Actually, let's just use the owner to bypass merkle - simpler test + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, different_item_at_index_0, []), + content_base64: different_item_at_index_0[:base64_content] + ) + + # Owner can bypass merkle, but slot is still taken + results = import_l1_block( + [create_input(creator: alice, to: alice, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Item slot taken/i) + end + end + + describe 'merkle proof failures' do + it 'rejects with empty proof when merkle_root is set' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Try to add with empty proof + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), # Empty proof! + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + + it 'rejects with proof for different item_index' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Use item 1's content but item 2's proof + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], proofs[2]), # Wrong proof! + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Invalid Merkle proof/i) + end + + it 'rejects when merkle_root is zero and non-owner tries with enforcement' do + # Create collection with zero merkle root + collection_id = create_header_collection(owner: alice, merkle_root: zero_merkle_root) + + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), + content_base64: items_manifest[1][:base64_content] + ) + + # force_merkle_sender triggers enforcement, but merkle_root is 0 → "Merkle proof required" + results = import_l1_block( + [create_input(creator: force_merkle_sender, to: force_merkle_sender, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + expect_protocol_failure(results[:l2_receipts].first, /Merkle proof required/i) + end + end + end + + context 'owner privileges' do + it 'allows owner to add without proof even when merkle_root is set' do + collection_id = create_header_collection(owner: alice, merkle_root: merkle_root) + + # Owner adds item 1 without a valid proof (empty proof) + uri = header_data_uri( + op: 'add_self_to_collection', + payload: add_item_payload(collection_id, items_manifest[1], []), # No proof needed for owner + content_base64: items_manifest[1][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: alice, to: alice, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) + + receipt = results[:l2_receipts].first + events = ProtocolEventReader.parse_receipt_events(receipt) + expect(events.any? { |e| e[:event] == 'ProtocolHandlerSuccess' }).to eq(true) + expect(events.any? { |e| e[:event] == 'ItemsAdded' }).to eq(true) + expect(get_collection_state(collection_id)[:currentSize]).to eq(2) + end + end + + # Helper methods for tests + def expect_protocol_failure(receipt, error_pattern) + events = ProtocolEventReader.parse_receipt_events(receipt) + failure = events.find { |e| e[:event] == 'ProtocolHandlerFailed' } + expect(failure).not_to be_nil, "Expected ProtocolHandlerFailed event but got: #{events.map { |e| e[:event] }}" + expect(failure[:reason].to_s).to match(error_pattern), + "Expected error matching #{error_pattern.inspect}, got: #{failure[:reason]}" + end + + def create_header_collection(owner:, merkle_root:, max_supply: "4") + uri = header_data_uri( + op: 'create_collection_and_add_self', + payload: { + "metadata" => metadata_payload(merkle_root: merkle_root, initial_owner: owner).merge("max_supply" => max_supply), + "item" => item_payload(items_manifest[0], proofs[0]) + }, + content_base64: items_manifest[0][:base64_content] + ) + + results = import_l1_block( + [create_input(creator: owner, to: owner, data_uri: uri)], + esip_overrides: { esip6_is_enabled: true } + ) +# binding.irb + expect(results[:l2_receipts].first[:status]).to eq('0x1'), "Collection creation failed" + results[:ethscription_ids].first + end + + def json_data_uri(hash) + "data:," + JSON.generate(hash) + end + + def metadata_payload(merkle_root:, initial_owner:, name: nil) { - "name" => "Header Merkle Collection", + "name" => name || "Header Merkle Collection #{SecureRandom.hex(4)}", "symbol" => "HDR", "max_supply" => "4", "description" => "Header-based minting flow",