はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、Ethereum上で異なるトークン規格(ERC20とERC223)を相互に変換できる仕組みを導入し、互換性と利便性を高める仕組みを提案しているERC7417についてまとめていきます!

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

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

ERC7417は、Ethereum上で広く使われるトークン標準どうしを相互運用できるようにするための仕組みを提案しています。 具体的には、ある標準(ERC20)のトークンを、別の標準(ERC223)のトークンへいつでも相互変換できる「トークン変換サービス(Token Converter)」というスマートコントラクトを提供します。 ERC223に変換したトークンは、制限なくERC20へ戻すこともできます。 これにより、古い標準との互換性を損なわず、異なる標準が同時に共存し、相互に入れ替えて利用できるようになります。

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

この変換を行うユーザー操作はシンプルです。 ユーザーが元の標準のトークンをConverterコントラクトにデポジット(預け入れ)すると、Converterが自動で別標準のトークンをユーザーへ送り返します。

なお、ここで出てくる用語の意味は以下のとおりです。

  • ERC20 / ERC223 Ethereumで定義されたトークンの仕様(インターフェース)です。 ウォレットや取引所、DAppがトークンと安全にやり取りできるよう、関数やイベントの振る舞いが決められています。
  • スマートコントラクト Ethereum上で自動実行されるプログラムです。 トークンの発行や送受信ルール、変換処理などをコードで厳密に管理します。
  • Token Converter ERC7417で中核となるスマートコントラクトです。 ユーザーが預けたトークンを、対応する別標準のトークンへ1:1で変換して返します。
  • ラッパー(wrapper) 元の資産を担保に、別の形の資産を発行する仕組みです。 ここでは、ERC20版とERC223版の「同じ経済的価値のトークン」を相互にラップするイメージです。
sequenceDiagram
    participant U as ユーザー
    participant C as Token Converter
    participant T20 as ERC-20トークン
    participant T223 as ERC-223トークン

    U->>C: ERC-20トークンをデポジット
    C->>T20: 受領・ロック
    C-->>U: 等価のERC-223トークンを送付

    U->>C: ERC-223トークンをデポジット(巻き戻し)
    C->>T223: 受領・ロック
    C-->>U: 等価のERC-20トークンを送付

動機

ERC7417の狙いは、トークン標準の「アップグレード」を、特定のスマートコントラクトを介して随時行えるようにすることです。 既にERC20は最も普及しており、新しい標準(ここではERC223)へ一斉に移行する明確なプロセスがないことが、採用の妨げになっています。 Converterを用意しておけば、どの時点でもERC20とERC223を並行運用でき、必要に応じて双方向に変換できます。

また、現状では同一銘柄のトークンが複数チェーンや複数標準で流通することが一般的です。 例えばUSDTは、Ethereum上のERC20、TRON上のTRC20(本文では「TRX USDT」と表現)、BSC上のBEP20など、複数の形態で存在します。 ERC7417は、この「同じ価値を持つが標準が異なるトークン」をEthereumメインネット内だけでも共存させ、相互に変換できる状態を標準化するイメージです。 つまり、Ethereum上で「ERC20版USDT」と「ERC223版USDT」が同時に存在し、自由に入れ替えられる世界を想定しています。

信頼性の確保も重要な論点です。 Converterコントラクトのデプロイ先アドレスを提案本文に明記し、トークン開発者が「正しいConverter」と安全に連携できるようにします。 アドレスが公開・固定されることで、なりすましコントラクトへの誤接続や、相互運用の破綻を避けられます。

さらに、Converterが発行(あるいは包み替え)するERC223トークンは、すべて同一の関数セットと戻り値仕様を備える前提です。 これにより、標準そのものの解釈の揺れや、実装のバラつきによる非互換を避けられます。 ERC20では、transfer関数の戻り値取り扱いの違いが長年の課題でした。 仕様上は「成功時に bool を返す」必要がありますが、実際のトークンには以下の3つの実装が混在してきました。

カテゴリ 成功時の挙動 失敗時の挙動
① 戻り値あり・厳格 true を返す 例外(revert)で失敗を通知
② 戻り値あり・寛容 true/false を返す false を返し、revertしない場合がある
③ 戻り値なし 何も返さない 例外(revert)で失敗を通知

厳密には③はERC20仕様に非準拠ですが、実際にはEthereumメインネットのUSDT(アドレス:0xdac17f958d2ee523a2206206994597c13d831ec7)がこの型であり、エコシステムの中核にあるため「非準拠だから未対応」という選択肢は現実的ではありません。 このような歴史的事情から、同一標準内での実装差分が無視できない問題となってきました。

Converterは、こうした実装差の吸収に寄与します。 Converterが一貫した仕様のERC223版を提供することで、「早期の流行期に独自拡張されたトークン」や「仕様の解釈が分かれる実装」があっても、変換後は共通仕様のトークンとして扱えます。 これにより、周辺のスマートコントラクト(例:分散型取引所)側では、ラップ関係にある2つのトークンが「本質的に同一でいつでも相互変換可能」であることを前提に、ペアを1つの資産として扱いやすくなります。 結果として、同一資産の別標準版どうしを同一流動性として束ねる、といった設計が可能になります。

最後に、このサービスはEthereumメインネットに一度デプロイしたら恒久的に利用されることを想定しています。 Converterのアドレスを提案本文に明記することで、開発者は迷わず正規のConverterを参照でき、相互運用の前提が揺らがないようにします。

変換フロー

手順は直感的で、ユーザー側の操作は送付先アドレスをConverterにするだけです。

  1. 変換元の標準(例:ERC20)のトークンを、指定のConverterアドレスへ送付します。
  2. Converterは受け取ったトークンをロックし、等価数量の変換先標準(例:ERC223)のトークンを自動でユーザーに送付します。
  3. 元に戻したい場合は、逆方向(ERC223 → ERC20)に同様の手順を実行します。

このように、Converterは「価値の保存」、「1:1の交換」、「いつでも巻き戻せること」を担保し、ERC20とERC223の共存・相互運用を実現します。

仕様

システム構成

トークン変換システムは、以下の2つの主要コンポーネントから構成されます。

コンポーネント名 説明
Converterコントラクト トークンの入出金、変換、ラップ/アンラップ(包み込み・解除)処理を行う中核的なスマートコントラクト。
Wrapperコントラクト 元のトークンに対して作成されるラッパーコントラクトで、1つのトークンにつき1種類の標準ごとに1つだけ存在。

Converterコントラクトの基本動作

各ERC20トークンに対して、最大1つのERC223ラッパーが存在します。 ERC20 → ERC223変換時には、Converterが元のトークンを受け取り、同量のERC223トークンを新規発行して送信者に返します。 逆にERC223 → ERC20変換時には、Converterが受け取ったERC223トークンをBurn)し、元のERC20トークンを送り返します。 変換比率は常に1:1で固定されます。

Converterコントラクト

getERC20WrapperFor

javascript
function getERC20WrapperFor(address _token) public view returns (address)

指定したトークンアドレスに対応するERC20ラッパーのアドレスを取得する関数。 指定されたERC223トークンに対応するERC20バージョンを返します。 該当するラッパーが存在しない場合は0x0を返します。 1つのERC223トークンにつき、1つのERC20ラッパーのみ作成されます。

引数

  • _token
    • 対象トークンのアドレス。

戻り値

  • address
    • 対応するERC20ラッパーのアドレス。存在しない場合は0x0。

getERC223WrapperFor

javascript
function getERC223WrapperFor(address _token) public view returns (address)

指定されたトークンに対応するERC223ラッパーのアドレスを返す関数。 指定したERC20トークンに対するERC223ラッパーのアドレスを取得します。 存在しない場合は0x0が返されます。

引数

  • _token
    • 元となるERC20トークンのアドレス。

戻り値

  • address
    • 対応するERC223ラッパーのアドレス。

getERC20OriginFor

javascript
function getERC20OriginFor(address _erc223Token) public view returns (address)

指定されたERC223ラッパーに対応する元のERC20トークンを取得する関数。 指定アドレスがConverterによって生成されたERC223ラッパーである場合、そのラッパーに対応する元のERC20トークンアドレスを返します。 該当がなければ0x0を返します。

引数

  • _erc223Token
    • ERC223ラッパーのアドレス。

戻り値

  • address
    • 元のERC20トークンのアドレス。

getERC223OriginFor

javascript
function getERC223OriginFor(address _erc20Token) public view returns (address)

指定されたERC20ラッパーに対応する元のERC223トークンを返す関数。 ERC20トークンがConverterで作られたラッパーである場合、その元となるERC223トークンアドレスを返します。 該当がなければ0x0。

引数

  • _erc20Token
    • ERC20ラッパーのアドレス。

戻り値

  • address
    • 元のERC223トークンアドレス。

predictWrapperAddress

javascript
function predictWrapperAddress(address _token, bool _isERC20) view external returns (address)

未デプロイのラッパーコントラクトのアドレスを予測する関数。 この関数はCREATE2オペコードを利用して、まだデプロイされていないラッパーのアドレスを事前に算出します。 生成アドレスはバイトコードの内容に依存します。

  • _isERC20 = true の場合 ERC223ラッパーのアドレスを予測。
  • _isERC20 = false の場合 ERC20ラッパーのアドレスを予測。

引数

  • _token
    • 対象トークンのアドレス。
  • _isERC20
    • トークンがERC20であるかどうか。

戻り値

  • address
    • 予測されるラッパーのアドレス。

createERC223Wrapper

javascript
function createERC223Wrapper(address _erc20Token) public returns (address)

指定されたERC20トークンに対応する新しいERC223ラッパーを作成する関数。 すでにラッパーが存在する場合はトランザクションを revert します。 作成後は標準的なERC223仕様を満たし、互換性のためにapproveとtransferFrom関数も実装されています。 standard()関数により「223」を返し、トークン標準の識別に使用されます。

引数

  • _erc20Token
    • 元となるERC20トークンアドレス。

戻り値

  • address
    • 新規作成されたERC223ラッパーのアドレス。

createERC20Wrapper

javascript
function createERC20Wrapper(address _erc223Token) public returns (address)

指定されたERC223トークンに対応するERC20ラッパーを作成する関数。 すでにラッパーが存在する場合は revert します。 _erc223Tokenが実際にERC223であるかどうかを検証しません。 よって、誤って同一標準のラッパーを作成することも技術的には可能です。

引数

  • _erc223Token
    • 元のERC223トークンアドレス。

戻り値

  • address
    • 新しく作成されたERC20ラッパーのアドレス。

wrapERC20toERC223

javascript
function wrapERC20toERC223(address _ERC20token, uint256 _amount) public returns (bool)

ERC20トークンをERC223トークンへ変換する関数。 transferFromを使って送信者のERC20を引き出し、同量のERC223ラッパートークンを送付します。 オリジナルのトークンはConverterコントラクト内で保管され、将来のアンラップに備えます。 ラッパーが存在しない場合、自動的に作成されます。

引数

  • _ERC20token
    • 変換元のERC20トークンアドレス。
  • _amount
    • 変換するトークンの数量。

戻り値

  • bool
    • 成功時はtrue。

convertERC20

javascript
function convertERC20(address _token, uint256 _amount) public returns (bool)

トークンの種類を自動判定して、ラップ/アンラップを実行する関数。 指定トークンがラッパーである場合はunwrapERC20toERC223を実行、 オリジントークンの場合はwrapERC20toERC223を実行します。 DAppや取引所などが両標準に対応する時に、変換処理を簡略化できます。

引数

  • _token
    • トークンアドレス。
  • _amount
    • 変換する数量。

戻り値

  • bool
    • 成功時はtrue。

isWrapper

javascript
function isWrapper(address _token) public view returns (bool)

指定トークンがConverterによって作られたラッパーかどうかを確認する関数。 指定アドレスがConverter生成のラッパーであればtrueを返します。 標準の種類(ERC20 / ERC223)は判別しません。

引数

  • _token
    • トークンアドレス。

戻り値

  • bool
    • ラッパーの場合true。

tokenReceived

javascript
function tokenReceived(address _from, uint _value, bytes memory _data) public override returns (bytes4)

ERC223トークン受取時に自動的に呼び出されるハンドラー関数。 この関数はERC223のtransfer時に呼び出され、受け取ったトークンによって以下の処理を実行します。

  • Converter生成のERC223ラッパーの場合 元のERC20トークンを返送。
  • Converter未登録のERC223オリジンの場合 対応するERC20ラッパーを作成し、トークンを送信。

返り値として0x8943ec02を返します。

引数

  • _from
    • 送信元アドレス。
  • _value
    • トークン数量。
  • _data
    • 追加データ。

戻り値

  • bytes4
    • 固定値 0x8943ec02。

extractStuckERC20

javascript
function extractStuckERC20(address _token)

誤ってConverterに直接送信されたERC20トークンを救出する関数。 Converterは、正規の変換記録(convertERC20toERC223経由)を保持しています。 balanceOf(this)との差分により、誤送信トークンを特定して返却可能にします。

引数

  • _token
    • 対象トークンのアドレス。

トークン変換の手順

ERC20 → ERC223 の変換手順

  1. ユーザーがERC20トークンのapprove関数を呼び出し、Converterに指定数量の引き出しを許可します。
  2. approveトランザクションがブロックチェーンに承認されるまで待機します。
  3. その後、ConverterコントラクトのconvertERC20toERC223関数を呼び出し、変換を実行します。

ERC223 → ERC20 の変換手順

  1. ユーザーがERC223トークンのtransfer関数を利用し、Converterアドレス宛に送信します。
  2. Converterは受信時にtokenReceivedを自動実行し、ERC20トークンをユーザーに返します。

補足

ERC223オリジナルトークンのサポート

トークンコンバーターを設計するうえで、以下の2つの案が検討されました。

案 内容 想定される影響
案1 既存のERC20をERC223に変換する片方向のみを提供 すべての新規発行がERC20前提になりがちで、ERC223をオリジンとして選ぶ開発者が不利になります。
同一資産の2標準版を扱う外部コントラクト(例:1つのプールで両版を束ねたいAMM)も、ERC223オリジン+ERC20版のペアを正しく同一資産と認識できません。
案2 任意のオリジントークンに対して、ERC20版・ERC223版の両ラッパーを作成可能 ERC223をオリジンとして採用したプロジェクトも、コンバーター経由でERC20互換を保てます。DEXやプールが「同一資産の2標準版」を正しく同一視でき、相互運用性が向上します。

結論として、相互運用性と公平性を最大化するために「案2」を採用します。 これにより、ERC223をオリジンに選んだ開発者も、追加開発なしにERC20版を得られ、外部サービスは「同一資産の標準違い」を正しく1つの資産として扱いやすくなります。

ERC223ラッパーにおける approve / transferFrom サポート

本来、ERC223では受取側コントラクトにコールバック(tokenReceived)が届くため、単純なデポジットはtransferだけで安全に行えます。 そのため、ERC223においてapprove / transferFromは「機能重複」に見えます。 しかし、現実にはERC20前提で作られたマルチシグや各種コントラクトが多数稼働しており、「コールバック前提ではない受け取り」を想定した設計が広く存在します。 エコシステム互換を最優先し、コンバーターが発行するERC223ラッパーにもapprove / transferFromを実装します。 これにより、参照実装としてそのまま使いやすくなります。

一方で、transferFromには典型的な注意点があります。 approveで自分自身に許可した後、transferFrom(self, contract, X)のようにコールバック非対応のコントラクトへトークンを押し込めてしまうと、救出ロジックがない限り資産が取り出せなくなる恐れがあります。 approve / transferFromはウォレットの通常送金で直接使われる手段ではなく、本来は「コントラクトが引き出す」ためのAPIです。 結論として、ERC223では可能な限りtransferの利用を優先し、approve / transferFromはやむを得ない互換性目的に限ってください。

ERC223のイベント仕様を既存エコシステムに合わせる理由

純粋なERC223実装では、以下のようなイベントが発行されます。

javascript
// 純粋な ERC223 で一般的な Transfer イベント
event Transfer(address indexed _from, address indexed _to, uint256 _value, bytes _data);

このイベントはERC20のTransfer(address,address,uint256)とはシグネチャが異なるため、ブロックチェーンエクスプローラやウォレット、各種履歴ビューアが正しく認識できない場合があります。 イベント自体はコントラクトの安全性や状態遷移に直接影響する「必須ロジック」ではありません。 ERC7417では、まずは既存ツール群との互換を優先する方針を採用します。 これにより、監視・分析・会計などの実運用でつまずきにくくなります。

標準判定における standard() 採用の理由

ERC165は「関数シグネチャの有無」を検査できますが、ERC20とERC223は公開関数の集合が似通っており、表面上のインターフェースだけでは見分けが難しい問題があります。 最終的な挙動はtransferの内部ロジック(コントラクト宛の送金でコールバックするか否か)に依存するため、ERC165だけでは十分に厳密ではありません。

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

判別が難しい理由を示すサンプル

javascript
abstract contract Token {
    function name() external virtual returns (string memory);
    function symbol() external virtual returns (string memory);
    function decimals() external virtual returns (uint8);

    function transfer(address, uint256) external virtual returns (bool);
    function approve(address, uint256) external virtual returns (bool);
    function transferFrom(address, address, uint256) external virtual returns (bool);
}

この抽象インターフェースでは、ERC20とERC223を関数シグネチャだけで区別できません。 内部実装次第で振る舞いが変わるためです。

ERC20的な実装例は以下になります。

javascript
function transfer(address _to, uint256 _amount) external virtual returns (bool) {
    balances[msg.sender] -= _amount;
    balances[_to] += _amount;
}

ERC223的な実装例(コントラクト宛ならtokenReceivedを呼ぶ)は以下になります。

javascript
function transfer(address _to, uint256 _amount) external virtual returns (bool) {
    balances[msg.sender] -= _amount;
    balances[_to] += _amount;
    if (_to.isContract()) {
        IERC223Recipient(_to).tokenReceived(msg.sender, _amount, hex"000000");
    }
}

多くのトークンはERC165自体を実装していない場合もあります。 そこでコンバーターは、発行するERC223ラッパーにstandard() returns (uint32)を実装し、223を返す設計にします。 さらに、オリジン側のERC223も任意で同関数を実装して自称できるようにし、未実装であれば「ERC20とみなす」という運用ルールを採用します。 この方法は、ERC165だけに頼るよりも実態に即した高精度な標準判定になります。

コードに登場する関数の説明

以下は、上記サンプルコードに登場する関数を、用途が分かるように整理したものです。

name

javascript
function name() external virtual returns (string memory);

トークン名を取得する関数。 ユーザーに表示するフルネームを返します。 多くのウォレットやエクスプローラで表示に使われます。

戻り値

  • string memory
    • トークン名。

symbol

javascript
function symbol() external virtual returns (string memory);

トークンシンボルを取得する関数。 取引画面や残高表示で使われる短い識別子(例:USDT、DAI)を返します。

戻り値

  • string memory
    • トークンシンボル。

decimals

javascript
function decimals() external virtual returns (uint8);

小数点桁数を取得する関数。 ユーザー表示や金額換算で用いるスケールを返します。18が一般的です。

戻り値

  • uint8
    • 小数点桁数。

transfer

javascript
function transfer(address _to, uint256 _amount) external virtual returns (bool);

トークンを指定アドレスへ送る関数。 送金者の残高から指定量を差し引き、宛先へ加算します。 ERC223的な実装では、宛先がコントラクトであればtokenReceivedを呼び出して安全な受け取りを担保します。ERC20的な実装ではコールバックを行いません。

引数

  • _to
    • 送金先アドレス。
  • _amount
    • 送金量。

戻り値

  • bool
    • 成功時にtrueを返す実装が推奨です。

approve

javascript
function approve(address _spender, uint256 _amount) external virtual returns (bool);

第三者が送信者の代わりにトークンを引き出す許可量を設定する関数。 スパイラル承認問題(古い許可量の上書き競合)や誤設定リスクがあるため、適切な手順やpermit(EIP2612)等の利用が望ましいです。 ERC223では通常transferが安全ですが、互換性のために用意されます。

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

引数

  • _spender
    • 引き出し可能者。
  • _amount
    • 許可する数量。

戻り値

  • bool
    • 成功時にtrue。

transferFrom

javascript
function transferFrom(address _from, address _to, uint256 _amount) external virtual returns (bool);

事前承認にもとづいて第三者が送金を実行する関数。 approveで設定した許可枠内で、_fromの残高から_toへ送金します。 ERC223においては、コールバック非対応のコントラクトに資産を押し込める誤用リスクがあるため、原則transferの利用を優先します。

引数

  • _from
    • 送付元アドレス。
  • _to
    • 送付先アドレス。
  • _amount
    • 送金量。

戻り値

  • bool
    • 成功時にtrue。

互換性

ERC7417は、異なるトークン標準を相互に変換できるようにすることで、互換性に関する不安を取り除くことを目的としています。 ERC20とERC223を1:1で入れ替えられるため、既存のDAppやツールがどちらか一方にしか対応していなくても、コンバーター経由で実運用を続けられます。

また、このサービス自体は先行する同種の仕組みがなく、過去仕様との互換性問題を引きずりません。そのため、歴史的経緯による制約を受けずに設計されています。

参考実装

js

pragma solidity =0.8.19;

library Address {
    function isContract(address account) internal view returns (bool) {
        // This method relies on extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.

        uint256 size;
        // solhint-disable-next-line no-inline-assembly
        assembly { size := extcodesize(account) }
        return size > 0;
    }
}

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
}

interface IERC20Metadata is IERC20 {
    /// @return The name of the token
    function name() external view returns (string memory);

    /// @return The symbol of the token
    function symbol() external view returns (string memory);

    /// @return The number of decimal places the token has
    function decimals() external view returns (uint8);
}

abstract contract IERC223Recipient {
    function tokenReceived(address _from, uint _value, bytes memory _data) public virtual returns (bytes4)
    {
        return 0x8943ec02;
    }
}

abstract contract ERC165 {
    /*
     * bytes4(keccak256('supportsInterface(bytes4)')) == 0x01ffc9a7
     */
    bytes4 private constant _INTERFACE_ID_ERC165 = 0x01ffc9a7;
    mapping(bytes4 => bool) private _supportedInterfaces;

    constructor () {
        // Derived contracts need only register support for their own interfaces,
        // we register support for ERC165 itself here
        _registerInterface(_INTERFACE_ID_ERC165);
    }
    function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) {
        return _supportedInterfaces[interfaceId];
    }
    function _registerInterface(bytes4 interfaceId) internal virtual {
        require(interfaceId != 0xffffffff, "ERC165: invalid interface id");
        _supportedInterfaces[interfaceId] = true;
    }
}

abstract contract IERC223 {
    function name()        public view virtual returns (string memory);
    function symbol()      public view virtual returns (string memory);
    function decimals()    public view virtual returns (uint8);
    function totalSupply() public view virtual returns (uint256);
    function balanceOf(address who) public virtual view returns (uint);
    function transfer(address to, uint value) public virtual returns (bool success);
    function transfer(address to, uint value, bytes calldata data) public payable virtual returns (bool success);
    event Transfer(address indexed from, address indexed to, uint value, bytes data);
}

interface standardERC20
{
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

/**
 * @dev Interface of the ERC20 standard.
 */
interface IERC223WrapperToken {
    function name()     external view returns (string memory);
    function symbol()   external view returns (string memory);
    function decimals() external view returns (uint8);
    function standard() external view returns (string memory);
    function origin()   external  view returns (address);

    function totalSupply()                                            external view returns (uint256);
    function balanceOf(address account)                               external view returns (uint256);
    function transfer(address to, uint256 value)                      external payable returns (bool);
    function transfer(address to, uint256 value, bytes calldata data) external payable returns (bool);
    function allowance(address owner, address spender)                external view returns (uint256);
    function approve(address spender, uint256 value)                  external returns (bool);
    function transferFrom(address from, address to, uint256 value)    external returns (bool);

    function mint(address _recipient, uint256 _quantity) external;
    function burn(address _recipient, uint256 _quantity) external;
}

interface IERC20WrapperToken {
    function name()     external view returns (string memory);
    function symbol()   external view returns (string memory);
    function decimals() external view returns (uint8);
    function standard() external view returns (string memory);
    function origin()   external  view returns (address);

    function totalSupply()                                         external view returns (uint256);
    function balanceOf(address account)                            external view returns (uint256);
    function transfer(address to, uint256 value)                   external returns (bool);
    function allowance(address owner, address spender)             external view returns (uint256);
    function approve(address spender, uint256 value)               external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);

    function mint(address _recipient, uint256 _quantity) external;
    function burn(address _recipient, uint256 _quantity) external;
}

contract ERC20Rescue
{
    // ERC20 tokens can get stuck on a contracts balance due to lack of error handling.
    //
    // The author of the ERC7417 can extract ERC20 tokens if they are mistakenly sent
    // to the wrapper-contracts balance.
    // Contact dexaran@ethereumclassic.org
    address public extractor = 0x01000B5fE61411C466b70631d7fF070187179Bbf;
    
    function safeTransfer(address token, address to, uint value) internal {
        // bytes4(keccak256(bytes('transfer(address,uint256)')));
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FAILED');
    }

    function rescueERC20(address _token, uint256 _amount) external 
    {
        safeTransfer(_token, extractor, _amount);
    }
}

contract ERC223WrapperToken is IERC223, ERC165, ERC20Rescue
{
    address public creator = msg.sender;
    address private wrapper_for;

    mapping(address account => mapping(address spender => uint256)) private allowances;

    event Transfer(address indexed from, address indexed to, uint256 amount);
    event TransferData(bytes data);
    event Approval(address indexed owner, address indexed spender, uint256 amount);

    function set(address _wrapper_for) external
    {
        require(msg.sender == creator);
        wrapper_for = _wrapper_for;
    }

    uint256 private _totalSupply;

    mapping(address => uint256) private balances; // List of user balances.

    function totalSupply() public view override returns (uint256)             { return _totalSupply; }
    function balanceOf(address _owner) public view override returns (uint256) { return balances[_owner]; }


    /**
     * @dev The ERC165 introspection function.
     */
    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return
            interfaceId == type(IERC20).interfaceId ||
            interfaceId == type(standardERC20).interfaceId ||
            interfaceId == type(IERC223WrapperToken).interfaceId ||
            interfaceId == type(IERC223).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    /**
     * @dev Standard ERC223 transfer function.
     *      Calls _to if it is a contract. Does not transfer tokens to contracts
     *      which do not explicitly declare the tokenReceived function.
     * @param _to    - transfer recipient. Can be contract or EOA.
     * @param _value - the quantity of tokens to transfer.
     * @param _data  - metadata to send alongside the transaction. Can be used to encode subsequent calls in the recipient.
     */
    function transfer(address _to, uint _value, bytes calldata _data) public payable override returns (bool success)
    {
        balances[msg.sender] = balances[msg.sender] - _value;
        balances[_to] = balances[_to] + _value;
        if (msg.value > 0) 
        {
            (bool sent, bytes memory data) = _to.call{value: msg.value}("");
            require(sent);
        }
        if(Address.isContract(_to)) {
            IERC223Recipient(_to).tokenReceived(msg.sender, _value, _data);
        }
        emit Transfer(msg.sender, _to, _value, _data);
        emit Transfer(msg.sender, _to, _value); // Old ERC20 compatible event. Added for backwards compatibility reasons.

        return true;
    }

    /**
     * @dev Standard ERC223 transfer function without _data parameter. It is supported for 
     *      backwards compatibility with ERC20 services.
     *      Calls _to if it is a contract. Does not transfer tokens to contracts
     *      which do not explicitly declare the tokenReceived function.
     * @param _to    - transfer recipient. Can be contract or EOA.
     * @param _value - the quantity of tokens to transfer.
     */
    function transfer(address _to, uint _value) public override returns (bool success)
    {
        bytes memory _empty = hex"00000000";
        balances[msg.sender] = balances[msg.sender] - _value;
        balances[_to] = balances[_to] + _value;
        if(Address.isContract(_to)) {
            IERC223Recipient(_to).tokenReceived(msg.sender, _value, _empty);
        }
        emit Transfer(msg.sender, _to, _value, _empty);
        emit Transfer(msg.sender, _to, _value); // Old ERC20 compatible event. Added for backwards compatibility reasons.

        return true;
    }

    function name() public view override returns (string memory)   { return IERC20Metadata(wrapper_for).name(); }
    function symbol() public view override returns (string memory) { return string.concat(IERC20Metadata(wrapper_for).symbol(), "223"); }
    function decimals() public view override returns (uint8)       { return IERC20Metadata(wrapper_for).decimals(); }
    function standard() public pure returns (uint32)               { return 223; }
    function origin() public view returns (address)                { return wrapper_for; }


    /**
     * @dev Minting function which will only be called by the converter contract.
     * @param _recipient - the address which will receive tokens.
     * @param _quantity  - the number of tokens to create.
     */
    function mint(address _recipient, uint256 _quantity) external
    {
        require(msg.sender == creator, "Wrapper Token: Only the creator contract can mint wrapper tokens.");
        balances[_recipient] += _quantity;
        _totalSupply += _quantity;
    }

    /**
     * @dev Burning function which will only be called by the converter contract.
     * @param _quantity  - the number of tokens to destroy. TokenConverter can only destroy tokens on it's own address.
     *                     Only the token converter is allowed to burn wrapper-tokens.
     */
    function burn(uint256 _quantity) external
    {
        require(msg.sender == creator, "Wrapper Token: Only the creator contract can destroy wrapper tokens.");
        balances[msg.sender] -= _quantity;
        _totalSupply -= _quantity;
    }

    // ERC20 functions for backwards compatibility.

    function allowance(address owner, address spender) public view virtual returns (uint256) {
        return allowances[owner][spender];
    }

    function approve(address _spender, uint _value) public returns (bool) {
        require(_spender != address(0), "ERC223: Spender error.");

        allowances[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);

        return true;
    }

    function transferFrom(address _from, address _to, uint _value) public returns (bool) {

        require(allowances[_from][msg.sender] >= _value, "ERC223: Insufficient allowance.");

        balances[_from] -= _value;
        allowances[_from][msg.sender] -= _value;
        balances[_to] += _value;

        emit Transfer(_from, _to, _value);

        return true;
    }
}

contract ERC20WrapperToken is IERC20, ERC165, ERC20Rescue
{
    address public creator = msg.sender;
    address public wrapper_for;

    mapping(address account => mapping(address spender => uint256)) private allowances;

    function set(address _wrapper_for) external
    {
        require(msg.sender == creator);
        wrapper_for = _wrapper_for;
    }

    uint256 private _totalSupply;
    mapping(address => uint256) private balances; // List of user balances.


    function balanceOf(address _owner) public view override returns (uint256) { return balances[_owner]; }

    function name()        public view  returns (string memory) { return IERC20Metadata(wrapper_for).name(); }
    function symbol()      public view  returns (string memory) { return string.concat(IERC223(wrapper_for).symbol(), "20"); }
    function decimals()    public view  returns (uint8)         { return IERC20Metadata(wrapper_for).decimals(); }
    function totalSupply() public view override returns (uint256)       { return _totalSupply; }
    function origin()      public view returns (address)                { return wrapper_for; }

    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return
            interfaceId == type(IERC20).interfaceId ||
            interfaceId == type(IERC20WrapperToken).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    function transfer(address _to, uint _value) public override returns (bool success)
    {
        balances[msg.sender] = balances[msg.sender] - _value;
        balances[_to] = balances[_to] + _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function mint(address _recipient, uint256 _quantity) external
    {
        require(msg.sender == creator, "Wrapper Token: Only the creator contract can mint wrapper tokens.");
        balances[_recipient] += _quantity;
        _totalSupply += _quantity;
    }

    function burn(address _from, uint256 _quantity) external
    {
        require(msg.sender == creator, "Wrapper Token: Only the creator contract can destroy wrapper tokens.");
        balances[_from] -= _quantity;
        _totalSupply    -= _quantity;
    }

    function allowance(address owner, address spender) public view virtual returns (uint256) {
        return allowances[owner][spender];
    }

    function approve(address _spender, uint _value) public returns (bool) {

        // Safety checks.

        require(_spender != address(0), "ERC20: Spender error.");

        allowances[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);

        return true;
    }

    function transferFrom(address _from, address _to, uint _value) public returns (bool) {

        require(allowances[_from][msg.sender] >= _value, "ERC20: Insufficient allowance.");

        balances[_from] -= _value;
        allowances[_from][msg.sender] -= _value;
        balances[_to] += _value;

        emit Transfer(_from, _to, _value);

        return true;
    }
}

contract TokenStandardConverter is IERC223Recipient
{
    event ERC223WrapperCreated(address indexed _token, address indexed _ERC223Wrapper);
    event ERC20WrapperCreated(address indexed _token, address indexed _ERC20Wrapper);

    mapping (address => ERC223WrapperToken) public erc223Wrappers; // A list of token wrappers. First one is ERC20 origin, second one is ERC223 version.
    mapping (address => ERC20WrapperToken)  public erc20Wrappers;

    mapping (address => address)            public erc223Origins;
    mapping (address => address)            public erc20Origins;
    mapping (address => uint256)            public erc20Supply; // Token => how much was deposited.

    function getERC20WrapperFor(address _token) public view returns (address)
    {
        return address(erc20Wrappers[_token]);
    }

    function getERC223WrapperFor(address _token) public view returns (address)
    {
        return address(erc223Wrappers[_token]);
    }

    function getERC20OriginFor(address _token) public view returns (address)
    {
        return (address(erc20Origins[_token]));
    }

    function getERC223OriginFor(address _token) public view returns (address)
    {
        return (address(erc223Origins[_token]));
    }

    function predictWrapperAddress(address _token,
                                   bool    _isERC20 // Is the provided _token a ERC20 or not?
                                                    // If it is set as ERC20 then we will predict the address of a 
                                                    // ERC223 wrapper for that token.
                                                    // Otherwise we will predict ERC20 wrapper address.
                                  ) view external returns (address)
    {
        bytes memory _bytecode;
        if(_isERC20)
        {
            _bytecode = type(ERC223WrapperToken).creationCode;
        }
        else
        {
            _bytecode = type(ERC20WrapperToken).creationCode;
        }

        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff), address(this), keccak256(abi.encode(_token)), keccak256(_bytecode)
          )
        );

        return address(uint160(uint(hash)));
    }

    function tokenReceived(address _from, uint _value, bytes memory /* _data */) public override returns (bytes4)
    {
        require(erc223Origins[msg.sender] == address(0), "Error: creating wrapper for a wrapper token.");
        // There are two possible cases:
        // 1. A user deposited ERC223 origin token to convert it to ERC20 wrapper
        // 2. A user deposited ERC223 wrapper token to unwrap it to ERC20 origin.

        if(erc20Origins[msg.sender] != address(0))
        {
            // Origin for deposited token exists.
            // Unwrap ERC-223 wrapper.

            erc20Supply[erc20Origins[msg.sender]] -= _value;
            safeTransfer(erc20Origins[msg.sender], _from, _value);

            ERC223WrapperToken(msg.sender).burn(_value);

            return this.tokenReceived.selector;
        }
        // Otherwise origin for the sender token doesn't exist
        // There are two possible cases:
        // 1. ERC20 wrapper for the deposited token exists
        // 2. ERC20 wrapper for the deposited token doesn't exist and must be created.
        else if(address(erc20Wrappers[msg.sender]) == address(0))
        {
            // Create ERC-20 wrapper if it doesn't exist.
            createERC20Wrapper(msg.sender);
        }

        // Mint ERC-20 wrapper tokens for the deposited ERC-223 token
        // if the ERC-20 wrapper didn't exist then it was just created in the above statement.
        erc20Wrappers[msg.sender].mint(_from, _value);
        return this.tokenReceived.selector;
    }

    function createERC223Wrapper(address _token) public returns (address)
    {
        require(address(erc223Wrappers[_token]) == address(0), "ERROR: Wrapper exists");
        require(!isWrapper(_token), "Error: Creating wrapper for a wrapper token");
        
        ERC223WrapperToken _newERC223Wrapper     = new ERC223WrapperToken{salt: keccak256(abi.encode(_token))}();
        _newERC223Wrapper.set(_token);
        erc223Wrappers[_token]                   = _newERC223Wrapper;
        erc20Origins[address(_newERC223Wrapper)] = _token;

        emit ERC223WrapperCreated(_token, address(_newERC223Wrapper));
        return address(_newERC223Wrapper);
    }

    function createERC20Wrapper(address _token) public returns (address)
    {
        require(address(erc20Wrappers[_token]) == address(0), "ERROR: Wrapper already exists.");
        require(!isWrapper(_token), "Error: Creating wrapper for a wrapper token");

        ERC20WrapperToken _newERC20Wrapper       = new ERC20WrapperToken{salt: keccak256(abi.encode(_token))}();
        _newERC20Wrapper.set(_token);
        erc20Wrappers[_token]                    = _newERC20Wrapper;
        erc223Origins[address(_newERC20Wrapper)] = _token;

        emit ERC20WrapperCreated(_token, address(_newERC20Wrapper));
        return address(_newERC20Wrapper);
    }

    function wrapERC20toERC223(address _ERC20token, uint256 _amount) public returns (bool)
    {
        // If there is no active wrapper for a token that user wants to wrap
        // then create it.
        if(address(erc223Wrappers[_ERC20token]) == address(0))
        {
            createERC223Wrapper(_ERC20token);
        }
        uint256 _converterBalance = IERC20(_ERC20token).balanceOf(address(this)); // Safety variable.
        safeTransferFrom(_ERC20token, msg.sender, address(this), _amount);

        _amount = IERC20(_ERC20token).balanceOf(address(this)) - _converterBalance;
        erc20Supply[_ERC20token] += _amount;

        erc223Wrappers[_ERC20token].mint(msg.sender, _amount);

        return true;
    }

    function unwrapERC20toERC223(address _ERC20token, uint256 _amount) public returns (bool)
    {
        require(IERC20(_ERC20token).balanceOf(msg.sender) >= _amount, "Error: Insufficient balance.");
        require(erc223Origins[_ERC20token] != address(0), "Error: provided token is not a ERC-20 wrapper.");

        ERC20WrapperToken(_ERC20token).burn(msg.sender, _amount);

        safeTransfer(erc223Origins[_ERC20token], msg.sender, _amount);

        return true;
    }

    function convertERC20(address _token, uint256 _amount) public returns (bool)
    {
        if(isWrapper(_token)) return unwrapERC20toERC223(_token, _amount);
        else return wrapERC20toERC223(_token, _amount);
    }

    function isWrapper(address _token) public view returns (bool)
    {
        return erc20Origins[_token] != address(0) || erc223Origins[_token] != address(0);
    }

    function extractStuckERC20(address _token) external 
    {
        require(msg.sender == address(0x01000B5fE61411C466b70631d7fF070187179Bbf));

        safeTransfer(_token, address(0x01000B5fE61411C466b70631d7fF070187179Bbf), IERC20(_token).balanceOf(address(this)) - erc20Supply[_token]);
    }
    
    function safeTransfer(address token, address to, uint value) internal {
        // bytes4(keccak256(bytes('transfer(address,uint256)')));
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FAILED');
    }

    function safeTransferFrom(address token, address from, address to, uint value) internal {
        // bytes4(keccak256(bytes('transferFrom(address,address,uint256)')));
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FROM_FAILED');
    }
}

セキュリティ

標準ごとにコンバーターを分ける理由

理論上は「任意の標準 ⇄ 任意の標準」を1つの仕組みで扱うこともできますが、標準ごとに内在するロジックが異なる場合があり、変換の前提や安全条件がずれてしまいます。 そこでERC7417は対象をERC20とERC223に限定し、それぞれの特性に合った変換手法に集中します。 これにより、想定外の相互作用を避けて検証範囲を明確にできます。

ERC20のtransferによる誤入金とrescueERC20

ERC20は受け取り側のコントラクトがトークン受領を検知しないままtransferで直接入金できてしまいます。 受け取り側が取り出し口(救出ロジック)を持たない場合、資産が取り出せず永久にロックされる恐れがあります。 コンバーターが発行するERC20ラッパーもERC20互換である以上、この問題に晒されます。 そのため、コンバーターには誤入金を救出するためのrescueERC20(仕様文ではextractStuckERC20に相当)を用意し、正規の変換記録との差分から誤入金分を抽出できる設計とします。

approveとtransferFrom依存のリスクと運用

コンバーターはERC20での入金にapprove → transferFromの二段階を使います。 これは2つの独立したトランザクションであるため、transferFromに依存する前に、承認が成功していることを確実に確認する必要があります。 UIやバックエンドは、承認のチェーン確定と許可量の検証を行い、競合や未反映による失敗を避けるべきです。

無制限承認(Unlimited Approval)の非推奨

多くのUIは利便性を理由に「無制限承認」を促しますが、引き出し権限が広すぎると、バグやロール侵害、鍵漏えい時の被害が大きくなります。 ERC7417では無制限承認は推奨しません。 必要額に限定し、利用後は承認を取り消すか最小化する運用が望ましいです。

標準の内省が困難であることとハイブリッド実装

現在、コントラクトがどの標準に実質準拠しているかを100%保証する信頼できる内省手段はありません。 例えば、見かけ上はERC20(approve / transferFromを備える)でありながら、transferの内部はERC223的にコールバック呼び出しを行う「ハイブリッド」実装も構成可能です。 このようなトークンに対しても、コンバーターはERC223ラッパーを作れてしまいます。 ワークフロー自体に致命的な問題は生じませんが、「コンバーターにERC223ラッパーがある=オリジンが完全にERC20互換」という意味にはなりません。 実用上はstandard()などの補助的内省や、実送金テストなどの検証手順を組み合わせて扱う必要があります。

ラッパー作成時の標準検証を行わない前提

信頼できる自動判別法がないため、コンバーターは与えられたアドレスが本当にERC20/ERC223かを検証しません。 その結果、同じオリジンに対してERC20ラッパーとERC223ラッパーの両方を作ることができます。 前提として、1つのラッパートークンには必ず1つのオリジンが対応しますが、「ラッパーをさらにラップする」ことはできません。 設計上、各オリジンに対し最大2種類(ERC20用、ERC223用)のラッパーが存在し得る点を、上流・下流のコントラクト双方で前提にしてください。

供給が減衰・バーンされるトークンの非適合

コンバーターは「変換のために預かったオリジントークンの残高が自律的に減らない」ことを前提に設計されています。 もしオリジンが時間経過で供給が減衰したり、Burnや徴税等で残高が減少する仕様だと、コンバーター保有分が不足し、1:1の変換保証が破綻します。 この場合、そのトークンに対してコンバーターでの代替版(ラッパー)を提供すべきではありません。

セキュリティ論点の整理

論点 説明 影響 推奨運用
標準別コンバーター ERC20とERC223に対象を限定 想定外挙動を低減 対象標準ごとに変換ロジックを分離
ERC20誤入金 transferで受領検知なく入金され得る 資産ロックの恐れ rescueERC20相当の救出機能で差分回収
二段階承認 approveとtransferFromは別Tx タイミング競合 承認確定・許可量検証を徹底
無制限承認 便益はあるがリスク増大 被害拡大 金額限定・使用後取り消し
標準内省困難 見かけと実挙動が乖離し得る 誤判定 standard()や実送金テストを併用
ラッパー検証なし 同一オリジンに2ラッパー可 設計前提の相違 「ラッパー≠ラッパーの対象」ルールを厳守
減衰供給 残高が自律的に減るトークン 1:1担保崩壊 その種のトークンは対象外にする

引用

Dexaran (@Dexaran) dexaran@ethereumclassic.org, "ERC-7417: Token Converter [DRAFT]," Ethereum Improvement Proposals, no. 7417, July 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7417.

最後に

今回は「Ethereum上で異なるトークン規格(ERC20とERC223)を相互に変換できる仕組みを導入し、互換性と利便性を高める仕組みを提案しているERC7417」についてまとめてきました! いかがだったでしょうか?

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

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