Skip to content

Commit c92961d

Browse files
committed
Enable address(0) erc20 balances to match ethscription behavior
1 parent 9a41741 commit c92961d

File tree

3 files changed

+280
-39
lines changed

3 files changed

+280
-39
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.24;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
6+
import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
7+
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
8+
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
9+
10+
/// @title ERC20NullOwnerCappedUpgradeable
11+
/// @notice ERC20 (Upgradeable) + Cap adapted to treat address(0) as a valid holder; single storage struct
12+
abstract contract ERC20NullOwnerCappedUpgradeable is Initializable, ContextUpgradeable, IERC20, IERC20Metadata, IERC20Errors {
13+
/// @custom:storage-location erc7201:ethscriptions.storage.ERC20NullOwnerCapped
14+
struct TokenStorage {
15+
mapping(address account => uint256) balances;
16+
mapping(address account => mapping(address spender => uint256)) allowances;
17+
uint256 totalSupply;
18+
string name;
19+
string symbol;
20+
uint256 cap;
21+
}
22+
23+
// Unique storage slot for this combined ERC20 + Cap storage
24+
// keccak256(abi.encode(uint256(keccak256("ethscriptions.storage.ERC20NullOwnerCapped")) - 1)) & ~bytes32(uint256(0xff))
25+
bytes32 private constant STORAGE_LOCATION = 0x8f4f7bb0f9a741a04db8c5a3930ef1872dc1b0c6f996f78adc3f57e5f8b78400;
26+
27+
function _getS() private pure returns (TokenStorage storage $) {
28+
assembly {
29+
$.slot := STORAGE_LOCATION
30+
}
31+
}
32+
33+
// Errors copied from OZ
34+
error ERC20ExceededCap(uint256 increasedSupply, uint256 cap);
35+
error ERC20InvalidCap(uint256 cap);
36+
37+
// Initializers
38+
function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing {
39+
__ERC20_init_unchained(name_, symbol_);
40+
}
41+
42+
function __ERC20_init_unchained(string memory name_, string memory symbol_) internal onlyInitializing {
43+
TokenStorage storage $ = _getS();
44+
$.name = name_;
45+
$.symbol = symbol_;
46+
}
47+
48+
function __ERC20Capped_init(uint256 cap_) internal onlyInitializing {
49+
__ERC20Capped_init_unchained(cap_);
50+
}
51+
52+
function __ERC20Capped_init_unchained(uint256 cap_) internal onlyInitializing {
53+
TokenStorage storage $ = _getS();
54+
if (cap_ == 0) {
55+
revert ERC20InvalidCap(0);
56+
}
57+
$.cap = cap_;
58+
}
59+
60+
// Views
61+
function name() public view virtual returns (string memory) {
62+
TokenStorage storage $ = _getS();
63+
return $.name;
64+
}
65+
66+
function symbol() public view virtual returns (string memory) {
67+
TokenStorage storage $ = _getS();
68+
return $.symbol;
69+
}
70+
71+
function decimals() public view virtual returns (uint8) {
72+
return 18;
73+
}
74+
75+
function totalSupply() public view virtual returns (uint256) {
76+
TokenStorage storage $ = _getS();
77+
return $.totalSupply;
78+
}
79+
80+
function balanceOf(address account) public view virtual returns (uint256) {
81+
TokenStorage storage $ = _getS();
82+
return $.balances[account];
83+
}
84+
85+
function allowance(address owner, address spender) public view virtual returns (uint256) {
86+
TokenStorage storage $ = _getS();
87+
return $.allowances[owner][spender];
88+
}
89+
90+
// External ERC-20 (can be overridden to restrict usage in child)
91+
function transfer(address to, uint256 value) public virtual returns (bool) {
92+
address owner = _msgSender();
93+
_transfer(owner, to, value);
94+
return true;
95+
}
96+
97+
function approve(address spender, uint256 value) public virtual returns (bool) {
98+
address owner = _msgSender();
99+
_approve(owner, spender, value);
100+
return true;
101+
}
102+
103+
function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
104+
address spender = _msgSender();
105+
_spendAllowance(from, spender, value);
106+
_transfer(from, to, value);
107+
return true;
108+
}
109+
110+
// Internal core
111+
function _transfer(address from, address to, uint256 value) internal {
112+
if (from == address(0)) {
113+
revert ERC20InvalidSender(address(0));
114+
}
115+
// Allow `to == address(0)` to support null-owner semantics
116+
_update(from, to, value);
117+
}
118+
119+
// Modified from OZ: do NOT burn on to == address(0); always credit recipient (including zero address).
120+
function _update(address from, address to, uint256 value) internal virtual {
121+
TokenStorage storage $ = _getS();
122+
if (from == address(0)) {
123+
// Mint path
124+
$.totalSupply += value;
125+
} else {
126+
uint256 fromBalance = $.balances[from];
127+
if (fromBalance < value) {
128+
revert ERC20InsufficientBalance(from, fromBalance, value);
129+
}
130+
unchecked {
131+
$.balances[from] = fromBalance - value;
132+
}
133+
}
134+
135+
// No burning: credit even address(0)
136+
unchecked {
137+
$.balances[to] += value;
138+
}
139+
140+
emit Transfer(from, to, value);
141+
142+
// Cap enforcement when minting
143+
if (from == address(0)) {
144+
uint256 maxSupply = $.cap;
145+
uint256 supply = $.totalSupply;
146+
if (supply > maxSupply) {
147+
revert ERC20ExceededCap(supply, maxSupply);
148+
}
149+
}
150+
}
151+
152+
// Mint (null-owner aware)
153+
function _mint(address account, uint256 value) internal {
154+
_update(address(0), account, value);
155+
}
156+
157+
// Approvals
158+
function _approve(address owner, address spender, uint256 value) internal {
159+
_approve(owner, spender, value, true);
160+
}
161+
162+
function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual {
163+
TokenStorage storage $ = _getS();
164+
if (owner == address(0)) {
165+
revert ERC20InvalidApprover(address(0));
166+
}
167+
if (spender == address(0)) {
168+
revert ERC20InvalidSpender(address(0));
169+
}
170+
$.allowances[owner][spender] = value;
171+
if (emitEvent) {
172+
emit Approval(owner, spender, value);
173+
}
174+
}
175+
176+
function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
177+
uint256 currentAllowance = allowance(owner, spender);
178+
if (currentAllowance < type(uint256).max) {
179+
if (currentAllowance < value) {
180+
revert ERC20InsufficientAllowance(spender, currentAllowance, value);
181+
}
182+
unchecked {
183+
_approve(owner, spender, currentAllowance - value, false);
184+
}
185+
}
186+
}
187+
188+
// Cap view
189+
function maxSupply() public view virtual returns (uint256) {
190+
TokenStorage storage $ = _getS();
191+
return $.cap;
192+
}
193+
}
Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity 0.8.24;
33

4-
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
5-
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol";
4+
import "./ERC20NullOwnerCappedUpgradeable.sol";
65
import "./libraries/Predeploys.sol";
76

8-
contract EthscriptionsERC20 is ERC20Upgradeable, ERC20CappedUpgradeable {
7+
/// @title EthscriptionsERC20
8+
/// @notice ERC20 with cap that supports null address ownership; only TokenManager can mint/transfer
9+
contract EthscriptionsERC20 is ERC20NullOwnerCappedUpgradeable {
910
address public constant tokenManager = Predeploys.TOKEN_MANAGER;
10-
1111
bytes32 public deployTxHash; // The ethscription hash that deployed this token
12-
12+
1313
function initialize(
1414
string memory name_,
1515
string memory symbol_,
@@ -20,52 +20,32 @@ contract EthscriptionsERC20 is ERC20Upgradeable, ERC20CappedUpgradeable {
2020
__ERC20Capped_init(cap_);
2121
deployTxHash = deployTxHash_;
2222
}
23-
23+
2424
modifier onlyTokenManager() {
2525
require(msg.sender == tokenManager, "Only TokenManager");
2626
_;
2727
}
28-
28+
29+
// TokenManager-only mint that allows to == address(0)
2930
function mint(address to, uint256 amount) external onlyTokenManager {
30-
_mint(to, amount);
31+
_update(address(0), to, amount);
3132
}
32-
33+
34+
// TokenManager-only transfer that allows to/from == address(0)
3335
function forceTransfer(address from, address to, uint256 amount) external onlyTokenManager {
34-
// This is used by TokenManager to shadow NFT transfers
35-
// It bypasses approval checks since it's a system-level transfer
36-
_transfer(from, to, amount);
36+
_update(from, to, amount);
3737
}
38-
39-
// Override transfer functions to prevent user-initiated transfers
40-
// Only the TokenManager can move tokens via forceTransfer
38+
39+
// Disable user-initiated ERC20 flows
4140
function transfer(address, uint256) public pure override returns (bool) {
4241
revert("Transfers only allowed via Ethscriptions NFT");
4342
}
44-
43+
4544
function transferFrom(address, address, uint256) public pure override returns (bool) {
4645
revert("Transfers only allowed via Ethscriptions NFT");
4746
}
48-
47+
4948
function approve(address, uint256) public pure override returns (bool) {
5049
revert("Approvals not allowed");
5150
}
52-
53-
function increaseAllowance(address, uint256) public pure returns (bool) {
54-
revert("Approvals not allowed");
55-
}
56-
57-
function decreaseAllowance(address, uint256) public pure returns (bool) {
58-
revert("Approvals not allowed");
59-
}
60-
61-
// Required overrides for multiple inheritance
62-
function _update(address from, address to, uint256 value)
63-
internal
64-
override(ERC20Upgradeable, ERC20CappedUpgradeable)
65-
{
66-
super._update(from, to, value);
67-
68-
// Token balance proving has been removed in favor of ethscription-only proving
69-
// Token balances can be derived from ethscription ownership and transfer history
70-
}
71-
}
51+
}

contracts/test/EthscriptionsToken.t.sol

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ contract EthscriptionsTokenTest is TestSetup {
392392
address tokenAddr = tokenManager.getTokenAddressByTick("TEST");
393393
EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr);
394394
assertEq(token.name(), "erc-20 TEST"); // Token name format is "protocol tick"
395-
assertEq(token.cap(), 1000000 ether); // Original cap, not the duplicate's
395+
assertEq(token.maxSupply(), 1000000 ether); // Original cap (maxSupply), not the duplicate's
396396
}
397397

398398
function testMintWithInvalidIdZero() public {
@@ -513,4 +513,72 @@ contract EthscriptionsTokenTest is TestSetup {
513513
TokenManager.TokenInfo memory info = tokenManager.getTokenInfo(DEPLOY_TX_HASH);
514514
assertEq(info.totalMinted, 1000);
515515
}
516-
}
516+
517+
function testMintToNullOwnerMintsERC20ToZero() public {
518+
// Deploy the token under tick TEST
519+
testTokenDeploy();
520+
521+
// Prepare a mint where the Ethscription initial owner is the null address
522+
bytes32 nullMintTx = bytes32(uint256(0xBADD0));
523+
string memory mintContent = 'data:,{"p":"erc-20","op":"mint","tick":"TEST","id":"1","amt":"1000"}';
524+
525+
TokenManager.MintOperation memory mintOp = TokenManager.MintOperation({
526+
tick: "TEST",
527+
id: 1,
528+
amount: 1000
529+
});
530+
531+
// Creator is Alice, but initial owner is address(0)
532+
Ethscriptions.CreateEthscriptionParams memory params = createTokenParams(
533+
nullMintTx,
534+
address(0),
535+
mintContent,
536+
"erc-20",
537+
"mint",
538+
abi.encode(mintOp)
539+
);
540+
541+
vm.prank(alice);
542+
uint256 tokenId = ethscriptions.createEthscription(params);
543+
544+
// The NFT should exist and end up owned by the null address
545+
assertEq(ethscriptions.ownerOf(tokenId), address(0));
546+
547+
// ERC20 should be minted and credited to the null address
548+
address tokenAddr = tokenManager.getTokenAddressByTick("TEST");
549+
EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr);
550+
assertEq(token.totalSupply(), 1000 ether);
551+
assertEq(token.balanceOf(address(0)), 1000 ether);
552+
553+
// TokenManager should record a token item and increase total minted
554+
assertTrue(tokenManager.isTokenItem(nullMintTx));
555+
TokenManager.TokenInfo memory info = tokenManager.getTokenInfo(DEPLOY_TX_HASH);
556+
assertEq(info.totalMinted, 1000);
557+
}
558+
559+
function testTransferTokenItemToNullAddressMovesERC20ToZero() public {
560+
// Setup: deploy and mint a token item to Bob
561+
testTokenMint();
562+
563+
address tokenAddr = tokenManager.getTokenAddressByTick("TEST");
564+
EthscriptionsERC20 token = EthscriptionsERC20(tokenAddr);
565+
566+
// Sanity: Bob has the ERC20 minted via the token item
567+
assertEq(token.balanceOf(bob), 1000 ether);
568+
assertEq(token.balanceOf(address(0)), 0);
569+
assertEq(token.totalSupply(), 1000 ether);
570+
571+
// Transfer the NFT representing the token item to the null address
572+
Ethscriptions.Ethscription memory mintEthscription = ethscriptions.getEthscription(MINT_TX_HASH_1);
573+
vm.prank(bob);
574+
ethscriptions.transferEthscription(address(0), MINT_TX_HASH_1);
575+
576+
// The NFT should now be owned by the null address
577+
assertEq(ethscriptions.ownerOf(mintEthscription.ethscriptionNumber), address(0));
578+
579+
// ERC20 transfer follows NFT to null owner
580+
assertEq(token.balanceOf(bob), 0);
581+
assertEq(token.balanceOf(address(0)), 1000 ether);
582+
assertEq(token.totalSupply(), 1000 ether);
583+
}
584+
}

0 commit comments

Comments
 (0)