Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions contracts/src/CollectionsManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
189 changes: 189 additions & 0 deletions contracts/src/ERC20NullOwnerCappedUpgradeable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// 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 = 0x4d6f413771b260e6694ffc0cfc1fc0bdb079c880580315446b9e26b778417b00;

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

// Update balances without affecting total supply (supports from/to == address(0))
function _update(address from, address to, uint256 value) internal virtual {
TokenStorage storage $ = _getS();
// 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;
}
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);
}

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): increases totalSupply and credits recipient (can be address(0))
function _mint(address account, uint256 value) internal {
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);
}
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.

// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/Ethscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 16 additions & 36 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);
}


// 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
}
}
}
2 changes: 1 addition & 1 deletion contracts/src/TokenManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion contracts/test/CollectionsManager.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading