本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

ドージコイン(Dogecoin)について調べていて、「こういうコインは個人でも作れるのか」という疑問が出てきました。調べるより作ったほうが早いので、実際に作って確かめた記録です。

結論から書くと、作れます。作ること自体は思っていたより短く済みました。ERC-20(Ethereum Request for Comments 20、Ethereum のトークン規格)の本体はコメント込みでも 80 行に届かず(実装だけなら 50 行あまり)、デプロイは「宛先が空のトランザクション」を 1 本送るだけで終わります。

ただし、その簡単さが何を意味するのかは、作ってみないと分かりにくい部分でした。以下では実装を追いながら、どこが簡単でどこが簡単ではないのかを見ていきます。

「コインを作る」には 2 つの意味がある

最初に整理が必要でした。「コインを作る」という言葉は、まったく難易度の違う 2 つの作業を指しています。

図のタイトルは「「コインを作る」の 2 つの意味 — 自前で用意する層が違う」。4 層のスタックを左右に並べた比較図。左「独自チェーンを立てる(ドージコイン=Litecoin 系の独自チェーン)」は、上から「残高と送金のルール(誰がいくら持つか)」「実行環境(EVM=Ethereum Virtual Machine などの仮想機械)」「合意形成(PoW=Proof of Work / PoS=Proof of Stake によるブロック生成)」「P2P ネットワーク(ノードの発見と伝播)」の 4 層すべてに「自前」と付いている。右「既存チェーンにトークンを載せる(ERC-20、ミームコインの大半)」は、最上層の「残高と送金のルール」だけが「自前」で、下の 3 層は「Ethereum が提供済み」として「既製」と付いている。左の書く量は chainparams の改変と運用で、難所は「掘るマイナーがいないと 51% 攻撃が成立する」。右の書く量は Solidity で 80 行弱で、難所は「流動性とコミュニティ(コードの外側)」。

ドージコインは左側です。ライトコイン(Litecoin)派生の Luckycoin をベースに作られた独自チェーンで、合意形成もマイニングも P2P(peer-to-peer)ネットワークも自前で持っています。日本語版 Wikipedia は Luckycoin をベースにしたと記しており、英語版は Litecoin からのフォークとも併記しています。いずれにせよ Litecoin 系の系譜にあり、Ethereum のような既存チェーンに載っているわけではない、という点がここでの要点です。

一方、いわゆるミームコインの大半は右側で、Ethereum のような既存チェーンの上にトークンとして載っているだけです。下の 3 層はチェーンが既に提供しているので、書くのは最上層の「誰がいくら持っているか」だけで済みます。

この違いは、難所がどこにあるかにも効いてきます。独自チェーンを立てた場合、コードが書けても掘ってくれるマイナーがいなければハッシュレートが確保できません。ハッシュレートの小さいチェーンは 51% 攻撃が現実的なコストで成立してしまうので、「動く」ことと「安全である」ことの間に距離があります。Ethereum Classic や Bitcoin Gold が実際に攻撃を受けた事例が知られています。

今回は右側、ERC-20 トークンのほうを作りました。

依存パッケージゼロで組む

手元に実験用のリポジトリがあり、そこにゼロ知識証明・量子計算・耐量子暗号・ステーブルコイン決済を、いずれも外部パッケージを使わず Node.js の標準ライブラリ(node:cryptoBigInt)だけで実装したものが入っています。

このうちステーブルコイン決済のモジュールに、Ethereum のトランザクションを組み立てる部品が一通り揃っていました。

部品内容
keccak.tsKeccak-256(Ethereum 版。SHA3-256 とはパディングが異なる)
secp256k1.ts楕円曲線の演算
ecdsa.ts署名・検証・公開鍵の復元
address.ts公開鍵からアドレスの導出、EIP-55(Ethereum Improvement Proposal 55)チェックサム
rlp.tsRLP(Recursive Length Prefix、Ethereum の直列化方式)
abi.tsABI(Application Binary Interface)エンコード
tx.tsEIP-1559 形式のトランザクションの構築と署名

つまり「トークンをデプロイする」ために足りないのは、実在のチェーンに繋ぐ部分と、コントラクトのバイトコードだけでした。ethers.js や viem を入れずに済むなら、そのほうが中で何が起きているか見えるので、そのまま足す方針にしました。

なお Solidity のコンパイルだけは solc が必要です。ここは一時ディレクトリに取得して使い捨て、生成されたバイトコードを定数としてリポジトリに置く形にしました。実行時には依存パッケージが要らない状態を保てます。

ERC-20 の中身

EIP-20 が必須としているのは 6 つの関数(totalSupply / balanceOf / transfer / transferFrom / approve / allowance)と 2 つのイベントです。name / symbol / decimals の 3 つは任意で、規格上も「他のコントラクトはこれらの存在を期待してはならない」とされています。両方を実装すると 9 関数になります。

状態として持つのは、総供給量と 2 つの対応表(Solidity では mapping。以下では写像と呼びます)だけです。ひとつは「どのアドレスがいくら持っているか」の残高表で、もうひとつは次に説明する委任枠の表です。トークンとは要するに、コントラクトの中にあるこの表の数字のことです。

自分で動かす場合と、他人に動かしてもらう場合

関数が 6 つもあるのは、トークンの動かし方が 2 通りあるためです。

自分のトークンを自分で送るだけなら transfer ひとつで足ります。ややこしいのは、他人のコントラクトに自分のトークンを引き出してもらいたいときです。分散型取引所(DEX)でトークンを交換する場面がこれにあたります。

ETH ならトランザクションの value に添えて渡せますが、トークンにはそれができません。トークンは別のコントラクトの中の数字でしかないからです。そこで ERC-20 は 2 段階に分けています。

1. 保有者 → トークン契約:  approve(DEX, 100)
   「DEX が私の口座から 100 まで引き出してよい」と委任枠に記録される

2. DEX   → トークン契約:  transferFrom(保有者, DEX, 100)
   委任枠を確認して減らし、実際に残高表を書き換える

銀行の口座振替に似た形です。上限つきの引き落とし許可をあらかじめ登録しておき、相手はその範囲で引き落とします。allowance が残り枠を読む関数、approve が枠を設定する関数です。

以下が実装です。OpenZeppelin のようなライブラリを継承せず、全部書いてもこの程度に収まります。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;

contract NakamuraCoin {
    string public constant name = "Nakamura Coin";
    string public constant symbol = "NKMR";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        _transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= value, "NKMR: allowance exceeded");
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - value;
        }
        _transfer(from, to, value);
        return true;
    }

    function _transfer(address from, address to, uint256 value) internal {
        require(to != address(0), "NKMR: transfer to zero address");
        uint256 fromBalance = balanceOf[from];
        require(fromBalance >= value, "NKMR: insufficient balance");
        balanceOf[from] = fromBalance - value;
        balanceOf[to] += value;
        emit Transfer(from, to, value);
    }
}

いくつか意図的にそうしている点があります。

mint 関数を持たせていないので、発行総量はコンストラクタで一度確定したらそれ以上増えません。ドージコインが発行上限を持たない無限発行モデルなのとは対照的です。

発行を「ゼロアドレスからの送金」として記録する

ここで前提がひとつ要ります。コントラクトが発行するイベント(ログ)は、残高とは別の場所に置かれます。残高はコントラクトのストレージに入りますが、ログはブロックのレシートに入り、コントラクトからは読めません。ログは完全に外部向けの通知で、契約の動作そのものには影響しません。

外部のプログラムが「誰がいくら持っているか」を知る方法は 2 通りあります。ひとつは balanceOf を呼んで直接たずねる方法です。もうひとつは、Transfer ログを最初から順に再生して、自分の手元に残高表を組み立てる方法です。エクスプローラの保有者一覧や資産管理ツールは後者を使います。保有者の一覧や取引の履歴は、こちらでないと得られないためです。

問題は、発行がストレージへの直接の書き込みで、送り主が存在しないことです。

balanceOf[msg.sender] = initialSupply;   // 無から 10 億 NKMR が出現する

このままだとログを全部再生しても「10 億 NKMR がどこかから来た」という記録がないので、再生した残高表は 0 のままになり、balanceOf の答えと食い違います。

そこで、発行を「ゼロアドレスから送られてきた」ことにして辻褄を合わせます。0x0000…0000 は誰も秘密鍵を持てないアドレスで、トークンの世界の外側を表す番人として使われます。

発行 : Transfer(0x0     → 受取人)   外から入ってきた
送金 : Transfer(送り主   → 受取人)
焼却 : Transfer(保有者   → 0x0)     外へ出ていった

こう決めておくと、ログの出入りを合計した値が実際の残高と必ず一致します。EIP-20 も「トークンを新規発行する際は _from0x0 にした Transfer を発行すべき(SHOULD)」と定めています。MUST ではないので出さなくても規格違反にはなりませんが、上のような集計をする側からは初期配布が見えなくなります。balanceOf を直接呼ぶ側には影響しません。

無限の委任枠は減算しない

approve は 1 回ごとにトランザクションなので、そのたびに手数料がかかります。交換のたびにぴったりの額を許可していると、毎回「許可」と「交換」の 2 回ぶん払うことになります。そのため多くのアプリは、最初に一度だけ type(uint256).max(約 1.16×10⁷⁷、事実上の無制限)を許可させて、以後は省きます。

このとき、送金のたびに枠を減らす処理には意味がありません。1.16×10⁷⁷ から 1,000 を引いても実質は変わらないうえ、ストレージへの書き込みには 5,000 ガスかかります。そこで「最大値なら無制限とみなして触らない」という扱いが広まりました。今回の実装が減算をスキップしているのはこのためです。

ただし無限の委任枠は危険でもあります。許可した相手のコントラクトに欠陥があれば、いつでも残高を全部引き出せる状態が続きます。ウォレットが無制限の承認を警告したり、許可の取り消し機能を用意したりしているのはこのためです。

approve の上書き仕様は素のまま残した

approve は枠を足し引きするのではなく、丸ごと上書きします。これには先回り(front-running)と呼ばれる問題が知られています。

たとえば Bob に 100 の枠を与えていて、それを 50 に減らしたくなったとします。

  1. 保有者が approve(Bob, 50) を送信する。ブロックに取り込まれるまで待機列(メモリプール)に並び、その内容は誰にでも見えています
  2. Bob がそれを見て、transferFrom(保有者 → Bob, 100) を高い手数料で送信し、先に成立させる。枠 100 を使い切る
  3. 続いて approve(Bob, 50) が成立し、枠が 50 に設定される
  4. Bob がさらに 50 を引き出す

保有者は 100 までのつもりが、合計 150 を渡すことになります。EIP-20 自身がこの点に触れていて、いったん 0 にしてから新しい値を設定すべきだと注意しています。実装によっては増減専用の関数を足したり、署名で許可を渡す EIP-2612(permit)を使ったりします。

成立するには相手が悪意を持っていることと、ゼロでない枠を減らそうとしていることの両方が必要なので、常に起きるわけではありません。ここでは規格の素の挙動を見せることを優先して、対策を入れずに残しました。

Solidity は何になるのか

コンパイルすると 1,638 バイトぶんの 16 進文字列(3,276 文字)が出てきます。

solc 0.8.36+commit.8a079791
creation code: 1638 バイト
functions: allowance(address,address) approve(address,uint256) balanceOf(address)
           decimals() name() symbol() totalSupply() transfer(address,uint256)
           transferFrom(address,address,uint256)

ここで出てくるのは creation code(初期化コード)で、チェーンに保存されるコードとは別物です。この区別が、デプロイの理解の中心にあるように思います。

デプロイの実体

デプロイの流れを 3 段で示した図。①送るトランザクションは「to :(空)」「value : 0」「data : creation code ‖ 引数 32 バイト」。矢印で②へ。②EVM が実行し、コンストラクタが動いて「総供給を確定」「配置者に全量を計上」「Transfer ログを発行」が起きる。矢印で③へ。③永続化されるコードは runtime code で、「実行の戻り値だけが保存される」「コンストラクタは残らない」。図の下に「送ったコードと保存されるコードは別物 — だから creation code と runtime code は長さが違う」とあり、さらに枠内に「保存先アドレスは、送信する前から決まっている address = keccak256( rlp([ 送信者, nonce ]) )[12:]」「← 乱数も鍵も入らない」と書かれている。

デプロイは特別な操作ではなく、宛先(to)を空にした普通のトランザクションです。トランザクションには宛先・金額のほかにデータ欄(data。calldata とも呼びます)があり、そこに置いた creation code を EVM(Ethereum Virtual Machine)が実行して、その戻り値がそのアドレスに永続化されます。

したがってコンストラクタは、保存されるコード(runtime code)には含まれません。一度しか動かないので、保存する必要がないためです。

コンストラクタ引数は creation code の末尾にそのまま連結します。

export function buildDeployData(initialSupply: bigint): Uint8Array {
  return concat(hexToBytes(ERC20_CREATION_CODE), encodeUint256(initialSupply));
}

トランザクション自体も、宛先が null になっている以外は送金と変わりません。

const tx: Eip1559Tx = {
  chainId,
  nonce,
  maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
  maxFeePerGas: fees.maxFeePerGas,
  gasLimit,
  to: null, // ← これがデプロイの本体。宛先が無い tx = コントラクト生成
  value: 0n,
  data,
};

アドレスは送信前に決まっている

作ってみて意外だったのがここでした。コントラクトのアドレスは、トランザクションを送る前から確定しています。

address = keccak256(rlp([送信者, nonce]))[12:]

乱数も鍵も入りません。送信者のアドレスと nonce だけで決まります。nonce はそのアカウントがこれまでに送ったトランザクションの通し番号で、0 から始まって 1 本送るごとに増えます。同じトランザクションが二重に実行されるのを防ぐための番号ですが、ここではアドレスの材料としても使われています。

export function computeContractAddress(sender: string, nonce: bigint): string {
  const encoded = rlpEncode([addressToBytes(sender), rlpUint(nonce)]);
  return toChecksumAddress(bytesToHex(keccak256(encoded).slice(12)));
}

広く知られた検証ベクタ(送信者 0x6ac7ea33f8831ea9dcc53393aaa88b25a785dbf0、nonce 0 なら 0xcd234a471b72ba2f1ccf0a70fcaba648a5eecd8d)と一致することを自己検証に入れてあります。

この性質のおかげで、デプロイ前にアドレスを他人へ伝えることができます。逆に nonce がずれると予告したアドレスは外れるので、間に別のトランザクションを挟むと壊れます。

ノードとの通信(JSON-RPC)を fetch だけで書く

ノードとの通信は JSON-RPC(Remote Procedure Call)で、POST{jsonrpc, id, method, params} を投げるだけです。

async call<T = unknown>(method: string, params: unknown[] = []): Promise<T> {
  const body = JSON.stringify({ jsonrpc: '2.0', id: ++this.id, method, params });
  const res = await fetch(this.url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body,
    signal: AbortSignal.timeout(this.timeoutMs),
  });
  if (!res.ok) throw new Error(`${method}: HTTP ${res.status} ${res.statusText}`);
  const json = (await res.json()) as { result?: T; error?: RpcError };
  if (json.error) throw new JsonRpcError(method, json.error);
  return json.result as T;
}

書いてみると、ethers.js や viem のようなライブラリが引き受けているのは通信そのものよりも、16 進文字列と bigint の変換やエラーの解釈のほうではないか、と思えてきます。両ライブラリの実装まで読んで確かめたわけではないので、印象の域は出ません。

しかもこの 16 進が 2 種類あります。数量(quantity)は先頭ゼロを詰めた最小長で表し、ゼロは 0x0 です。一方、データ(calldata やハッシュ)は固定長で先頭ゼロを保ちます。両者が同じ JSON の中に混在するので、ここは間違えやすい箇所でした。

nonce の取得も気をつける点がありました。eth_getTransactionCountlatest で取ると未確定の自分のトランザクションが数に入らないため、連続送信で nonce が衝突します。pending を指定しています。

ABI の静的型と動的型

既存モジュールの ABI エンコードは静的型(addressuint256)だけを扱っていたので、「32 バイト語を並べるだけ」で済んでいました。今回 name()symbol() を読むために、動的型の string を追加する必要がありました。

動的型の返り値は 3 段の間接参照になります。

語0:  0x20            ← 本体の開始位置(先頭からのバイト数)
語1:  0x0d            ← バイト長(13)
語2:  "Nakamura Coin" を右ゼロ埋めした 32 バイト
export function decodeString(returned: Uint8Array): string {
  if (returned.length < 64) throw new Error(`decodeString: 短すぎる (${returned.length} バイト)`);
  const offset = Number(bytesToBigInt(returned.slice(0, 32)));
  if (offset + 32 > returned.length) throw new Error('decodeString: オフセットが範囲外');
  const length = Number(bytesToBigInt(returned.slice(offset, offset + 32)));
  if (offset + 32 + length > returned.length) throw new Error('decodeString: 長さが範囲外');
  return new TextDecoder().decode(returned.slice(offset + 32, offset + 32 + length));
}

汎用の ABI ライブラリが複雑になる主因は、この間接参照の有無にあるようです。静的型しか使わない範囲であれば、自前で書いても大した量にはなりません。

鍵をディスクに置かずに扱う

デプロイには秘密鍵が要ります。テストネット専用の使い捨て鍵とはいえ、平文でディスクに置くのは避けたかったので、1Password に入れて実行時にだけ環境変数として注入する構成にしました。

鍵の生成もラボ自身のコード(node:crypto の CSPRNG=暗号論的に安全な擬似乱数生成器と、secp256k1 の実装)で行い、画面にも出さずそのまま 1Password へ渡しています。

KEYJSON="$(node --experimental-strip-types - <<'EOF'
import { keygen } from './src/01-zkp/secp256k1.ts';
import { publicKeyToAddress } from './src/05-jpyc/address.ts';

const { sk, pk } = keygen();
process.stdout.write(JSON.stringify({
  privateKey: '0x' + sk.toString(16).padStart(64, '0'),
  address: publicKeyToAddress(pk),
}));
EOF
)"

SK="$(print -r -- "$KEYJSON" | jq -r '.privateKey')"

op item create --category="API Credential" --vault=Personal --title="$ITEM_TITLE" "private_key[concealed]=$SK" "address[text]=$ADDR"

unset SK KEYJSON

.env.local には値ではなく参照だけを書きます。

NKMR_DEPLOYER_KEY=op://Personal/si2026 sepolia deployer/private_key

実行時に op が解決します。

op run --env-file=.env.local -- npm run token:deploy

送信前にローカルで検証できること

実際にデプロイする前に、ネットワークに触れずに確認できることが意外に多くありました。統合自己検証に 19 項目を足して、全体で 70 項目になっています。

npm test の実行結果のターミナル画面。「06-token — 独自 ERC-20 のデプロイ」という見出しの下に 19 個の検証項目が並び、すべてに緑のチェックが付いている。項目は「アドレス導出: CREATE の既知ベクタと一致する」「アドレス導出: nonce が違えば別アドレスになる」「アドレス導出: 鍵ではなく送信者と nonce だけで決まる(再現性)」「バイトコード: 16 進として妥当で、記録された長さと一致する」「デプロイ data = creation code ‖ コンストラクタ引数(32 バイト)」「デプロイ tx: to が空で、署名から送信者を復元できる」「デプロイ tx: 生成の intrinsic gas は 53,000 から始まる」「デプロイ tx: EIP-3860 の initcode 課金(32 バイト語あたり 2 gas)が乗る」「EIP-7623: ゼロ詰め calldata では送金でも floor 側が下限になる」「デプロイ tx: 通常計算の intrinsic gas が 79,106 になる」「デプロイ tx: EIP-7623 の calldata 下限が効き、実際の下限は 86,000」「EIP-7623: 課金の傾きが非ゼロ 40 / ゼロ 10 gas per byte になる」「ABI: 関数セレクタが ERC-20 の既知値と一致する」「ABI: コンパイル結果が ERC-20 の 9 関数をすべて公開している」「ABI: 動的型 string を復号できる(name() の戻り値)」「ABI: 壊れた string を黙って通さない」「単位変換: parseUnits / formatUnits が往復する」「単位変換: decimals を超える小数を拒否する」「RPC: quantity は先頭ゼロなしで、往復する」。最下部に「全 70 項目が成功」と表示されている。

関数セレクタは keccak256(関数シグネチャ) の先頭 4 バイトなので、既知の値と突き合わせられます。transfer(address,uint256)0xa9059cbbbalanceOf(address)0x70a08231 といった値です。自分で書いた Keccak-256 の実装が正しいことの確認にもなります。

デプロイ用トランザクションについては、署名したバイト列から送信者を復元して、鍵に対応するアドレスと一致することを確かめています。トランザクションに From 欄が無いのに送信者が分かるのは、署名から公開鍵を復元できるためです。

テストネットの ETH はどこから来るのか

デプロイには手数料が要るので、まず Sepolia の ETH が必要です。テストネットの ETH は蛇口(faucet)から配られます。今回は PoW faucet と呼ばれる種類のものを使い、0.123 SepoliaETH を受け取りました。

この faucet はブラウザ上で計算作業(scrypt や argon2 などのハッシュ計算)をさせてから配布します。名前に PoW(Proof of Work)と付いていますが、この計算はチェーンの合意形成には一切寄与していません。Sepolia はそもそも Proof of Stake で動いており、マイニングという工程が存在しないためです。

配布元の説明にも、この処理で新しいコインが生成されるわけではなく、faucet の残高が一気に吸い上げられるのを防ぐための保護手段のひとつだと明記されています。要するに CAPTCHA の代わりに計算コストを課しているだけで、渡されるのは faucet が事前に保有している残高から切り出したものです。GPU が有利になりすぎないアルゴリズムが選ばれているのも、配布の公平性を保つためとされています。

ネイティブ通貨とトークンは別物

ここで受け取る SepoliaETH と、これから作る NKMR は層が違います。

SepoliaETH(ネイティブ通貨)NKMR(ERC-20 トークン)
どこに記録されるかプロトコルが持つアカウント状態コントラクトのストレージ上の写像
実体チェーンの仕様そのもの誰かがデプロイしたコード
ガス代の支払い使える使えない
発行者いない(プロトコルが決める)デプロイした人

トークンはコントラクトの中の数字にすぎないので、それ自体では動かせません。NKMR を 1 送るにも、手数料はネイティブ通貨の SepoliaETH で払う必要があります。この非対称性は、トークンだけ持っていて送金できない状況としてよく現れます。

実際にデプロイする

準備ができたので送信します。

デプロイ実行のターミナル画面。「7. デプロイする」の下に「送信中… レシートが出るまで待ちます(通常 12〜30 秒)」と表示され、続いて「デプロイ成功: 0xCB71635E70a95dccd6D0e41DC72d898d88b80785」「tx ハッシュ 0x47404cec6d6797ca4dd5366e510960ee262f275f17e5908d904bdca4fe04fb8f」「ブロック 11526997」「gasUsed 422071」「実費 0.000465676957675241 SepoliaETH」が並び、「予測アドレスと実際のアドレスが一致 — nonce から先に分かっていた」と出ている。「8. 本当に ERC-20 として動いているか確かめる」の下には name() が Nakamura Coin、symbol() が NKMR、decimals() が 18、totalSupply() が 1000000000、balanceOf(自分) が 1000000000 と表示され、「全量が配置者に入っている。mint 関数は無いので、これ以上は増えない。」と注記されている。

実際のトランザクションは以下で確認できます。

送信前に計算した予測アドレスと、レシートに入っていたアドレスが一致しました。nonce が 0 の段階で keccak256(rlp([送信者, 0])) から求めた値なので、送信するまでもなく決まっていたことになります。

実際の消費は 422,071 でした。この大部分はコンストラクタの実行とコードの永続化に使われています。

実行前に確定する下限は、意外と込み入っている

デプロイに最低限かかるガスは、送る data だけから計算できます。鍵もノードも要りません。

npm run token の実行結果のターミナル画面。「4. デプロイ tx の中身」として to が「(空)← これがデプロイの本体」、data が「1670 バイト = creation code 1638 + 引数 32」、value が 0、初期供給が 1000000000 NKMR と表示されている。続いて「実行前に確定する下限は、この data だけから計算できる」として、基本コストが「53,000 = 送金 21,000 + 生成 32,000」、calldata が「26000 = ゼロ 60×4 + 非ゼロ 1610×16(EIP-2028)」、initcode が「106 = 53 語 × 2(EIP-3860)」、通常計算が 79106、calldata 下限が「86000 = 21,000 + 10 × 6500 トークン(EIP-7623)」、最後に「→ 下限 86000(大きい方が効く)」と並ぶ。下部に「calldata が多く実行が軽い tx では、EIP-7623 の下限のほうが上に来る」「デプロイはまさにその形なので、下限を決めているのは calldata 側」と注記されている。

内訳はこうなります。上 3 行が積み上げの要素で、下 2 行は競合する 2 つの下限です。

項目根拠
基本コスト53,000送金の 21,000 + 生成分 32,000
calldata26,000ゼロ 60 バイト×4 + 非ゼロ 1,610 バイト×16(EIP-2028)
initcode106ceil(1670/32) = 53 語 × 2 gas(EIP-3860)
通常計算の合計79,106上記の和
calldata 下限86,00021,000 + 10 gas × 6,500 トークン(EIP-7623)

実際に効く下限は最後の 86,000 のほうです。EIP-7623 は「calldata を大量に積むわりに実行が軽い」トランザクションが安く通ってしまうのを防ぐために導入されたもので、ゼロバイトを 1、非ゼロバイトを 4 と数えたトークン数に 10 gas を掛けた値を下限にします。コントラクトのデプロイはまさにこの形なので、通常計算ではなく下限側が効きます。

なお data は 1,670 バイトで、その内訳は creation code 1,638 バイトとコンストラクタ引数 32 バイトです。calldata コストは両方に掛かります。

この下限は、最初は間違えて計算していました

上の表は書き直したものです。最初に実装した intrinsicGas() は「基本コスト + calldata」だけを数えており、EIP-3860 の initcode 費用も EIP-7623 の下限も入っていませんでした。デモは 79,000 と表示し、記事にもその数字を書いていました。

公開前の事実確認で、仕様の原文(execution-specs の calculate_intrinsic_cost)と突き合わせて気づきました。裏を取るために、Sepolia のノードへ eth_estimateGas を投げて課金の傾きを測っています。

非ゼロバイト:
  n=  0 gas=21165
  n=100 gas=25340 傾き=41/byte
  n=200 gas=29348 傾き=40/byte
  n=300 gas=33356 傾き=40/byte
ゼロバイト:
  n=  0 gas=21165
  n=100 gas=22334 傾き=11/byte
  n=200 gas=23336 傾き=10/byte

EIP-2028 だけなら非ゼロ 16 / ゼロ 4 になるはずのところ、実測は 40 と 10 でした。これは EIP-7623 の下限(10 gas × トークン数、非ゼロは 4 トークン)そのものです。下限側が効いていることが、実際の課金からも確認できます。

gasLimit を 533,235 取っていたので、この計算違いでデプロイが失敗することはありませんでした。ただ「実行前に確定する下限」と書いた値が誤っていたので、ラボのコードごと直しています。

本当に ERC-20 として応答するか

デプロイできたことと、規格どおり動くことは別なので、自前の JSON-RPC クライアントから読み出して確認します。

  name()                 Nakamura Coin
  symbol()               NKMR
  decimals()             18
  totalSupply()          1000000000
  balanceOf(自分)          1000000000

name() は動的型の string を返すので、前述の「オフセット → 長さ → 本体」を自前でたどった結果です。全量が配置者に入っており、mint を持たせていないのでこれ以上増えません。

creation code と runtime code は長さが違う

デプロイ後に eth_getCode でチェーン上のコードを取得すると、送ったものより短くなっていました。

バイト数
送った creation code1,638
保存された runtime code1,482
156

送ったコードと保存されたコードが別物であることが、そのまま数字に出ています。差の 156 バイトが、一度しか動かないコンストラクタと、runtime code を返す処理にあたります。

送ってみる

最後に 1,000 NKMR を別のアドレスへ送りました。以下は読み取り専用の --inspect モードの出力で、鍵も送信も要らないため第三者でも同じものを再現できます。

npm run token:inspect の実行結果のターミナル画面。「チェーン上に保存されたコード」として creation code 1638 バイト、runtime code 1482 バイト、差 156 バイト(コンストラクタと配置処理のぶん)が並ぶ。「ERC-20 として応答するか」として name() が Nakamura Coin、symbol() が NKMR、decimals() が 18、totalSupply() が 1000000000。「Transfer ログを自前デコーダで復号する」としてブロック 11527002、gasUsed 51605、実費 0.00005724229263156 SepoliaETH、from が 0x51d00559747DE02B54136916F0cA3d77fC21E5CC、to が 0x9d8A62f656a8d1615C1294fd71e9CFb3E4855A4F、value が 1000 NKMR。「送信後の残高」として 0x51d0…E5CC が 999999000 NKMR、0x9d8A…5A4F が 1000 NKMR、totalSupply が 1000000000(不変)と表示され、「送金は残高の写像を 2 つ書き換えるだけで、総供給は動かない。」と注記されている。

コントラクトが発行した Transfer ログを、自分で書いたデコーダで読み戻せています。

ログは「トピック」と「データ」の 2 つの区画に分かれています。イベント定義で indexed を付けた項目はトピック側に入り、外部から検索の条件に使えます(「このアドレス宛の Transfer だけ取り出す」といった絞り込みができる)。付けなかった項目はデータ側にまとめて入り、検索はできませんが安く済みます。Transferfromtoindexedvalue がデータ側という配分で、「誰の出入りか」で引けて「いくらか」は取り出して読む、という設計になっています。

送金の消費は 51,605 で、デプロイの 422,071 と比べるとかなり小さくなります。デプロイはコードを永続化する操作なので高く、以後の送金は写像を 2 つ書き換えるだけ、という差です。

なお実費はテストネットなので実質ゼロですが、参考までに記事執筆時点のメインネットの base fee は 0.042 gwei(1 gwei は 10 億分の 1 ETH)で、同じ 422,071 gas なら 0.000018 ETH 程度でした。ガス価格は時期によって二桁以上変動するので、あくまでこの時点の値です。

作った後に残るもの

作る作業そのものは、ここまで見てきたとおりの分量で終わりました。今回触っていない範囲に、別の難しさが残っています。以下は実装で確かめたことではなく、調べた範囲での整理です。

まず流動性です。取引所に上場するか、分散型取引所(DEX)に自分で資金を置いて流動性を供給しない限り、そのトークンに価格は存在しません。デプロイした直後のトークンは、単に「誰かのアドレスに数字が入っている」状態です。

次にコミュニティです。ドージコインが 2013 年のジョークから現在まで残っているのは、技術が優れていたからというより、コミュニティが続いたからと言われています。これはコードを書いても手に入らない部分です。

独自チェーンを選んだ場合はさらに安全性の問題が加わります。前述のとおり、掘る人がいないチェーンは 51% 攻撃のコストが下がります。

つまり「コインを作る」の難しさは、コードの外側に寄っているように見えます。今回の範囲で確かめられたのは、コード側が小さく済むところまでです。

日本の規制について

2026 年 7 月 15 日に「金融商品取引法及び資金決済に関する法律の一部を改正する法律」が成立し、同月 23 日に公布されました(令和 8 年法律第 64 号)。暗号資産取引の規制の根拠法が資金決済法から金融商品取引法へ移り、情報公表規制・業規制・インサイダー取引規制が整備される内容です。暗号資産に関する部分は公布から 1 年以内の政令で定める日に施行されるとされており、2027 年中の施行が見込まれています。

また 2026 年 3 月 31 日に成立した税制改正(所得税法等の一部を改正する法律)により、暗号資産の譲渡による所得を申告分離課税とする枠組みが法律上は整いました。ただし適用されるのは上記の金商法改正の施行日が属する年の翌年 1 月 1 日以後に行う譲渡からで、調べた限りでは 2028 年開始の見込みです。それまでは従来どおり総合課税(雑所得)の扱いになります。

調べた限りでは、トークンを作る行為そのものに登録が要るという整理にはなっていないようです。一方で、不特定多数に対して販売したり交換の場を提供したりすると暗号資産交換業に該当し、無登録での営業には罰則があります。収益を目的とする場合は、着手前に専門家に確認する範囲だと思います。

今回作ったトークンはテストネット上のもので、価値を持たせる意図はありません。

手元で試す

リポジトリの該当モジュールは以下のコマンドで動きます。

npm run token           # 送信せずに、何を送ろうとしているかを表示
npm run token:compile   # erc20.sol → bytecode.ts を再生成
npm test                # 統合自己検証 (06-token の 19 項目を含む)

# デプロイ済みのものを読むだけ。鍵も資金も要らないので、ここが追試の入口になる
npm run token:inspect -- 0xCB71635E70a95dccd6D0e41DC72d898d88b80785 \
  --tx 0x3c1bf98b5a909e482e7358ef5a7aaa83064663f11c0473ffc241bf85c3086d34

# 実際にデプロイする (秘密鍵は 1Password から注入)
op run --env-file=.env.local -- npm run token:deploy
op run --env-file=.env.local -- npm run token:deploy -- --transfer 0x受取先アドレス

引数なしの npm run token は送信を行わないので、テストネットの資金を用意しなくても、creation code・関数セレクタ・ガスの下限・予測アドレス・署名済みトランザクションの中身までは確認できます。--inspect はデプロイ済みのコントラクトを読むだけなので、こちらも資金は要りません。

参考