はじめに

初めまして。 CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です! スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

代表的なゲームはクリプトスペルズというブロックチェーンゲームです。

今回は、バイトコードに変更不可のメタデータを追加した、プロキシコントラクを作成するための最小限の実装を提案している規格であるERC3448についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なERCについてまとめています。

この記事では、EthereumのOpcodesについても取り扱っているため、興味がある方は以下の記事を参考にしてください。

概要

この標準は、ブロックチェーン上でのコントラクトがどのように動くかを明確にし、信頼性を高めるためのものです。 具体的には、最小限のバイトコードプロキシという技術を使って、コントラクトが他のコントラクトへのリダイレクト(つまり、別の場所で実行される命令へのポインタ)を常に同じ方法で行うことを保証します。 これにより、ユーザーやツール(Etherscanなど)は、コントラクトがどこにリダイレクトしているのかと、それに付随するメタデータ(情報)が何かを簡単に理解できます。

この標準に従ったコントラクトは、DELEGATECALLという機能を使って、すべての呼び出しを特定のコード(実装コントラクト)に転送します。 この時、送られる情報にはユーザーからの入力(calldata)と、コントラクトの末尾にあるメタデータが含まれます。 そして、実装コントラクトからの結果が呼び出し元に正しく戻されます。 もし何らかの理由で実装コントラクトが失敗(revert)した場合は、そのエラー情報も一緒に戻されます。

これにより、ERC20やERC721などのコントラクトを使う時に、そのコントラクトが確実に期待通りに動作するという保証が得られます。 これは、ブロックチェーン上でのやり取りをより安全で信頼性の高いものにし、開発者やユーザーに安心を提供します。

ERC20については以下の記事を参考にしてください。

ERC721については以下の記事を参考にしてください。

動機

この標準は、あるコントラクトの機能をまったく同じまま、少しの設定を変えて新しい場所に設置したい場合に使えます。 これを「クローン」と呼びます。

簡単に言えば、お気に入りのレシピで作ったケーキを、ちょっとした味の変化で何度も焼きたいようなものです。 例えば、ERC20やERC721のようなトークンコントラクトを作る時、同じ基本機能に少しだけ違う特徴を加えたいと考えたら、この標準を使って、新しいトークンを簡単に多く作ることができます。 各クローンコントラクトは、元のコントラクトの能力をそのまま保ちつつ、異なる設定(パラメータ)を持つことができます。 これにより、さまざまなニーズや条件に合わせたカスタマイズが容易になります。

仕様

MetaProxyコントラクトは、特定のバイトコードに基づいて動作するコントラクトです。 このバイトコードの一部分を、別のコントラクト(マスター機能コントラクト)のアドレスに置き換えることで、MetaProxyコントラクトはマスター機能コントラクトの機能を使えるようになります。 これは、あるコントラクトが別のコントラクトの機能を「借りて使う」ようなイメージです。

text
20 bytes target contract address
----------------------------------------
363d3d373d3d3d3d60368038038091363936013d7300000000000000000000000000000000000000005af43d3d93803e603457fd5bf3

MetaProxyコントラクトの正確なバイトコードは、上記の通りです。 このコードの中で、インデックス21から41まで(両方を含む)の20バイトは、マスター機能コントラクトの20バイトアドレスで置き換えられます。 つまり、この部分にマスターコントラクトのアドレスをセットすることで、MetaProxyコントラクトがその機能を引き継げるようになります。

更に、このコントラクトには、メタデータを追加することができます。 メタデータとは、コントラクトに関する追加情報のことで、開発者が任意で設定することができます。 このメタデータの長さは、バイトコードの最後の部分で示されます。 これにより、コントラクトは自分にどれだけの追加情報がついているのかを知ることができます。

text
<54 bytes metaproxy> <arbitrary data> <length in bytes of arbitrary data (uint256)>

バイトコードの最後の32バイト(ワード単位)は、そのメタデータの長さをバイト数で示す必要があります。 これにより、コントラクトはどれだけのメタデータが付随しているのかを把握できます。

この形式に従ってコントラクトが作られると、54バイトのMetaProxyコードに続いて任意のデータがあり、最後にその任意のデータの長さが記されることになります。 これにより、コントラクトの機能を維持しつつ、追加の情報を柔軟に付加することができるようになります。

このバイトコードの形式に従うことで、MetaProxyコントラクトはマスター機能コントラクトの機能を利用しながら、必要に応じて追加情報を持つことができます。 これは、柔軟に機能を拡張できるようにするための仕組みです。

補足

この取り組みの目標は、コントラクトの運用をより効率的かつコスト効果的にするためのものです。 具体的には以下の3つの点に焦点を当てています。

安価なメタデータ格納

通常、コントラクトに情報を保存するには「ストレージスロット」という領域を使いますが、これは費用がかかります。 この目標は、各子コントラクトに追加情報(メタデータ)を格納する安価な方法を提供することです。このメタデータは一度設定されると変更できない「不変」なものです。

クローンの安価なデプロイメント

既存のコントラクトの機能を複製して新しい場所に配置する「クローン」を作るコストを抑えます。 これにより、同じ基本機能を持つ多くのコントラクトを効率的に展開できるようになります。

エラーメッセージの効率的な伝達

コントラクトがエラーに遭遇した場合、そのエラーメッセージを呼び出し元に正確に伝える「エラーリターンバブリング」を処理します。 これにより、開発者は何が間違っているのかをすぐに理解し、適切に対応できるようになります。

これらの目標は、ERC20やERC721などのコントラクトを含む、さまざまな種類のコントラクトに適用され、より経済的で効率的なブロックチェーンエコシステムを実現することを目指しています。

ストレージスロット

スマートコントラクトのストレージスロットは、ブロックチェーン上のスマートコントラクトがデータを永続的に保存する場所です。 スマートコントラクトは基本的に小さなプログラムで、ブロックチェーン上で動作しますが、そのプログラムが実行される時に必要なデータをどこかに保存しておく必要があります。 ここでいう「どこか」とは、ストレージスロットのことです。

ストレージスロットは、基本的には大きなキャビネットのようなもので、各引き出し(スロット)にデータを保存することができます。 スマートコントラクトが動作するたびに、必要なデータをこのキャビネットから取り出したり、新しいデータを格納したりします。

ただし、ブロックチェーンのストレージは非常に高価です。 なぜなら、保存したデータはブロックチェーンネットワーク上の各参加者によってコピーされ、永久に保持されるため、スペースを取るということは多くのリソースを消費することを意味します。 そのため、スマートコントラクト開発者は通常、できるだけ少ないストレージスロットを使用し、効率的にデータを保存する方法を考える必要があります。

総じて、ストレージスロットはスマートコントラクトがデータを永続的に保存するための重要な部分であり、その使用はコストと密接に関連しています。

互換性

互換性の問題はありません。

テスト

スマートコントラクトをテストするというのは、コントラクトが正しく動くかどうかをチェックすることです。 以下は、そのテストを行うためのいくつかの方法です。

引数なしの呼び出し

これは「何も教えてあげないで、コントラクトを動かしてみる」テストです。 関数が特に外からの情報なしに正しく動くかを見ます。 これは、基本的な動作が正しいかを確認するために重要です。

引数ありの呼び出し

「この情報を使って、コントラクトを動かしてみて」と指示して、コントラクトがその情報を正しく使って動くかをテストします。 これは、コントラクトが外部からの情報を適切に扱えるかを確認するのに役立ちます。

戻り値のある呼び出し

コントラクトが何かしらの「答え」を返す時、その答えが正しいかを見ます。 これにより、コントラクトが期待通りの結果を出せているかを確認できます。

リバート時の呼び出し

もしコントラクトが何らかの理由で「これはダメだ」と判断し、途中で止まる(revertする)時、その理由がきちんと伝わるかをチェックします。 これは、問題が起きた時に、それが何であるかを正しく知らせるために重要です。

これらのテストは、Ethereum Improvement Proposal(EIP)のアセットディレクトリにあるSolidityコントラクトで見つけることができます。 テストをしっかり行うことで、ERC20やERC721などのコントラクトが予期せぬ問題なく動くことを確認でき、より安全なブロックチェーン環境を作ることができます。

実装

実装は以下に格納されています。

デプロイメントバイトコード

text
// PUSH1 11;
// CODESIZE;
// SUB;
// DUP1;
// PUSH1 11;
// RETURNDATASIZE;
// CODECOPY;
// RETURNDATASIZE;
// RETURN;

上記のデプロイメントバイトコードは、スマートコントラクトがブロックチェーン上に展開される時に実行される命令セットです。 それぞれの命令は、特定の操作を行います。 以下は、それぞれの命令が何をするのかについての説明です。

  1. PUSH1 11;
    • これは「11」という値をスタックにプッシュします。
    • つまり、次の操作で使うために「11」という数字を一時的に保存します。
  2. CODESIZE;
    • この命令は、コントラクトの全コードサイズをスタックにプッシュします。
    • つまり、コントラクト全体がどれだけの大きさかを確認するための数値がスタックに追加されます。
  3. SUB;
    • スタックの上位2つの数値を取り出し、減算を行い、その結果をスタックにプッシュします。
    • この場合、全コードサイズから「11」を引きます。
  4. DUP1;
    • スタックの一番上の値を複製し、その複製をスタックにプッシュします。
    • つまり、前の操作の結果をコピーします。
  5. PUSH1 11;
    • 再度「11」という値をスタックにプッシュします。
  6. RETURNDATASIZE;
    • 現在の戻りデータのサイズ(バイト数)をスタックにプッシュします。
    • ただし、この時点ではまだデータがないため、通常は「0」がプッシュされます。
  7. CODECOPY;
    • スタックから3つの値をポップし(目的地のオフセット、ソースのオフセット、コピーする長さ)、その情報に基づいてコードの一部をメモリにコピーします。
  8. RETURNDATASIZE;
    • 再度、現在の戻りデータのサイズをスタックにプッシュします。
    • この時点でも通常は「0」がプッシュされます。
  9. RETURN;
    • スタックから2つの値をポップし(オフセットと長さ)、それらを使用してメモリからデータを呼び出し元に返します。
    • これにより、実際のコントラクトコードがブロックチェーンにデプロイされます。

これらの命令は一連の流れとして機能し、スマートコントラクトがブロックチェーンにデプロイされる際の基本的なプロセスを形成しています。

MetaProxy

text
// copy args
// CALLDATASIZE;   calldatasize
// RETURNDATASIZE; 0, calldatasize
// RETURNDATASIZE; 0, 0, calldatasize
// CALLDATACOPY;

// RETURNDATASIZE; 0
// RETURNDATASIZE; 0, 0
// RETURNDATASIZE; 0, 0, 0
// RETURNDATASIZE; 0, 0, 0, 0

// PUSH1 54;       54, 0, 0, 0, 0
// DUP1;           54, 54, 0, 0, 0, 0
// CODESIZE;       codesize, 54, 54, 0, 0, 0, 0
// SUB;            codesize-54, 54, 0, 0, 0, 0
// DUP1;           codesize-54, codesize-54, 54, 0, 0, 0, 0
// SWAP2;          54, codesize-54, codesize-54, 0, 0, 0, 0
// CALLDATASIZE;   calldatasize, 54, codesize-54, codesize-54, 0, 0, 0, 0
// CODECOPY;       codesize-54, 0, 0, 0, 0

// CALLDATASIZE;   calldatasize, codesize-54, 0, 0, 0, 0
// ADD;            calldatasize+codesize-54, 0, 0, 0, 0
// RETURNDATASIZE; 0, calldatasize+codesize-54, 0, 0, 0, 0
// PUSH20 0;       addr, 0, calldatasize+codesize-54, 0, 0, 0, 0 - zero is replaced with shl(96, address())
// GAS;            gas, addr, 0, calldatasize+codesize-54, 0, 0, 0, 0
// DELEGATECALL;   (gas, addr, 0, calldatasize() + metadata, 0, 0) delegatecall to the target contract;
//
// RETURNDATASIZE; returndatasize, retcode, 0, 0
// RETURNDATASIZE; returndatasize, returndatasize, retcode, 0, 0
// SWAP4;          0, returndatasize, retcode, 0, returndatasize
// DUP1;           0, 0, returndatasize, retcode, 0, returndatasize
// RETURNDATACOPY; (0, 0, returndatasize) - Copy everything into memory that the call returned

// stack = retcode, 0, returndatasize # this is for either revert(0, returndatasize()) or return (0, returndatasize())

// PUSH1 _SUCCESS_; push jumpdest of _SUCCESS_
// JUMPI;          jump if delegatecall returned `1`
// REVERT;         (0, returndatasize()) if delegatecall returned `0`
// JUMPDEST _SUCCESS_;
// RETURN;         (0, returndatasize()) if delegatecall returned non-zero (1)

上記のMetaProxyバイトコードは、スマートコントラクトが別のコントラクトへの委任呼び出し(DELEGATECALL)を行う時の一連の操作を表しています。

  1. CALLDATASIZE

    • この命令は呼び出しデータのサイズ(バイト数)をスタックにプッシュします。
    • これは、コントラクトへ渡された入力データの長さを知るために使われます。
  2. RETURNDATASIZE

    • 現在の戻りデータのサイズをスタックにプッシュします。
    • 初期状態ではこの値は0ですが、命令の実行後に呼び出し元にデータを返す際に使用されます。
  3. CALLDATACOPY

    • 呼び出しデータをメモリにコピーします。
    • これは、他のコントラクトにデータを渡す前に、そのデータを一時的に保存するために使われます。
  4. PUSH1 54

    • 「54」という数値をスタックにプッシュします。
    • この値は後続の計算や操作に使用されます。
  5. DUP1

    • スタックの一番上の要素を複製して、そのコピーをスタックにプッシュします。
  6. CODESIZE

    • 現在のコントラクトのコードサイズをスタックにプッシュします。
    • これは、全体のコード長を知るために使用されます。
  7. SUB

    • スタックの上位2つの数値を減算し、結果をスタックにプッシュします。
  8. SWAP2

    • スタックの上から2番目と3番目の要素の位置を交換します。
  9. CODECOPY

    • コードの特定の部分をメモリにコピーします。
  10. ADD

    • スタックの上位2つの数値を加算し、結果をスタックにプッシュします。
  11. PUSH20 0

    • 20バイトの0をスタックにプッシュします。
    • これは通常、特定のアドレスをプッシュするために使用され、後で実際のアドレスに置き換えられます。
  12. GAS

    • 利用可能なガスの量をスタックにプッシュします。
  13. DELEGATECALL

    • 別のコントラクトに委任呼び出しを行います。
    • これにより、現在のコントラクトのコンテキストで別のコントラクトのコードが実行されます。
  14. RETURNDATACOPY

    • 呼び出しの戻りデータをメモリにコピーします。
  15. PUSH1 SUCCESS

    • 成功した場合にジャンプする場所を示すために使用される値をスタックにプッシュします。
  16. JUMPI

    • 条件が真の場合に指定された位置にジャンプします。この場合、DELEGATECALLが成功したかどうかをチェックします。
  17. REVERT

    • 呼び出しが失敗した場合にトランザクションをリバートし、エラーメッセージを含むデータを戻します。
  18. JUMPDEST SUCCESS

    • ジャンプの目的地としてマークされた場所です。ここからコードの次の部分が実行されます。
  19. RETURN

    • メモリからデータを呼び出し元に返します。これは通常、呼び出しが成功した後に実行されます。

これらの命令は一連のプロセスを形成し、他のコントラクトに委任呼び出しを行い、その結果を処理するMetaProxyコントラクトの動作を定義します。

例

abi.encodeを使ったプロキシの構築

javascript
/// @notice MetaProxy construction via abi encoded bytes.
function createFromBytes (
  address a,
  uint256 b,
  uint256[] calldata c
) external payable returns (address proxy) {
  // creates a new proxy where the metadata is the result of abi.encode()
  proxy = MetaProxyFactory._metaProxyFromBytes(address(this), abi.encode(a, b, c));
  require(proxy != address(0));
  // optional one-time setup, a constructor() substitute
  MyContract(proxy).init{ value: msg.value }();
}

createFromBytes関数は、abi.encodeを使用してメタデータを結果として得る新しいプロキシを作成します。 この関数は、アドレスa、数値b、数値配列cを引数として受け取り、MyContractの初期設定(コンストラクターの代わり)をオプションで行った後、新しいプロキシのアドレスを返します。

calldataを使ったプロキシの構築

javascript
/// @notice MetaProxy construction via calldata.
function createFromCalldata (
  address a,
  uint256 b,
  uint256[] calldata c
) external payable returns (address proxy) {
  // creates a new proxy where the metadata is everything after the 4th byte from calldata.
  proxy = MetaProxyFactory._metaProxyFromCalldata(address(this));
  require(proxy != address(0));
  // optional one-time setup, a constructor() substitute
  MyContract(proxy).init{ value: msg.value }();
}

createFromCalldata関数は、呼び出しデータ(calldata)から直接メタデータを取得して新しいプロキシを作成します。 この関数も同様に、MyContractの初期設定をオプションで行い、新しいプロキシのアドレスを返します。

calldataとabi.decodeを使用してメタデータを取得

javascript
/// @notice Returns the metadata of this (MetaProxy) contract.
/// Only relevant with contracts created via the MetaProxy standard.
/// @dev This function is aimed to be invoked with- & without a call.
function getMetadataWithoutCall () public pure returns (
  address a,
  uint256 b,
  uint256[] memory c
) {
  bytes memory data;
  assembly {
    let posOfMetadataSize := sub(calldatasize(), 32)
    let size := calldataload(posOfMetadataSize)
    let dataPtr := sub(posOfMetadataSize, size)
    data := mload(64)
    // increment free memory pointer by metadata size + 32 bytes (length)
    mstore(64, add(data, add(size, 32)))
    mstore(data, size)
    let memPtr := add(data, 32)
    calldatacopy(memPtr, dataPtr, size)
  }
  return abi.decode(data, (address, uint256, uint256[]));
}

getMetadataWithoutCall関数は、この(MetaProxy)コントラクトのメタデータを返します。 この関数は、呼び出しデータからメタデータのサイズと位置を決定し、abi.decodeを使ってデコードして、アドレスa、数値b、数値配列cの形でメタデータを返します。

自身への呼び出しを使ってメタデータを取得

javascript
/// @notice Returns the metadata of this (MetaProxy) contract.
/// Only relevant with contracts created via the MetaProxy standard.
/// @dev This function is aimed to to be invoked via a call.
function getMetadataViaCall () public pure returns (
  address a,
  uint256 b,
  uint256[] memory c
) {
  assembly {
    let posOfMetadataSize := sub(calldatasize(), 32)
    let size := calldataload(posOfMetadataSize)
    let dataPtr := sub(posOfMetadataSize, size)
    calldatacopy(0, dataPtr, size)
    return(0, size)
  }
}

getMetadataViaCall関数も、この(MetaProxy)コントラクトのメタデータを返しますが、こちらは自己呼び出しを通じてデータを取得します。 この関数はメモリの特定の位置にメタデータをコピーし、そのデータを呼び出し元に返します。

これらの例の他にも、Solidityの構造体やカスタムデータエンコーディングを使用することも可能です。 これらの方法は、ERC20やERC721などのコントラクトを作成し、扱う際に、柔軟かつ効率的なアプローチを提供します。

セキュリティ

この標準は、コントラクトのバイトコード(基本的なコード)の設計に関するものですが、実際にそれがどんな影響を及ぼすかについては詳しく説明していません。 つまり、提供されているのはあくまで一つの例であり、実際にプロジェクトでこの標準に基づいたコントラクトを使う場合、その使用方法や具体的な実装によっては思わぬ問題が起こるかもしれません。 だからこそ、ERC20やERC721のようなコントラクトを使う時には、ただこの標準に従うだけでなく、どんな影響があるのかをしっかりと調べて理解することが大切です。 特に、予期せぬ副作用やリスクを避けるために、十分な検討と調査を行うことが推奨されています。

引用

pinkiebell (@pinkiebell), "ERC-3448: MetaProxy Standard," Ethereum Improvement Proposals, no. 3448, March 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3448.

最後に

今回は「バイトコードに変更不可のメタデータを追加した、プロキシコントラクを作成するための最小限の実装を提案している規格であるERC3448」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!