From c92961da2b6d110d314d55a7f0dd8796ac8496e3 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Thu, 16 Oct 2025 17:38:16 -0400 Subject: [PATCH 1/3] Enable address(0) erc20 balances to match ethscription behavior --- .../src/ERC20NullOwnerCappedUpgradeable.sol | 193 ++++++++++++++++++ contracts/src/EthscriptionsERC20.sol | 54 ++--- contracts/test/EthscriptionsToken.t.sol | 72 ++++++- 3 files changed, 280 insertions(+), 39 deletions(-) create mode 100644 contracts/src/ERC20NullOwnerCappedUpgradeable.sol diff --git a/contracts/src/ERC20NullOwnerCappedUpgradeable.sol b/contracts/src/ERC20NullOwnerCappedUpgradeable.sol new file mode 100644 index 0000000..425c3af --- /dev/null +++ b/contracts/src/ERC20NullOwnerCappedUpgradeable.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +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 {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"; + +/// @title ERC20NullOwnerCappedUpgradeable +/// @notice ERC20 (Upgradeable) + Cap adapted to treat address(0) as a valid holder; single storage struct +abstract contract ERC20NullOwnerCappedUpgradeable is Initializable, ContextUpgradeable, IERC20, IERC20Metadata, IERC20Errors { + /// @custom:storage-location erc7201:ethscriptions.storage.ERC20NullOwnerCapped + struct TokenStorage { + mapping(address account => uint256) balances; + mapping(address account => mapping(address spender => uint256)) allowances; + uint256 totalSupply; + string name; + string symbol; + uint256 cap; + } + + // Unique storage slot for this combined ERC20 + Cap storage + // keccak256(abi.encode(uint256(keccak256("ethscriptions.storage.ERC20NullOwnerCapped")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant STORAGE_LOCATION = 0x8f4f7bb0f9a741a04db8c5a3930ef1872dc1b0c6f996f78adc3f57e5f8b78400; + + function _getS() private pure returns (TokenStorage storage $) { + assembly { + $.slot := STORAGE_LOCATION + } + } + + // Errors copied from OZ + error ERC20ExceededCap(uint256 increasedSupply, uint256 cap); + error ERC20InvalidCap(uint256 cap); + + // Initializers + function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC20_init_unchained(name_, symbol_); + } + + function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing { + TokenStorage storage $ = _getS(); + $.name = name_; + $.symbol = symbol_; + } + + function __ERC20Capped_init(uint256 cap_) internal onlyInitializing { + __ERC20Capped_init_unchained(cap_); + } + + function __ERC20Capped_init_unchained(uint256 cap_) internal onlyInitializing { + TokenStorage storage $ = _getS(); + if (cap_ == 0) { + revert ERC20InvalidCap(0); + } + $.cap = cap_; + } + + // Views + function name() public view virtual returns (string memory) { + TokenStorage storage $ = _getS(); + return $.name; + } + + function symbol() public view virtual returns (string memory) { + TokenStorage storage $ = _getS(); + return $.symbol; + } + + function decimals() public view virtual returns (uint8) { + return 18; + } + + function totalSupply() public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.totalSupply; + } + + function balanceOf(address account) public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.balances[account]; + } + + function allowance(address owner, address spender) public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.allowances[owner][spender]; + } + + // External ERC-20 (can be overridden to restrict usage in child) + function transfer(address to, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _transfer(owner, to, value); + return true; + } + + function approve(address spender, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _approve(owner, spender, value); + return true; + } + + function transferFrom(address from, address to, uint256 value) public virtual returns (bool) { + address spender = _msgSender(); + _spendAllowance(from, spender, value); + _transfer(from, to, value); + return true; + } + + // Internal core + function _transfer(address from, address to, uint256 value) internal { + if (from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + // Allow `to == address(0)` to support null-owner semantics + _update(from, to, value); + } + + // Modified from OZ: do NOT burn on to == address(0); always credit recipient (including zero address). + function _update(address from, address to, uint256 value) internal virtual { + TokenStorage storage $ = _getS(); + if (from == address(0)) { + // Mint path + $.totalSupply += value; + } else { + uint256 fromBalance = $.balances[from]; + if (fromBalance < value) { + revert ERC20InsufficientBalance(from, fromBalance, value); + } + unchecked { + $.balances[from] = fromBalance - value; + } + } + + // No burning: credit even address(0) + unchecked { + $.balances[to] += value; + } + + emit Transfer(from, to, value); + + // Cap enforcement when minting + if (from == address(0)) { + uint256 maxSupply = $.cap; + uint256 supply = $.totalSupply; + if (supply > maxSupply) { + revert ERC20ExceededCap(supply, maxSupply); + } + } + } + + // Mint (null-owner aware) + function _mint(address account, uint256 value) internal { + _update(address(0), account, value); + } + + // Approvals + function _approve(address owner, address spender, uint256 value) internal { + _approve(owner, spender, value, true); + } + + function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual { + TokenStorage storage $ = _getS(); + if (owner == address(0)) { + revert ERC20InvalidApprover(address(0)); + } + if (spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + $.allowances[owner][spender] = value; + if (emitEvent) { + emit Approval(owner, spender, value); + } + } + + function _spendAllowance(address owner, address spender, uint256 value) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance < type(uint256).max) { + if (currentAllowance < value) { + revert ERC20InsufficientAllowance(spender, currentAllowance, value); + } + unchecked { + _approve(owner, spender, currentAllowance - value, false); + } + } + } + + // Cap view + function maxSupply() public view virtual returns (uint256) { + TokenStorage storage $ = _getS(); + return $.cap; + } +} diff --git a/contracts/src/EthscriptionsERC20.sol b/contracts/src/EthscriptionsERC20.sol index 6b8f631..35ccc59 100644 --- a/contracts/src/EthscriptionsERC20.sol +++ b/contracts/src/EthscriptionsERC20.sol @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; +import "./ERC20NullOwnerCappedUpgradeable.sol"; import "./libraries/Predeploys.sol"; -contract EthscriptionsERC20 is ERC20Upgradeable, ERC20CappedUpgradeable { +/// @title EthscriptionsERC20 +/// @notice ERC20 with cap that supports null address ownership; only TokenManager can mint/transfer +contract EthscriptionsERC20 is ERC20NullOwnerCappedUpgradeable { address public constant tokenManager = Predeploys.TOKEN_MANAGER; - bytes32 public deployTxHash; // The ethscription hash that deployed this token - + function initialize( string memory name_, string memory symbol_, @@ -20,52 +20,32 @@ contract EthscriptionsERC20 is ERC20Upgradeable, ERC20CappedUpgradeable { __ERC20Capped_init(cap_); deployTxHash = deployTxHash_; } - + modifier onlyTokenManager() { require(msg.sender == tokenManager, "Only TokenManager"); _; } - + + // TokenManager-only mint that allows to == address(0) function mint(address to, uint256 amount) external onlyTokenManager { - _mint(to, amount); + _update(address(0), to, amount); } - + + // TokenManager-only transfer that allows to/from == address(0) function forceTransfer(address from, address to, uint256 amount) external onlyTokenManager { - // This is used by TokenManager to shadow NFT transfers - // It bypasses approval checks since it's a system-level transfer - _transfer(from, to, amount); + _update(from, to, amount); } - - // Override transfer functions to prevent user-initiated transfers - // Only the TokenManager can move tokens via forceTransfer + + // Disable user-initiated ERC20 flows function transfer(address, uint256) public pure override returns (bool) { revert("Transfers only allowed via Ethscriptions NFT"); } - + function transferFrom(address, address, uint256) public pure override returns (bool) { revert("Transfers only allowed via Ethscriptions NFT"); } - + function approve(address, uint256) public pure override returns (bool) { revert("Approvals not allowed"); } - - function increaseAllowance(address, uint256) public pure returns (bool) { - revert("Approvals not allowed"); - } - - function decreaseAllowance(address, uint256) public pure returns (bool) { - revert("Approvals not allowed"); - } - - // Required overrides for multiple inheritance - function _update(address from, address to, uint256 value) - internal - override(ERC20Upgradeable, ERC20CappedUpgradeable) - { - super._update(from, to, value); - - // Token balance proving has been removed in favor of ethscription-only proving - // Token balances can be derived from ethscription ownership and transfer history - } -} \ No newline at end of file +} diff --git a/contracts/test/EthscriptionsToken.t.sol b/contracts/test/EthscriptionsToken.t.sol index a17fec4..a580b8d 100644 --- a/contracts/test/EthscriptionsToken.t.sol +++ b/contracts/test/EthscriptionsToken.t.sol @@ -392,7 +392,7 @@ contract EthscriptionsTokenTest is TestSetup { address tokenAddr = tokenManager.getTokenAddressByTick("TEST"); EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr); assertEq(token.name(), "erc-20 TEST"); // Token name format is "protocol tick" - assertEq(token.cap(), 1000000 ether); // Original cap, not the duplicate's + assertEq(token.maxSupply(), 1000000 ether); // Original cap (maxSupply), not the duplicate's } function testMintWithInvalidIdZero() public { @@ -513,4 +513,72 @@ contract EthscriptionsTokenTest is TestSetup { TokenManager.TokenInfo memory info = tokenManager.getTokenInfo(DEPLOY_TX_HASH); assertEq(info.totalMinted, 1000); } -} \ No newline at end of file + + function testMintToNullOwnerMintsERC20ToZero() public { + // Deploy the token under tick TEST + testTokenDeploy(); + + // Prepare a mint where the Ethscription initial owner is the null address + bytes32 nullMintTx = bytes32(uint256(0xBADD0)); + string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}'; + + TokenManager.MintOperation memory mintOp = TokenManager.MintOperation({ + tick: "TEST", + id: 1, + amount: 1000 + }); + + // Creator is Alice, but initial owner is address(0) + Ethscriptions.CreateEthscriptionParams memory params = createTokenParams( + nullMintTx, + address(0), + mintContent, + "erc-20", + "mint", + abi.encode(mintOp) + ); + + vm.prank(alice); + uint256 tokenId = ethscriptions.createEthscription(params); + + // The NFT should exist and end up owned by the null address + assertEq(ethscriptions.ownerOf(tokenId), address(0)); + + // ERC20 should be minted and credited to the null address + address tokenAddr = tokenManager.getTokenAddressByTick("TEST"); + EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr); + assertEq(token.totalSupply(), 1000 ether); + assertEq(token.balanceOf(address(0)), 1000 ether); + + // TokenManager should record a token item and increase total minted + assertTrue(tokenManager.isTokenItem(nullMintTx)); + TokenManager.TokenInfo memory info = tokenManager.getTokenInfo(DEPLOY_TX_HASH); + assertEq(info.totalMinted, 1000); + } + + function testTransferTokenItemToNullAddressMovesERC20ToZero() public { + // Setup: deploy and mint a token item to Bob + testTokenMint(); + + address tokenAddr = tokenManager.getTokenAddressByTick("TEST"); + EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr); + + // Sanity: Bob has the ERC20 minted via the token item + assertEq(token.balanceOf(bob), 1000 ether); + assertEq(token.balanceOf(address(0)), 0); + assertEq(token.totalSupply(), 1000 ether); + + // Transfer the NFT representing the token item to the null address + Ethscriptions.Ethscription memory mintEthscription = ethscriptions.getEthscription(MINT_TX_HASH_1); + vm.prank(bob); + ethscriptions.transferEthscription(address(0), MINT_TX_HASH_1); + + // The NFT should now be owned by the null address + assertEq(ethscriptions.ownerOf(mintEthscription.ethscriptionNumber), address(0)); + + // ERC20 transfer follows NFT to null owner + assertEq(token.balanceOf(bob), 0); + assertEq(token.balanceOf(address(0)), 1000 ether); + assertEq(token.totalSupply(), 1000 ether); + } +} From dd5038ff410ba17813fd1644db3922da70d791e7 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Thu, 16 Oct 2025 17:42:32 -0400 Subject: [PATCH 2/3] Move files around --- contracts/src/CollectionsManager.sol | 4 ++-- contracts/src/{protocols => }/EthscriptionERC721.sol | 6 +++--- contracts/src/Ethscriptions.sol | 2 +- contracts/src/TokenManager.sol | 2 +- .../src/{protocols => interfaces}/IProtocolHandler.sol | 0 contracts/test/CollectionsManager.t.sol | 2 +- contracts/test/ProtocolRegistration.t.sol | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename contracts/src/{protocols => }/EthscriptionERC721.sol (98%) rename contracts/src/{protocols => interfaces}/IProtocolHandler.sol (100%) diff --git a/contracts/src/CollectionsManager.sol b/contracts/src/CollectionsManager.sol index d252a35..06b392e 100644 --- a/contracts/src/CollectionsManager.sol +++ b/contracts/src/CollectionsManager.sol @@ -3,10 +3,10 @@ pragma solidity 0.8.24; import "@openzeppelin/contracts/proxy/Clones.sol"; import {LibString} from "solady/utils/LibString.sol"; -import "./protocols/EthscriptionERC721.sol"; +import "./EthscriptionERC721.sol"; import "./Ethscriptions.sol"; import "./libraries/Predeploys.sol"; -import "./protocols/IProtocolHandler.sol"; +import "./interfaces/IProtocolHandler.sol"; contract CollectionsManager is IProtocolHandler { using Clones for address; diff --git a/contracts/src/protocols/EthscriptionERC721.sol b/contracts/src/EthscriptionERC721.sol similarity index 98% rename from contracts/src/protocols/EthscriptionERC721.sol rename to contracts/src/EthscriptionERC721.sol index 7e7dec1..b08d03f 100644 --- a/contracts/src/protocols/EthscriptionERC721.sol +++ b/contracts/src/EthscriptionERC721.sol @@ -2,13 +2,13 @@ pragma solidity 0.8.24; // import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; -import "../ERC721EthscriptionsUpgradeable.sol"; +import "./ERC721EthscriptionsUpgradeable.sol"; // import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; // import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import "../Ethscriptions.sol"; +import "./Ethscriptions.sol"; import {LibString} from "solady/utils/LibString.sol"; import {Base64} from "solady/utils/Base64.sol"; -import "../CollectionsManager.sol"; +import "./CollectionsManager.sol"; /// @title EthscriptionERC721 /// @notice ERC-721 contract for an Ethscription collection diff --git a/contracts/src/Ethscriptions.sol b/contracts/src/Ethscriptions.sol index 8e60f8c..103475e 100644 --- a/contracts/src/Ethscriptions.sol +++ b/contracts/src/Ethscriptions.sol @@ -8,7 +8,7 @@ import {LibString} from "solady/utils/LibString.sol"; import "./EthscriptionsProver.sol"; import "./libraries/Predeploys.sol"; import "./L2/L1Block.sol"; -import "./protocols/IProtocolHandler.sol"; +import "./interfaces/IProtocolHandler.sol"; /// @title Ethscriptions ERC-721 Contract /// @notice Mints Ethscriptions as ERC-721 tokens based on L1 transaction data diff --git a/contracts/src/TokenManager.sol b/contracts/src/TokenManager.sol index 0b79da1..81012a8 100644 --- a/contracts/src/TokenManager.sol +++ b/contracts/src/TokenManager.sol @@ -6,7 +6,7 @@ import {LibString} from "solady/utils/LibString.sol"; import "./EthscriptionsERC20.sol"; import "./Ethscriptions.sol"; import "./libraries/Predeploys.sol"; -import "./protocols/IProtocolHandler.sol"; +import "./interfaces/IProtocolHandler.sol"; contract TokenManager is IProtocolHandler { using Clones for address; diff --git a/contracts/src/protocols/IProtocolHandler.sol b/contracts/src/interfaces/IProtocolHandler.sol similarity index 100% rename from contracts/src/protocols/IProtocolHandler.sol rename to contracts/src/interfaces/IProtocolHandler.sol diff --git a/contracts/test/CollectionsManager.t.sol b/contracts/test/CollectionsManager.t.sol index 29541fe..43915f4 100644 --- a/contracts/test/CollectionsManager.t.sol +++ b/contracts/test/CollectionsManager.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import "./TestSetup.sol"; import "../src/CollectionsManager.sol"; -import "../src/protocols/EthscriptionERC721.sol"; +import "../src/EthscriptionERC721.sol"; import {LibString} from "solady/utils/LibString.sol"; contract CollectionsManagerTest is TestSetup { diff --git a/contracts/test/ProtocolRegistration.t.sol b/contracts/test/ProtocolRegistration.t.sol index a80e531..b33332c 100644 --- a/contracts/test/ProtocolRegistration.t.sol +++ b/contracts/test/ProtocolRegistration.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.24; import "./TestSetup.sol"; -import "../src/protocols/IProtocolHandler.sol"; +import "../src/interfaces/IProtocolHandler.sol"; /// @title Protocol Registration Tests /// @notice Tests for concurrent protocol handler registration and related edge cases From 9d82ffe87c1be1479ff6a973c1bc9e467d9ac3c4 Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Thu, 16 Oct 2025 17:51:11 -0400 Subject: [PATCH 3/3] Fix _mint / _update semantics --- .../src/ERC20NullOwnerCappedUpgradeable.sol | 50 +++++++++---------- contracts/src/EthscriptionsERC20.sol | 2 +- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/contracts/src/ERC20NullOwnerCappedUpgradeable.sol b/contracts/src/ERC20NullOwnerCappedUpgradeable.sol index 425c3af..91b7cda 100644 --- a/contracts/src/ERC20NullOwnerCappedUpgradeable.sol +++ b/contracts/src/ERC20NullOwnerCappedUpgradeable.sol @@ -22,7 +22,7 @@ abstract contract ERC20NullOwnerCappedUpgradeable is Initializable, ContextUpgra // Unique storage slot for this combined ERC20 + Cap storage // keccak256(abi.encode(uint256(keccak256("ethscriptions.storage.ERC20NullOwnerCapped")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant STORAGE_LOCATION = 0x8f4f7bb0f9a741a04db8c5a3930ef1872dc1b0c6f996f78adc3f57e5f8b78400; + bytes32 private constant STORAGE_LOCATION = 0x4d6f413771b260e6694ffc0cfc1fc0bdb079c880580315446b9e26b778417b00; function _getS() private pure returns (TokenStorage storage $) { assembly { @@ -116,42 +116,38 @@ abstract contract ERC20NullOwnerCappedUpgradeable is Initializable, ContextUpgra _update(from, to, value); } - // Modified from OZ: do NOT burn on to == address(0); always credit recipient (including zero address). + // Update balances without affecting total supply (supports from/to == address(0)) function _update(address from, address to, uint256 value) internal virtual { TokenStorage storage $ = _getS(); - if (from == address(0)) { - // Mint path - $.totalSupply += value; - } else { - uint256 fromBalance = $.balances[from]; - if (fromBalance < value) { - revert ERC20InsufficientBalance(from, fromBalance, value); - } - unchecked { - $.balances[from] = fromBalance - value; - } + // Debit from + uint256 fromBalance = $.balances[from]; + if (fromBalance < value) { + revert ERC20InsufficientBalance(from, fromBalance, value); } - - // No burning: credit even address(0) + unchecked { + $.balances[from] = fromBalance - value; + } + // Credit to unchecked { $.balances[to] += value; } - emit Transfer(from, to, value); - - // Cap enforcement when minting - if (from == address(0)) { - uint256 maxSupply = $.cap; - uint256 supply = $.totalSupply; - if (supply > maxSupply) { - revert ERC20ExceededCap(supply, maxSupply); - } - } } - // Mint (null-owner aware) + // Mint (null-owner aware): increases totalSupply and credits recipient (can be address(0)) function _mint(address account, uint256 value) internal { - _update(address(0), account, value); + TokenStorage storage $ = _getS(); + + uint256 newSupply = $.totalSupply + value; + if (newSupply > $.cap) { + revert ERC20ExceededCap(newSupply, $.cap); + } + + $.totalSupply = newSupply; + + unchecked { $.balances[account] += value; } + + emit Transfer(address(0), account, value); } // Approvals diff --git a/contracts/src/EthscriptionsERC20.sol b/contracts/src/EthscriptionsERC20.sol index 35ccc59..f628f11 100644 --- a/contracts/src/EthscriptionsERC20.sol +++ b/contracts/src/EthscriptionsERC20.sol @@ -28,7 +28,7 @@ contract EthscriptionsERC20 is ERC20NullOwnerCappedUpgradeable { // TokenManager-only mint that allows to == address(0) function mint(address to, uint256 amount) external onlyTokenManager { - _update(address(0), to, amount); + _mint(to, amount); } // TokenManager-only transfer that allows to/from == address(0)