はじめに

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

今回は、approveを使用せずに、トークンをdepositすることで同じapproveする仕組みを提案しているERC4546についてまとめていきます!

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

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

概要

Wrapped Deposit Contractは、ユーザーに代わって資産(Ether、ERC20トークン、ERC721トークン)を預け入れるコントラクトです。

このコントラクトを利用すると、ユーザーは一度だけ資産の送金上限を承認(approve)するだけで済み、その後は異なる複数のアプリケーションに対して自由に資産を預け入れることができるようになります。

動機

現在のdAppにおける資産預け入れフローは、コストが高く安全性にも問題があります。

ERC20トークンを預け入れる時に、ユーザーは以下の方法を取る必要があります。

  • 毎回、送金する金額に対して「承認トランザクション(approve)」を送信する。
  • setApprovalForAllを実行して、保有トークンを事前に全てapproveする。

前者の方法は、手間がかかりガス代も毎回発生します。 後者の方法は、資産流出リスクが高まるため安全性に問題があります。

さらに、approveの仕組みを非技術者や仮想通貨初心者に説明するのは非常に難しく、ユーザー体験を損なう原因となっています。 このような煩雑な作業が、全てのdAppで個別に発生している点も課題となっています。

Wrapped Deposit Contractは、これらの問題を解決するために設計されています。

仕様

デプロイと要件

Wrapped Deposit Contractは、特定のアドレス(例:0x1111119a9e30bceadf9f939390293ffacef93fe9)にデプロイされるべきです。

このコントラクトは以下の条件を満たす必要があります。

  • アップグレード不可であり、デプロイ後に状態変数を変更できない。
  • 以下のpublic関数を実装する必要がある。

関数一覧

  • depositERC20(address to, address token, uint amount)
  • depositERC721(address to, address token, uint tokenId)
  • safeDepositERC721(address to, address token, uint tokenId, bytes memory data)
  • safeDepositERC1155(address to, address token, uint tokenId, uint value, bytes calldata data)
  • batchDepositERC1155(address to, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data)
  • depositEther(address to)(payable)

各関数は、以下の動作要件を満たす必要があります。

  • toアドレスがコードサイズ0(コントラクトではない)であればrevertする。
  • toアドレスに対して、受け入れ可能かどうかを確認する関数呼び出しを行い、戻り値がtrueでなければrevertする。
  • 資産のtransferが失敗した場合もrevertする。

受け入れ側コントラクトのインターフェース

資産の受け入れをサポートするコントラクトは、以下のインターフェースを実装することが推奨されています。

ERC20Receiver.sol
interface ERC20Receiver {
  function acceptERC20Deposit(address depositor, address token, uint amount) external returns (bool);
}
ERC721Receiver.sol
interface ERC721Receiver {
  function acceptERC721Deposit(address depositor, address token, uint tokenId) external returns (bool);
}
ERC1155Receiver.sol
interface ERC1155Receiver {
  function acceptERC1155Deposit(address depositor, address token, uint tokenId, uint value, bytes calldata data) external returns (bool);
  function acceptERC1155BatchDeposit(address depositor, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data) external returns (bool);
}
EtherReceiver.sol
interface EtherReceiver {
  function acceptEtherDeposit(address depositor, uint amount) external returns (bool);
}

これらのインターフェースのうち、必要なものを任意に実装すればよいですが未実装の資産タイプについては預け入れが行われない設計です。

関数

depositERC20

js
function depositERC20(address to, address token, uint amount) external;

ERC20トークンを指定アドレスに預け入れる関数。 ユーザーが預け入れたいERC20トークンとその数量を指定し、指定先アドレスが受け入れ可能か確認したうえでtransfer処理を行います。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。
  • token
    • 預け入れるERC20トークンのコントラクトアドレス。
  • amount
    • 預け入れるトークンの数量。

depositERC721

js
function depositERC721(address to, address token, uint tokenId) external;

ERC721トークンを指定アドレスに預け入れる関数。 ユーザーが預け入れたいERC721トークン(NFT)とそのトークンIDを指定し、指定先アドレスが受け入れ可能か確認してからトークンをtransferします。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。
  • token
    • 預け入れるERC721トークンのコントラクトアドレス。
  • tokenId
    • 預け入れるNFTのトークンID。

safeDepositERC721

js
function safeDepositERC721(address to, address token, uint tokenId, bytes memory data) external;

追加データを付与してERC721トークンを安全に預け入れる関数。 depositERC721と基本的に同じですが、追加情報(data)を渡せる点が異なります。 これにより、受け入れ側で付加的な情報を活用できます。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。
  • token
    • 預け入れるERC721トークンのコントラクトアドレス。
  • tokenId
    • 預け入れるNFTのトークンID。
  • data
    • 受け入れ先に渡す追加データ。

safeDepositERC1155

js
function safeDepositERC1155(address to, address token, uint tokenId, uint value, bytes calldata data) external;

ERC1155トークンを1種類安全に預け入れる関数。 単一のERC1155トークン(マルチトークン規格)を、追加データとともに預け入れます。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。
  • token
    • 預け入れるERC1155トークンのコントラクトアドレス。
  • tokenId
    • 預け入れるトークンのID。
  • value
    • 預け入れるトークンの数量。
  • data
    • 受け入れ先に渡す追加データ。

batchDepositERC1155

js
function batchDepositERC1155(address to, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data) external;

複数種類のERC1155トークンを一括で預け入れる関数。 複数のトークンIDとその数量を指定してまとめて預け入れます。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。
  • token
    • 預け入れるERC1155トークンのコントラクトアドレス。
  • tokenIds
    • 預け入れる複数トークンのIDリスト。
  • values
    • 各トークンIDに対応する数量リスト。
  • data
    • 受け入れ先に渡す追加データ。

depositEther

js
function depositEther(address to) external payable;

ネイティブトークンを預け入れる関数。 ユーザーがEtherを直接コントラクトに送信し、指定したアドレスに対して預け入れます。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • to
    • 資産の受け入れ先アドレス。

acceptERC20Deposit

js
function acceptERC20Deposit(address depositor, address token, uint amount) external returns (bool);

ERC20トークンの預け入れを受け付けるかを返す関数。 送信者、トークンアドレス、数量を受け取り、受け入れ可能であればtrueを返します。 受け入れ不可またはtransfer失敗の場合はrevertします。

引数

  • depositor
    • トークンを預け入れようとしている送信者アドレス。
  • token
    • 預け入れ対象のERC20トークンアドレス。
  • amount
    • 預け入れ希望のトークン数量。

戻り値

  • bool
    • 預け入れを許可するならtrue、拒否するならfalse。

acceptERC721Deposit

js
function acceptERC721Deposit(address depositor, address token, uint tokenId) external returns (bool);

ERC721トークンの預け入れを受け付けるかを返す関数。 送信者、トークンアドレス、トークンIDを受け取り、受け入れ可能であればtrueを返します。 受け入れ拒否またはエラー時にはfalseを返します。

引数

  • depositor
    • トークンを預け入れようとしている送信者アドレス。
  • token
    • 預け入れ対象のERC721トークンアドレス。
  • tokenId
    • 預け入れ希望のNFTのトークンID。

戻り値

  • bool
    • 預け入れを許可するならtrue、拒否するならfalse。

acceptERC1155Deposit

js
function acceptERC1155Deposit(address depositor, address token, uint tokenId, uint value, bytes calldata data) external returns (bool);

単一のERC1155トークンの預け入れを受け付けるかを返す関数。 送信者、トークンアドレス、トークンID、数量、追加データを受け取り、受け入れ可能であればtrueを返します。 受け入れ拒否またはエラー時にはfalseを返します。

引数

  • depositor
    • トークンを預け入れようとしている送信者アドレス。
  • token
    • 預け入れ対象のERC1155トークンアドレス。
  • tokenId
    • 預け入れ希望のトークンID。
  • value
    • 預け入れ希望の数量。
  • data
    • 受け入れ先に渡す追加データ。

戻り値

  • bool
    • 預け入れを許可するならtrue、拒否するならfalse。

acceptERC1155BatchDeposit

js
function acceptERC1155BatchDeposit(address depositor, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data) external returns (bool);

複数種類のERC1155トークンの一括預け入れを受け付けるかを返す関数。 送信者、トークンアドレス、複数のトークンIDと数量、追加データを受け取り、受け入れ可能であればtrueを返します。 受け入れ拒否またはエラー時にはfalseを返します。

引数

  • depositor
    • トークンを預け入れようとしている送信者アドレス。
  • token
    • 預け入れ対象のERC1155トークンアドレス。
  • tokenIds
    • 預け入れ希望のトークンIDリスト。
  • values
    • 各トークンIDに対応する数量リスト。
  • data
    • 受け入れ先に渡す追加データ。

戻り値

  • bool
    • 預け入れを許可するならtrue、拒否するならfalse。

acceptEtherDeposit

js
function acceptEtherDeposit(address depositor, uint amount) external returns (bool);

ネイティブトークンの預け入れを受け付けるかを返す関数。 送信者と金額を受け取り、Etherの受け入れが可能であればtrueを返します。 受け入れ拒否またはエラー時にはfalseを返します。

引数

  • depositor
    • ネイティブトークンを預け入れようとしている送信者アドレス。
  • amount
    • 預け入れ希望のEther数量(Wei単位)。

戻り値

  • bool
    • 預け入れを許可するならtrue、拒否するならfalse。

補足

Wrapped Deposit Contractによって、全てのトークンのtransfer処理を単一のコントラクトにまとめることができます。 これにより、ユーザーはトークンごとに一度だけ承認(approve)を行えば、それ以降は複数の異なるコントラクトへ自由に預け入れができるようになります。

この仕組みのメリットは以下です。

  • ユーザーは、各受け取り側コントラクトに対してトークンの送金権限(approve)を与える必要がなくなり、信頼リスクを低減できる。
  • 受け取り側コントラクトは、自らトークンの受け取りやtransferロジックを実装する必要がなくなるため、開発の複雑さを大幅に削減できる。

また、ユーザー体験も大きく向上します。 例えば「このdAppを有効化すれば、他のアプリでもこのトークンが使えるようになります」といった、シンプルなメッセージで案内できるようになります。 これにより、仮想通貨初心者ユーザーへの説明が簡単になり、トークン利用への心理的ハードルを下げる効果が期待できます。

互換性

ERC4546は互換性の問題がありません。 Wrapped Deposit Contractを利用した新しい預け入れフローを使うためには、各受け取り側コントラクトが、専用の「accept系関数」(例:acceptERC20Depositなど)を実装する必要があります。

既存のコントラクトがこの仕様に対応するためには、以下のような対応が考えられます。

  • アップグレード可能なコントラクトであれば、後から新たにaccept系関数を実装してWrapped Deposit Contractに対応できる。
  • 既存ユーザーのために、従来の直接承認(approveしてから直接送金する方法)も残しておき、新しいユーザーにはWrapped Deposit Contractの利用を促す運用ができる。

つまり、アップグレード対応可能なコントラクトであれば、両方の仕組みを共存させることで既存ユーザーにも配慮しつつ新規ユーザーには新しい利便性を提供することが可能です。

参考実装

js
pragma solidity ^0.7.0;

interface ERC20Receiver {
  function acceptERC20Deposit(address depositor, address token, uint amount) external returns (bool);
}

interface ERC721Receiver {
  function acceptERC721Deposit(address depositor, address token, uint tokenId) external returns (bool);
}

interface ERC1155Receiver {
  function acceptERC1155Deposit(address depositor, address token, uint tokenId, uint value, bytes calldata data) external returns (bool);
  function acceptERC1155BatchDeposit(address depositor, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data) external returns (bool);
}

interface EtherReceiver {
  function acceptEtherDeposit(address depositor, uint amount) external returns (bool);
}

interface IERC20 {
  function transferFrom(address sender, address recipient, uint amount) external returns (bool);
}

interface IERC721 {
  function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
  function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory data) external payable;
}

interface IERC1155 {
  function safeTransferFrom(address _from, address _to, uint _id, uint _value, bytes calldata _data) external;
  function safeBatchTransferFrom(address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external;
}

contract WrappedDeposit {
  function depositERC20(address to, address token, uint amount) public {
    _assertContract(to);
    require(ERC20Receiver(to).acceptERC20Deposit(msg.sender, token, amount));
    bytes memory data = abi.encodeWithSelector(
      IERC20(token).transferFrom.selector,
      msg.sender,
      to,
      amount
    );
    (bool success, bytes memory returndata) = token.call(data);
    require(success);
    // backward compat for tokens incorrectly implementing the transfer function
    if (returndata.length > 0) {
      require(abi.decode(returndata, (bool)), "ERC20 operation did not succeed");
    }
  }

  function depositERC721(address to, address token, uint tokenId) public {
    _assertContract(to);
    require(ERC721Receiver(to).acceptERC721Deposit(msg.sender, token, tokenId));
    IERC721(token).transferFrom(msg.sender, to, tokenId);
  }

  function safeDepositERC721(address to, address token, uint tokenId, bytes memory data) public {
    _assertContract(to);
    require(ERC721Receiver(to).acceptERC721Deposit(msg.sender, token, tokenId));
    IERC721(token).safeTransferFrom(msg.sender, to, tokenId, data);
  }

  function safeDepositERC1155(address to, address token, uint tokenId, uint value, bytes calldata data) public {
    _assertContract(to);
    require(ERC1155Receiver(to).acceptERC1155Deposit(msg.sender, to, tokenId, value, data));
    IERC1155(token).safeTransferFrom(msg.sender, to, tokenId, value, data);
  }

  function batchDepositERC1155(address to, address token, uint[] calldata tokenIds, uint[] calldata values, bytes calldata data) public {
    _assertContract(to);
    require(ERC1155Receiver(to).acceptERC1155BatchDeposit(msg.sender, to, tokenIds, values, data));
    IERC1155(token).safeBatchTransferFrom(msg.sender, to, tokenIds, values, data);
  }

  function depositEther(address to) public payable {
    _assertContract(to);
    require(EtherReceiver(to).acceptEtherDeposit(msg.sender, msg.value));
    (bool success, ) = to.call{value: msg.value}('');
    require(success, "nonpayable");
  }

  function _assertContract(address c) private view {
    uint size;
    assembly {
      size := extcodesize(c)
    }
    require(size > 0, "noncontract");
  }
}

セキュリティ

コントラクトのシンプルさ

Wrapped Deposit Contractの実装は、できる限り小さく簡潔にするべきです。
理由は、コードが小さいほどバグのリスクを減らすことができるためです。

具体的には、コントラクト全体が数分で読んで理解できる程度のサイズに収められるべきだとされています。
エンジニアが短時間で理解できる設計とすることで、コードレビューやセキュリティ監査の質を高め、結果として安全性を向上させることができます。

受け取り側コントラクトの検証義務

Wrapped Deposit Contractから資産を受け取るコントラクトは、必ず次の検証を行わなければなりません。

  • 関数が呼び出された時のmsg.senderがWrapped Deposit Contractのアドレスと一致しているかを確認する。

この検証を行わないと、悪意のある第三者が直接関数を呼び出し、偽の預け入れを実行される可能性があります。

そのため、必ずmsg.senderのチェックを実装し、正規のWrapped Deposit Contractからのみ資産を受け入れる設計とすることが必要です。

引用

Justice Hudson (@jchancehud), "ERC-4546: Wrapped Deposits [DRAFT]," Ethereum Improvement Proposals, no. 4546, December 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-4546.

最後に

今回は「approveを使用せずに、トークンをdepositすることで同じapproveする仕組みを提案しているERC4546」についてまとめてきました! いかがだったでしょうか?

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

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