こちらのfoundry チュートリアル(NFT 作成)をやってみた結果と気づきのメモです。
https://book.getfoundry.sh/tutorials/solmate-nft
基本的な NFT contract 作成
- 手順どおりfoundry でnftディレクトリを作成
forge init nft
cd nft
- openzeppelin, solmateをいれる。
forge install transmissions11/solmate Openzeppelin/openzeppelin-contracts
-
openzeppelin のライブラリのパスを
remappings.txt
を作ってマッピングする -
src/NFT.sol
ファイルを写経。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {ERC721} from "solmate/tokens/ERC721.sol";
import {Strings} from "openzeppelin-contracts/utils/Strings.sol";
contract NFT is ERC721 {
uint256 public currentTokenId;
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {}
function mintTo(address recipient) public payable returns (uint256) {
uint256 newItemId = ++currentTokenId;
_safeMint(recipient, newItemId);
return newItemId;
}
function tokenURI(uint256 id ) public view virtual override returns (string memory) {
return Strings.toString(id);
}
}
- Best Practice(https://book.getfoundry.sh/tutorials/best-practices)には importするときは全部ファイルをインポートするなと記載があったので、その部分は変更した。
- ビルドする。
forge build
デプロイ
- ローカルテストノード起動
( address, keyをメモする)
anvil
- デプロイ
(環境変数$SEC1
にプライベートキー、$PRC
にhttp://127.0.0.1:8545
をいれている)
root@DESKTOP-N2O3OSL:~/nft# forge create NFT --rpc-url=$RPC --private-key=$SEC1 --constructor-args "the first NFT" "first NFT symbol"
[⠰] Compiling...
No files changed, compilation skipped
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0xf47c06552b02efd2cfcc4b39b4c5010a6979e90ac0c417ac9ee8892ad76bf015
- ここで
Deployed to
のアドレスがContact-addressになるのでメモもしくは環境変数にいれる。 -
name
,symbol
はconstructor
で指定してしているだけなので、これはあとで変更できないけど、なんか意味あるのかよくわからない。 -
ERC721 contract
を継承しているので、そちらをみてみる
検証
- 環境変数に以下を入れる
SEC1=<Account 1 privte-key>
SEC2=<Account 2 privat-key>
ADR1=<Account 1 address>
ADR2=<Account 1 address>
ミント
(以下$Conにコンタクトアドレスをいれてます)
root@DESKTOP-N2O3OSL:~/nft# cast send --rpc-url=$RPC $CON "mintTo(address)" $ADR2 --private-key $SEC2
blockHash 0xc92abfb4d0658d88938a20845b427cd1954e6ffa2c32471048da6238f8c7a3a5
blockNumber 2
contractAddress
cumulativeGasUsed 90805
effectiveGasPrice 3883515875
gasUsed 90805
logs [{"address":"0x5fbdb2315678afecb367f032d93f642f64180aa3","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x","blockHash":"0xc92abfb4d0658d88938a20845b427cd1954e6ffa2c32471048da6238f8c7a3a5","blockNumber":"0x2","transactionHash":"0x824d03d888b04f99ad1a1367e581aea54c9b20b3d16e4451c4197ac454a8c681","transactionIndex":"0x0","logIndex":"0x0","transactionLogIndex":"0x0","removed":false}]
logsBloom 0x00000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000008000000000000000000040000000000000000000000000840020000000000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000060000000000000000000000000000000000001000000000000000000000000000000
root
status 1
transactionHash 0x824d03d888b04f99ad1a1367e581aea54c9b20b3d16e4451c4197ac454a8c681
transactionIndex 0
type 2
-
mintoTo()
の戻り値はnewItemID
なのにどこにも戻り値がない。だからといってcast tx <transaction-id>
でトランザクションをみても記載はない。 -
event
とemit
を使用してトランザクションログにID
を書き込めるみたい。トランザクションが増えるのでコストがかかる。 - トランザクションを伴う関数は戻り値がない ->
event
で確認 - トランザクションを伴わない関数は戻り値を取得できる。この場合は
ownerOf()
など
所有証明 引数にitemID
が必要
cast call --rpc-url=$RPC $CON "ownerOf(uint256)" 1
0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
- 戻り値が
$AD2
のアドレスと同じ(期待どおり)
何個所有しているか
lib/solmate/src/tokens/ERC721.sol
をみてみるとbalanceOf
関数がある。
cast call --rpc-url=$RPC $CON "balanceOf(address)" $SRC2
0x0000000000000000000000000000000000000000000000000000000000000001
一個ミントしてこのアドレスに送付したので現在は1個。複数ミントして送付するとその数だけ増えたことを確認した。他の関数もlib/solmate/src/tokens/ERC721.sol
で見てNFTを完全に理解したい。
ここまでの気づき
- NFTってアドレスとIDしか記録しないのか!
NTFコントラクトの拡張とテスト
チュートリアルのとおりNTF.sol
を以下のとおり写経する (少し修正している。後述)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {ERC721} from "solmate/tokens/ERC721.sol";
import {Strings} from "openzeppelin-contracts/utils/Strings.sol";
import {Ownable} from "openzeppelin-contracts/access/Ownable.sol";
error MintPriceNotPaid();
error MaxSupply();
error NonExistentTokenURI();
error WithdrawTransfer();
contract NFT is ERC721, Ownable {
using Strings for uint256;
string public baseURI;
uint256 public currentTokenId;
uint256 public constant TOTAL_SUPPLY = 10_000;
uint256 public constant MINT_PRICE = 0.08 ether;
constructor(
string memory _name,
string memory _symbol,
string memory _baseURI
) ERC721(_name, _symbol) Ownable(msg.sender) {
baseURI = _baseURI;
}
function mintTo(address recipient) public payable returns (uint256) {
if(msg.value !=MINT_PRICE) {
revert MintPriceNotPaid();
}
uint256 newTokenId = ++currentTokenId;
if (newTokenId > TOTAL_SUPPLY) {
revert MaxSupply();
}
_safeMint(recipient, newTokenId);
return newTokenId;
}
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
if(ownerOf(tokenId) == address(0)) {
revert NonExistentTokenURI();
}
return bytes(baseURI).length > 0
? string(abi.encodePacked(baseURI, tokenId.toString()))
:"";
}
function withdrawPayments(address payable payee) external onlyOwner {
uint256 balance = address(this).balance;
(bool transferTx, ) = payee.call{value: balance}("");
if(!transferTx) {
revert WithdrawTransfer();
}
}
}
-
using Srtings for uint256;
...uint256
の引数に対してStrings
ライブラリの関数をStrings.function(uint256)
でなくてfunction(unit256)
で呼び出せる。 -
uint256 public constant TOTAL_SUPPLY = 10_000;
... 可読性のため数字にアンダースコアをいれてよい。10_000
は10000
と同じ。 -
uint256 public constant MINT_PRICE = 0.08 ether;
...ether = 1e18
- チュートリアルのままではコンパイルできない。以下のエラーが発生。
Ownable
の仕様が変わってconstructor にアドレスが必要になった。そのためmsg.sender
を引数に指定した。
root@DESKTOP-N2O3OSL:~/nft# forge build
[⠆] Compiling...
[⠰] Compiling 3 files with 0.8.22
[⠔] Solc 0.8.22 finished in 6.41ms
Error:
Compiler run failed:
Error (3415): No arguments passed to the base constructor. Specify the arguments or mark "NFT" as abstract.
--> src/NFT.sol:13:1:
|
13 | contract NFT is ERC721, Ownable {
| ^ (Relevant source part starts here and spans across multiple lines).
Note: Base constructor parameters:
--> lib/openzeppelin-contracts/contracts/access/Ownable.sol:38:16:
|
38 | constructor(address initialOwner) {
| ^^^^^^^^^^^^^^^^^^^^^^
root@DESKTOP-N2O3OSL:~/nft#
次にテストtest/nft.t.sol
を写経する。いくつか修正(後述)
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import "forge-std/Test.sol";
import "../src/NFT.sol";
import {ERC721TokenReceiver} from "solmate/tokens/ERC721.sol";
contract NFTTest is Test {
using stdStorage for StdStorage;
NFT private nft;
function setUp() public {
nft = new NFT("NFT_tutorial", "TUT", "http://mynft/");
}
function test_RevertMintWithoutValue() public {
vm.expectRevert(MintPriceNotPaid.selector);
nft.mintTo(address(1));
}
function test_MintPricePaid() public {
nft.mintTo{value: 0.08 ether}(address(1));
}
function test_RevertMintMaxSupplyReached() public {
uint256 slot = stdstore
.target(address(nft))
.sig("currentTokenId()")
.find();
bytes32 loc = bytes32(slot);
bytes32 mockedCurrentTokenId = bytes32(abi.encode(10000));
vm.store(address(nft), loc, mockedCurrentTokenId);
vm.expectRevert(MaxSupply.selector);
nft.mintTo{value: 0.08 ether}(address(1));
}
function testRevertMintToZeroAddress() public {
vm.expectRevert("INVALID_RECIPIENT");
nft.mintTo{value: 0.08 ether}(address(0));
}
function test_BalanceIncremented() public {
nft.mintTo{value: 0.08 ether}(address(1));
uint256 slotBalance = stdstore
.target(address(nft))
.sig(nft.balanceOf.selector)
.with_key(address(1))
.find();
uint256 balance1 = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balance1, 1);
nft.mintTo{value: 0.08 ether}(address(1));
uint256 balance2 = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balance2, 2);
}
function test_SafeContractReceiver() public {
Receiver receiver = new Receiver();
nft.mintTo{value: 0.08 ether}(address(receiver));
uint256 slotBalance = stdstore
.target(address(nft))
.sig(nft.balanceOf.selector)
.with_key(address(receiver))
.find();
uint256 balance = uint256(vm.load(address(nft), bytes32(slotBalance)));
assertEq(balance, 1);
}
function test_RevertUnSafeContractReceiver() public {
vm.etch(address(11), bytes("mock code"));
vm.expectRevert(bytes(""));
nft.mintTo{value: 0.08 ether}(address(11));
}
function testWithdrawWorksAsOwner() public {
Receiver receiver = new Receiver();
address payable payee = payable(address(0x1337));
uint256 priorPayeeBalance = payee.balance;
nft.mintTo{value: nft.MINT_PRICE()}(address(receiver));
uint256 nftBalance = address(nft).balance;
nft.withdrawPayments(payee);
assertEq(payee.balance, priorPayeeBalance + nftBalance);
}
function test_WithdrawalFailsAsNotOwner() public {
Receiver receiver = new Receiver();
nft.mintTo{value: nft.MINT_PRICE()}(address(receiver));
assertEq(address(nft).balance, nft.MINT_PRICE());
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector,address(0xd3ad)));
vm.startPrank(address(0xd3ad));
nft.withdrawPayments(payable(address(0xd3ad)));
vm.stopPrank();
}
}
contract Receiver is ERC721TokenReceiver {
/*
function onERC721Received (
address operator,
address from,
uint256 id,
bytes calldata data
) external override returns (bytes4){
return this.onERC721Received.selector;
}
*/
}
-
setUp()
のUは大文字であることに気を付ける。(写経してて間違えた) - 最近の
Ownable
はOwnableUnauthorizedAccount(address)
を送信者アドレスと一緒に返すのでvm.expectRevert
の引数を変更 - stdstoreで内部の値を操作する。 https://book.getfoundry.sh/reference/forge-std/std-storage
- vmはhttps://book.getfoundry.sh/cheatcodes/ 後でチートコードをよんでみる
-
test_RevertUnSafeContractReceiver()
がよくわかってない。
テスト実行
root@DESKTOP-N2O3OSL:~/nft# forge test
[⠆] Compiling...
No files changed, compilation skipped
Running 9 tests for test/nft.t.sol:NFTTest
[PASS] testRevertMintToZeroAddress() (gas: 37611)
[PASS] testWithdrawWorksAsOwner() (gas: 222342)
[PASS] test_BalanceIncremented() (gas: 217272)
[PASS] test_MintPricePaid() (gas: 81302)
[PASS] test_RevertMintMaxSupplyReached() (gas: 103651)
[PASS] test_RevertMintWithoutValue() (gas: 8431)
[PASS] test_RevertUnSafeContractReceiver() (gas: 85619)
[PASS] test_SafeContractReceiver() (gas: 272598)
[PASS] test_WithdrawalFailsAsNotOwner() (gas: 192528)
Test result: ok. 9 passed; 0 failed; 0 skipped; finished in 4.62ms
Ran 1 test suites: 9 tests passed, 0 failed, 0 skipped (9 total tests)
拡張後のcastコマンド
デプロイ
forge create --rpc-url $RPC --constructor-args "ForgeNFT" "FNFT" "https://aaa.org" --private-key $SEC1 NFT
ミント (etherを送信するひつようあり)
cast send $CON "mintTo(address)" $ADR2 --private-key $SEC1 --value "0.08 ether"