Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions contracts/src/ERC20NullOwnerCappedUpgradeable.sol
Original file line number Diff line number Diff line change
@@ -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

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage location calculation comment should be verified. The actual hash calculation should match the provided constant value to ensure storage slot uniqueness and prevent collisions with other contracts.

Suggested change
bytes32 private constant STORAGE_LOCATION = 0x8f4f7bb0f9a741a04db8c5a3930ef1872dc1b0c6f996f78adc3f57e5f8b78400;
function _getS() private pure returns (TokenStorage storage $) {
assembly {
$.slot := STORAGE_LOCATION
// Compute storage slot as described in the comment above
function _storageLocation() internal pure returns (bytes32) {
// keccak256(abi.encode(uint256(keccak256("ethscriptions.storage.ERC20NullOwnerCapped")) - 1)) & ~bytes32(uint256(0xff))
bytes32 typeHash = keccak256("ethscriptions.storage.ERC20NullOwnerCapped");
uint256 slot = uint256(keccak256(abi.encode(uint256(typeHash) - 1))) & ~uint256(0xff);
return bytes32(slot);
}
function _getS() private pure returns (TokenStorage storage $) {
bytes32 location = _storageLocation();
assembly {
$.slot := location

Copilot uses AI. Check for mistakes.
}
}

// 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;
}
Comment on lines +122 to +133

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _update function doesn't handle the case where from is address(0) (minting scenario). When minting, from should be address(0) and the balance check will fail since address(0) has no balance to debit. This function should handle minting by checking if from == address(0) and only updating totalSupply and crediting to in that case.

Suggested change
// Debit from
uint256 fromBalance = $.balances[from];
if (fromBalance < value) {
revert ERC20InsufficientBalance(from, fromBalance, value);
}
unchecked {
$.balances[from] = fromBalance - value;
}
// Credit to
unchecked {
$.balances[to] += value;
}
if (from == address(0)) {
// Minting: only credit 'to'
unchecked {
$.balances[to] += value;
}
} else {
// Debit from
uint256 fromBalance = $.balances[from];
if (fromBalance < value) {
revert ERC20InsufficientBalance(from, fromBalance, value);
}
unchecked {
$.balances[from] = fromBalance - value;
}
// Credit to
unchecked {
$.balances[to] += value;
}
}

Copilot uses AI. Check for mistakes.

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);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Minting Error for Transfers from Zero Address

The _update function incorrectly treats all transfers from address(0) as mints. This logic always increases totalSupply and doesn't debit address(0)'s balance, allowing new tokens to be created from nothing instead of transferring existing ones.

Fix in Cursor Fix in Web


// Mint (null-owner aware)
function _mint(address account, uint256 value) internal {
_update(address(0), account, value);
}
Comment on lines +138 to +151

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _mint function bypasses the _update function and directly manipulates balances and totalSupply. This creates inconsistency since _update is meant to be the single point for balance changes. The _mint function should call _update(address(0), account, value) after checking the cap, but _update needs to be fixed first to handle the minting case.

Copilot uses AI. Check for mistakes.

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _mint function is defined but not used in the codebase where mint directly calls _update. This creates inconsistency - either use _mint in the public mint function or remove this unused helper to avoid confusion.

Suggested change
// Mint (null-owner aware)
function _mint(address account, uint256 value) internal {
_update(address(0), account, value);
}

Copilot uses AI. Check for mistakes.
// 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;
}
}
54 changes: 17 additions & 37 deletions contracts/src/EthscriptionsERC20.sol
Original file line number Diff line number Diff line change
@@ -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_,
Expand All @@ -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);

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mint function bypasses the standard _mint helper function which handles minting logic. Consider using _mint(to, amount) instead of directly calling _update(address(0), to, amount) for consistency with ERC20 patterns and to ensure any future mint-specific logic is preserved.

Suggested change
_update(address(0), to, amount);
_mint(to, amount);

Copilot uses AI. Check for mistakes.
}


// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Minting Error in Token Transfer

The forceTransfer function directly calls _update. When from is address(0), _update interprets this as a mint operation, incorrectly increasing totalSupply instead of transferring existing tokens. This leads to token inflation and breaks ERC20 balance invariants.

Fix in Cursor Fix in Web

}

// 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
}
}
}
72 changes: 70 additions & 2 deletions contracts/test/EthscriptionsToken.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -513,4 +513,72 @@ contract EthscriptionsTokenTest is TestSetup {
TokenManager.TokenInfo memory info = tokenManager.getTokenInfo(DEPLOY_TX_HASH);
assertEq(info.totalMinted, 1000);
}
}

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);
}
}