Closed30

sendERC20

ikmzkroikmzkro

不特定多数のERC20をスマートコントラクト経由で送金できるようにしたい。
独自トークンを作成し送金、複数送金することは実装、動作確認できている。
ただし、送金関数を変更すると失敗しておりその理由がぱっと思いつかなかったので、
依存しているOZのコード系を整理していく。

ikmzkroikmzkro

送金が成功したケースだと、コントラクト内で独自ERC20トークンの作成と送金を実装している。
その際はスマートコントラクト側に独自通貨をdeposit, withdrawできるようにしており、
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";からtransfer関数をコールして成功することが確認できている、

ikmzkroikmzkro

送金が失敗したケースだと、コントラクト外部で独自ERC20トークンの作成を行い、コントラクト内部で送金関数を実装している。この場合using SafeERC20 for IERC20;としたうえで、指定したtokenAddressからsafeTransferFromをコールするというものだった。

ikmzkroikmzkro

transfer, transferFrom , safeTransferFromの比較を行うと下記の通りになる。

  • transfer は、CA(スマートコントラクト)自分自身が持っているトークンの残高から送金する。なので、事前に approve する必要がない
  • transferFrom は、CA(スマートコントラクト)自分自身ではなく、事前に approve された EOA から、事前に approve された範囲以内のトークンから送金する
  • safeTransferは、呼び出し元のコントラクトから指定されたtokenをtoアドレスにvalue量転送します。
  • safeTransferFromは、fromアドレスから呼び出し元のコントラクトによって承認されたtokenをtoアドレスにvalue量転送します

下二つの関数では、Solidityの戻り値のサイズチェック機構を回避するために低レベルの呼び出しを行っています。Address.functionCallを使用してこの呼び出しを実行し、対象アドレスにコントラクトコードが含まれていること、そして低レベルの呼び出しで成功したことを確認します。

また、戻り値がある場合、その戻り値が false でないことを確認しています。もし戻り値が false の場合は、SafeERC20FailedOperationエラーを発生させます。

ikmzkroikmzkro

SafeERC20はコントラクト内から安全にERC20トークンのtransfer関数を呼び出せるライブラリらしい。

  1. transfer関数を呼び出すトークンアドレスがコントラクトアドレスか確かめられることです。extcodesize関数でアドレスのコードの長さを調べて確かめます
  2. transfer関数の戻り値を受け取ることができtransferが実行できたのかどうかを確認できる

https://zenn.dev/retocrooman/articles/20768619a313ae

ikmzkroikmzkro

仮に下記のようなERC20コントラクトを書いた場合、どのような関数が初期設定されるのか。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TestERC20 is ERC20 {
    constructor(uint256 initialSupply) ERC20("TestERC20", "TEST") {
        _mint(msg.sender, initialSupply);
    }
}
ikmzkroikmzkro

デプロイしてヴェリファイするます。

import { ethers } from 'hardhat';

// npx hardhat run scripts/deployTestERC20.ts --network sepolia
// npx hardhat verify --network sepolia --contract contracts/TestERC20.sol:TestERC20 0x604E24d924322CBEc1D4063DbDB410975b41473a "1000000000000000000000000000"
// https://sepolia.etherscan.io/address/0x604E24d924322CBEc1D4063DbDB410975b41473a#writeContract

async function main() {
  const ERC20 = await ethers.getContractFactory("TestERC20");
  const erc20 = await ERC20.deploy("1000000000000000000000000000");
  await erc20.deployed();
  console.log(`TestERC20 deployed to: https://sepolia.etherscan.io/address/${erc20.address}`)
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

ikmzkroikmzkro

通貨名、通貨銘柄、供給量していするだけでこれだけのことができるとは最新のimport "@openzeppelin/contracts/token/ERC20/ERC20.sol";は優秀だな。

シナリオ

  • approve(spender: address, amount: uint256)で指定のアドレス(EOA, CA)に対して指定枚数だけ操作権限を譲渡する
  • allowanece(owner: address, spender: address)でCAのOwnerアドレスと権限譲渡したspenderのアドレスを指定すると、spenderが操作可能な通貨枚数が確認できる
  • balanceOf(account: address)でCA内で保持する通貨の枚数を確認できる
  • transfer(from: address, to: address)で保有する通貨を転送できる
  • transferFrom(from: address, to: address, amount: uint256)保有する通貨を転送できる。
ikmzkroikmzkro

transfer(from: address, to: address)

    /**
     * @dev See {IERC20-transfer}.
     *
     * Requirements:
     *
     * - `to` cannot be the zero address.
     * - the caller must have a balance of at least `amount`.
     */
    function transfer(address to, uint256 amount) public virtual override returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, amount);
        return true;
    }

メモ

  • transfer(address to, uint256 amount)において
    • toは送金先アドレス
    • amountは送金額
  • _transfer(from, to, amount);において
    • fromは_msgSender()
    • toは送金先アドレス
    • amountは送金額

動作確認

  • CAのOwnerからテストEOAに1枚送金、返金を確認できた
  • 送金枚数等だが_balances[address]で管理されているので特に追跡用関数は不要
ikmzkroikmzkro

_transfer(address from, address to, uint256 amount)

 /**
     * @dev Moves `amount` of tokens from `from` to `to`.
     *
     * This internal function is equivalent to {transfer}, and can be used to
     * e.g. implement automatic token fees, slashing mechanisms, etc.
     *
     * Emits a {Transfer} event.
     *
     * Requirements:
     *
     * - `from` cannot be the zero address.
     * - `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     */
    function _transfer(address from, address to, uint256 amount) internal virtual {
        require(from != address(0), "ERC20: transfer from the zero address");
        require(to != address(0), "ERC20: transfer to the zero address");

        _beforeTokenTransfer(from, to, amount);

        uint256 fromBalance = _balances[from];
        require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
        unchecked {
            _balances[from] = fromBalance - amount;
            // Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
            // decrementing then incrementing.
            _balances[to] += amount;
        }

        emit Transfer(from, to, amount);

        _afterTokenTransfer(from, to, amount);
    }
ikmzkroikmzkro

transferFrom(from: address, to: address, amount: uint256)

    /**
     * @dev See {IERC20-transferFrom}.
     *
     * Emits an {Approval} event indicating the updated allowance. This is not
     * required by the EIP. See the note at the beginning of {ERC20}.
     *
     * NOTE: Does not update the allowance if the current allowance
     * is the maximum `uint256`.
     *
     * Requirements:
     *
     * - `from` and `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     * - the caller must have allowance for ``from``'s tokens of at least
     * `amount`.
     */
    function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, amount);
        _transfer(from, to, amount);
        return true;
    }

メモ

  • _spendAllowance(from, spender, amount)において
    • fromはCAのowner
    • spenderは_msgSender()=関数呼び出し者
    • amountは送金額
    • CAのownerがspenderに対して許容した通貨枚数を確認し
      • 許可枚数が無限なら何もしない
      • 許可枚数がamount以上であれば残高不足を通知
    • uncheckedでオーバーフローやアンダーフローなどの算術演算の結果が安全であることを保証
    • CAのownerがspenderに対して許容した通貨枚数を送金額だけ減額する
  • _transfer(from, to, amount);において
    • fromはCAのowner
    • toは送金先アドレス
    • amountは送金額
    • CA内の許容量等は何もせず、単にCAとアドレスに紐づく残高管理を行ってくれる

動作確認

ikmzkroikmzkro

_spendAllowance(from, spender, amount);

    /**
     * @dev Updates `owner` s allowance for `spender` based on spent `amount`.
     *
     * Does not update the allowance amount in case of infinite allowance.
     * Revert if not enough allowance is available.
     *
     * Might emit an {Approval} event.
     */
    function _spendAllowance(address owner, address spender, uint256 amount) internal virtual {
        uint256 currentAllowance = allowance(owner, spender);
        if (currentAllowance != type(uint256).max) {
            require(currentAllowance >= amount, "ERC20: insufficient allowance");
            unchecked {
                _approve(owner, spender, currentAllowance - amount);
            }
        }
    }
ikmzkroikmzkro

allowance

    /**
     * @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens.
     *
     * This internal function is equivalent to `approve`, and can be used to
     * e.g. set automatic allowances for certain subsystems, etc.
     *
     * Emits an {Approval} event.
     *
     * Requirements:
     *
     * - `owner` cannot be the zero address.
     * - `spender` cannot be the zero address.
     */
    function _approve(address owner, address spender, uint256 amount) internal virtual {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
ikmzkroikmzkro

よってtransfertransferFromの違いはこのへん

  • _transfer(from, to, amount);において

    • fromは_msgSender()
    • toは送金先アドレス
    • amountは送金額
  • _transfer(from, to, amount);において

    • fromはCAのowner、付随してspenderは_msgSender()
    • toは送金先アドレス
    • amountは送金額
ikmzkroikmzkro

差分を整理するために、toは0x427fE323F17BA719c3D5794c2a84240E7Cc9A7f1、amountは1で固定するます。

ikmzkroikmzkro

CAのownerでテスト

  • transfer
    • CAのownerには豊かな資金があるので送金額が送金残高を超えることはない
    • spenderがownerになるのでapproveでリミッター解除しておくこと
  • transferFrom
    • CAのownerであっても送金金額が制御される
    • approveでspenderにownerを指定すればリミッターは解除できる
    • allowanceでowner, spenderとしてコールすると反映が確認できる
ikmzkroikmzkro

CAのowner以外でテスト

  • transfer
    • 前提としてOwnerからfromに対して通貨枚数を許可すること
    • fromは_msgSender() = CAのowner以外
    • allowance[owner, spender]で許可枚数を確認すること
    • しかし、失敗する模様(ガスの見積もりではねられる)
    • 実際のエラー:Fail with error 'ERC20: transfer amount exceeds balance'
      • _transferの中で発生しているみたいなので調査
  • transferFrom
    • transferと同じ前提条件を課す
    • こっちは成功する
ikmzkroikmzkro

transferでFail with error 'ERC20: transfer amount exceeds balance'

CAのowner以外でテストで失敗する理由について調査するます。

uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
  • fromには_msgSender()が入る(CAのowner以外)
  • _balances[from]で呼び出すと送金枚数が保有枚数以上ならエラーを吐くらしい
  • なので、送金枚数=保有枚数ならエラー吐かれるはず
  • _balances[to]でこの値は更新されるので送金関数で再度増してみる
  • つまり許容枚数がおおくても残高が少なければ意味ない、何をいまさら
  • balanceOfで見ると0になってたので11に増やした
  • もう一度transferを実行してみる
ikmzkroikmzkro

CAのowner以外でテスト(2)

  • transfer
    • 前提としてOwnerからfromに対して通貨枚数を許可すること
    • fromは送金枚数に対して大きな残高があること
    • fromは_msgSender() = CAのowner以外
    • 成功した。
ikmzkroikmzkro

おけ。今失敗しているのはsendERC20なので今回まだ見れていないが必要そう・・・

function sendERC20(
        address tokenAddress,
        SendRequest[] calldata sendRequests
    ) external {
        IERC20 token = IERC20(tokenAddress);
        for (uint256 i = 0; i < sendRequests.length; i++) {
            // token.safeIncreaseAllowance(address(this), sendRequests[i].amount);
            token.safeTransferFrom(
                msg.sender,
                sendRequests[i].to,
                sendRequests[i].amount
            );
            // emit ERC20TokenTransfer(sendRequests[i].to, sendRequests[i].amount);
        }
    }
ikmzkroikmzkro

一旦以外でやってみて成功体験を積みたい。
呼び出し時はCA以外を想定しているのでtransferFromではなくtransferを利用する。
まずはこれをデプロイ。

    function sendERC20(
        address tokenAddress,
        SendRequest[] calldata sendRequests
    ) external {
        IERC20 token = IERC20(tokenAddress);
        for (uint256 i = 0; i < sendRequests.length; i++) {
            token.transfer(
                sendRequests[i].to,
                sendRequests[i].amount
            );
        }
    }
ikmzkroikmzkro

Fail with error 'ERC20: insufficient allowance'

    /**
     * @dev Updates `owner` s allowance for `spender` based on spent `amount`.
     *
     * Does not update the allowance amount in case of infinite allowance.
     * Revert if not enough allowance is available.
     *
     * Might emit an {Approval} event.
     */
    function _spendAllowance(address owner, address spender, uint256 amount) internal virtual {
        uint256 currentAllowance = allowance(owner, spender);
        if (currentAllowance != type(uint256).max) {
            require(currentAllowance >= amount, "ERC20: insufficient allowance");
            unchecked {
                _approve(owner, spender, currentAllowance - amount);
            }
        }
    }

insufficient allowanceは許容した枚数を超えた送金枚数となっていた場合に発生する。
今回はtransferなので出ないはず。

ikmzkroikmzkro

Fail with error 'ERC20: transfer amount exceeds balance'

    /**
     * @dev Moves `amount` of tokens from `from` to `to`.
     *
     * This internal function is equivalent to {transfer}, and can be used to
     * e.g. implement automatic token fees, slashing mechanisms, etc.
     *
     * Emits a {Transfer} event.
     *
     * Requirements:
     *
     * - `from` cannot be the zero address.
     * - `to` cannot be the zero address.
     * - `from` must have a balance of at least `amount`.
     */
    function _transfer(address from, address to, uint256 amount) internal virtual {
        require(from != address(0), "ERC20: transfer from the zero address");
        require(to != address(0), "ERC20: transfer to the zero address");

        _beforeTokenTransfer(from, to, amount);

        uint256 fromBalance = _balances[from];
        require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
        unchecked {
            _balances[from] = fromBalance - amount;
            // Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
            // decrementing then incrementing.
            _balances[to] += amount;
        }

        emit Transfer(from, to, amount);

        _afterTokenTransfer(from, to, amount);
    }

from=msg,sender()の残高はちゃんとあるのになあと思ったが、今回はCAから呼び出すのでCAの残高がないからはじかれているという仮説。

token.transfer(
    sendRequests[i].to,
    sendRequests[i].amount
);

TESTERC20コントラクトのallowanceとbalanceOfが1000を返すように設定を実施。
しかしガス見積もりで跳ね返される。。。なぜだ

送金する金額の指定方法がおかしい説。
decimalは18 uint8、amountはuint256で指定している

    struct SendRequest {
        address payable to;
        uint256 amount;
    }

ちな、decimalは少数以下の数。1TEST送るなら 110decimalで指定する。

再度見返したがmsg,sender()となるのはTESTではなくsendERC20なのでそいつに残高を与えてみる
sendERC20コントラクトのallowanceとbalanceOfが1000を返すように設定を実施。
8888888通った。。。

ikmzkroikmzkro

さてさて問題はSafeERC20.solのsafeTransferFromを使えるかどうか。
引数に指定するCAがSafeERC20.solを取り込んでいない場合詰む。
今回のようにTESTCAには自分で指定して載せれるけど複数銘柄がそれに対応しているか不明なのよ。

ikmzkroikmzkro
    /**
     * @dev Transfer `value` amount of `token` from `from` to `to`, spending the approval given by `from` to the
     * calling contract. If `token` returns no value, non-reverting calls are assumed to be successful.
     */
    function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
        _callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
    }

IERC20を継承したtoken=ERC20ContractAddressを引数に渡す。他は誰から誰にどれだけ転送するかを残りの引数に渡す。

ikmzkroikmzkro

_callOptionalReturn

SafeERC20.sol
    /**
     * @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
     * on the return value: the return value is optional (but if data is returned, it must not be false).
     * @param token The token targeted by the call.
     * @param data The call data (encoded using abi.encode or one of its variants).
     */
    function _callOptionalReturn(IERC20 token, bytes memory data) private {
        // We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
        // we're implementing it ourselves. We use {Address-functionCall} to perform this call, which verifies that
        // the target address contains contract code and also asserts for success in the low-level call.

        bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed");
        require(returndata.length == 0 || abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
    }

呼び出し先のアドレスがコントラクトコードを含んでいることを確認し、また低レベルの呼び出しで成功することをアサートします。呼び出し先のコントラクトが正常に処理された場合にのみ、返り値が必要です。この関数は、返り値を必須ではなくして、コントラクト呼び出しをより柔軟に扱いますが、コントラクトが意図した動作を行ったかどうかを確認するために返り値をチェックしています。これにより、不正な操作や攻撃を回避するためのセキュリティ対策となります。

ikmzkroikmzkro

一応かいた。セキュリティ考慮してもまあこれ使っておくとベターか。

    function safeTransferFrom(
        address tokenAddress,
        SendRequest[] calldata sendRequests
    ) external {
        IERC20 token = IERC20(tokenAddress);
        for (uint256 i = 0; i < sendRequests.length; i++) {
            token.safeTransferFrom(
                msg.sender,
                sendRequests[i].to,
                sendRequests[i].amount
            );
        }
    }

test

  it("should send ERC20 token", async () => {
    const [owner, recipient1, recipient2] = await ethers.getSigners();

    // Deploy TestERC20 token contract
    const TestERC20 = await ethers.getContractFactory("TestERC20");
    const testERC20 = await TestERC20.deploy(ethers.utils.parseEther("1000"));
    await testERC20.deployed();

    // Deploy BulkTransfer contract
    const BulkTransfer = await ethers.getContractFactory("BulkTransfer");
    const bulkTransfer = await BulkTransfer.deploy();
    await bulkTransfer.deployed();
    // Approve the BulkTransfer contract to spend tokens on behalf of the owner
    await testERC20.approve(bulkTransfer.address, ethers.utils.parseEther("1000"));

    // Prepare the send requests
    const sendRequests = [
        { to: recipient1.address, amount: ethers.utils.parseEther("100") },
        { to: recipient2.address, amount: ethers.utils.parseEther("200") }
    ];

    // Execute the bulk transfer
    await bulkTransfer.safeTransferFrom(testERC20.address, sendRequests);

    // Check final balances
    const finalBalanceOwner = await testERC20.balanceOf(owner.address);
    const finalBalanceRecipient1 = await testERC20.balanceOf(recipient1.address);
    const finalBalanceRecipient2 = await testERC20.balanceOf(recipient2.address);

    // Validate the balances
    expect(finalBalanceOwner).to.equal(ethers.utils.parseEther("700"));
    expect(finalBalanceRecipient1).to.equal(ethers.utils.parseEther("100"));
    expect(finalBalanceRecipient2).to.equal(ethers.utils.parseEther("200"));
  });
このスクラップは29日前にクローズされました