maybe daily dev notes

私の開発日誌

EtherscanのContract verificationをAPIで実行する

渦中のEthereumネタ。

はじめに

Etherscanでコントラクトのアドレスを開くと、コードが表示されることがある。

Contractのコード

コントラクトのユーザーたちは、このコードを見ることで中の実装を把握でき、安心してコントラクトを利用することができる。 実はEthereum自体にはデプロイされたスマートコントラクトのコードを取得する機能はなく、これはEtherscanがオフチェインで提供している機能である。

この機能を使うためには、コントラクトをデプロイした後、Etherscanにコンパイルの構成情報とソースコードをEtherscanに教えれば良い。これによりEtherscanが改めてコードをコンパイルし、デプロイされたコントラクトとコードが合致することを検証する。これは次のページから申請することができる。Verify & Publish Contract Source Code

今回の記事では、この作業をプログラマブルかつhardhatなどのツールを使わずに行う方法を説明する。なお、この公式ドキュメントに同じタイトルのページがあるが、このページは情報が古く不完全である。今回紹介する方法は、最新でかつより美しい形 (後述) でCodeをEtherscanに登録することができる。

方法

公式ドキュメントどおりにやる(80点の)方法

Etherscan 公式ドキュメントに従えば、次の手順で実行できる:

  1. コントラクトをコンパイル・デプロイし、コントラクトのアドレスを得る
  2. コントラクトのソースコードをflattenする。flattenとは、すべてのインポートを削除し、インポート対象のコードを1ファイルに含めてしまうこと。これには solidity-flattener と呼ばれるツールを使えば良い。大抵各言語でプログラマブルに呼び出し可能なものが開発されており、例えば nodejs, python など。
  3. 下記をパラメータとして、Etherscan APIを実行
  4. VerificationリクエストのGUIDが返ってくる
  5. GUIDを使い、適宜こちらのAPIでVerificationの進行状況をポーリング
  6. 通常10秒程度でVerificationが完了する

この方法では、flattenされたコントラクトコードがそのままEtherscanに登録されるため、ユーザーたちはコントラクトの実装を読みづらい状態となってしまう。例えば、こちらのコントラクトがその状態。

100点の方法

こちらの方法を使えば、コードをflattenせずに登録することができる。例えばこのコントラクトのように。

この方法では、先に紹介した方法のうち2と3だけを変えれば良い。実はVerification用のAPIcodeformat: 'solidity-standard-json-input' と入力すると solcのstandard json input形式を入力することができるので、これを活用する。

まず、送るべきAPIリクエストは以下。

import fetch from 'node-fetch';
// ...
  await fetch(etherscanApiEndpoint, {
    method: 'POST',
    body: new URLSearchParams({
      apiKey: etherscanApiKey,
      module: 'contract',
      action: 'verifysourcecode',
      codeformat: 'solidity-standard-json-input',
      compilerversion,
      constructorArguements,
      sourcecode: standardJson,
      contractaddress,
      contractname,
    }).toString(),
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  });

それぞれの変数の中身を解説していく。

  • etherscanApiEndpoint: Etherscan APIのエンドポイント。Endpoint URLs - Etherscan に掲載されている。Networkごとにエンドポイントが異なるので注意。
  • etherscanApiKey: Etherscan APIAPIキー。ユーザー登録すれば入手可能
  • compilerVersion: デプロイしたコントラクトをコンパイルしたsolcのバージョン。ここ に掲載されている文字列以外はエラーになるので注意。例えば solc-js は以下のような文字列を返すので、不要な部分を削除する必要あり (ここでは .Emscripten.clang の部分。)
> const solc = require('solc')
> solc.version()
'0.8.13+commit.abaa5c0e.Emscripten.clang'
  • constructorArguements: コントラクトをデプロイした際に指定した、コンストラクタの引数をABIエンコードしたもの。これは web3.js を使うと、次のコードで取得できる。
import Web3 from 'web3';
// ...
  // コントラクトをコンパイルして得られるABIとBytecode
  const abi = {...};
  const bytecode = '0x...';
  // コントラクトをデプロイ
  const contract = new Web3.eth.Contract(abi);
  const call = contract.deploy({
    data: bytecode,
    arguments: [1, 2, 3, 4], // コンストラクタ引数
  });
  const constructorArguments = call.encodeABI().slice(bytecode.length);
  // ....

call.encodeABI() で得られる文字列は以下のフォーマットになっている

0x[コントラクトのバイトコード][ABIエンコードされたコンストラクタ引数]

ので、最初のコントラクトのバイトコードを削除すれば、ABIエンコードされたコンストラクタ引数が得られるという仕組み。

ちなみにArguementsはArgumentsのtypoと思われるが、API後方互換性を保つために今となっては直すに直せないのだろう。

  • standardJson: これが肝。このドキュメント のとおりにJSONを作る必要がある。シンプルにすると、概ね以下のようなJSONを作ればOK。
{
    "language": "Solidity",
    "sources": {
        "input.sol": {
            "content": "// SPDX-License-Identifier: MIT\n\npragma solidity ^0.8.0;\n\nimport \"@openzeppelin/contracts/access/Ownable.sol\";\nimport \"@openzeppelin/contracts/security/ReentrancyGuard.sol\";\nimport \"erc721a/contracts/ERC721A.sol\";\nimport \"@openzeppelin/contracts/utils/Strings.sol\";\n\ncontract MyContract is Ownable, ERC721A, ..."
        },
        "@openzeppelin/contracts/security/ReentrancyGuard.sol": {
            "content": "..."
        },
        "erc721a/contracts/ERC721A.sol": {
            "content": "..."
        },
        ...
    },
    "settings": {
        "optimizer": {
            "enabled": true,
            "runs": 200
        },
        "outputSelection": {
            "input.sol": {
                "*": [
                    "evm.bytecode.object",
                    "abi",
                    "evm.deployedBytecode"
                ]
            }
        }
    }
}

要は sources フィールドに必要なすべてのファイルのソースコードを含めればOK。↑の例で input.sol がデプロイするコントラクト、それ以外が import で依存しているコントラクト。依存するコントラクトを含めるのが少し厄介なところだが、一応次のコードで達成することができる。

import * as fs from 'fs';
import solc from 'solc';
let standardJson: any;

const getStandardJson = async (fileName: string, contractName: string) => {
  const file = fs.readFileSync(fileName).toString();
  const output = 'input.sol';
  const input = {
    language: 'Solidity',
    sources: {
      [output]: {
        content: file,
      },
    },
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
      outputSelection: {
        [output]: {
          '*': ['evm.bytecode.object', 'abi', 'evm.deployedBytecode'],
        },
      },
    },
  };
  // standardJson を初期化
  standardJson = input;
  solc.compile(JSON.stringify(input), { import: findImports });
  return standardJson;
};

const importCache: { [key: string]: { contents: string } } = {};
const findImports = (path: string) => {
  if (importCache[path] == null) {
    const file = fs.readFileSync(`node_modules/${path}`);
    importCache[path] = {
      contents: file.toString(),
    };
  }
  // standardJson に依存するソースコードを追加
  standardJson.sources[path] = { content: importCache[path].contents };
  return importCache[path];
};

要は、コンパイルして依存するソースコードを拾っていこうというアイデア。明らかに無駄の多い方法ではあるが少なくとも動作するので、一旦こうしている。ベターな方法を知っている方いれば教えて下さい。

また、もちろん settings.optimizer の部分は実際にコントラクトをコンパイルしたときの設定と同一にすること。これで standardJson は得られた。

  • contractaddress: デプロイしたコントラクトのアドレス
  • contractname: コントラクトのエントリポイントを特定するための文字列。↑の例の場合は input.sol:MyContract とする。要はメインのコードのファイル名とコントラクトの名前を : でつなげればOK。

 これでEtherscanへのAPIリクエストを作ることができた!あとは↑のコードを参考にPOSTリクエストを送信すればOK!

まとめ

Hardhatなどを使わずにAPI経由でEtherscanでコントラクトをverifyする方法を紹介した。現状、この方法を見つけるためだけにわざわざHardhatのコードを読み込む必要があるという悲しい現実もある。この方法が正しくEtherscanのドキュメントにも明記されることを望む。