Alert Source Discuss
🚧 Stagnant Standards Track: ERC

ERC-3009: 授权转账

Authors Peter Jihoon Kim (@petejkim), Kevin Britz (@kbrizzle), David Knott (@DavidLKnott)
Created 2020-09-28
Discussion Link https://github.com/ethereum/EIPs/issues/3010
Requires EIP-20, EIP-712

简述

一个通过签名授权实现可替换资产转移的合约接口。

概要

一组函数,通过符合 EIP-712 类型消息签名规范的签名,实现与 ERC-20 代币合约的元交易和原子交互。

这使得用户能够:

  • 将 gas 费的支付委托给其他人,
  • 使用代币本身而不是 ETH 支付 gas 费,
  • 在单个原子交易中执行一个或多个代币转移和其他操作,
  • 将 ERC-20 代币转移到另一个地址,并让接收者提交交易,
  • 以最小的开销批量处理多个交易,并且
  • 创建和执行多个交易,而无需担心由于意外的 nonce 重用或矿工的不正确排序而导致交易失败。

动机

现有的规范 EIP-2612,也允许元交易,因此鼓励合约同时实现这两种规范以获得最大的兼容性。此规范与 EIP-2612 之间的两个主要区别是:

  • EIP-2612 使用连续 nonce,而此规范使用随机的 32 字节 nonce,以及
  • EIP-2612 依赖于 ERC-20 approve/transferFrom(“ERC-20 授权”)模式。

使用连续 nonce 的最大问题是,它不允许用户一次执行多个交易,而不会有交易失败的风险,因为:

  • DApp 可能会无意中重用尚未在区块链中处理的 nonce。
  • 矿工可能会以错误的顺序处理交易。

如果 gas 价格非常高并且交易经常排队并在很长时间内未确认,则这尤其成问题。非连续 nonce 允许用户同时创建任意数量的交易。

ERC-20 授权机制容易受到多次提款攻击/SWC-114的影响,并鼓励使用诸如“无限”授权之类的反模式。可升级合约的广泛普及使得这些攻击在现实中发生的条件变得有利。

ERC-20 授权模式的缺陷导致了替代代币标准(如 ERC-777ERC-677)的开发。但是,由于兼容性和潜在的安全问题,它们未能获得广泛采用。

规范

事件

event AuthorizationUsed(
    address indexed authorizer,
    bytes32 indexed nonce
);

// keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

// keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

/**
 * @notice 返回授权的状态
 * @dev Nonce 是对授权者的地址唯一的随机生成的 32 字节数据
 * @param authorizer    授权者的地址
 * @param nonce         授权的 Nonce
 * @return 如果 nonce 被使用则返回 True
 */
function authorizationState(
    address authorizer,
    bytes32 nonce
) external view returns (bool);

/**
 * @notice 使用签名授权执行转账
 * @param from          付款人的地址(授权者)
 * @param to            收款人的地址
 * @param value         要转移的金额
 * @param validAfter    此授权生效后的时间(unix 时间)
 * @param validBefore   此授权失效前的时间(unix 时间)
 * @param nonce         唯一 nonce
 * @param v             签名的 v 值
 * @param r             签名的 r 值
 * @param s             签名的 s 值
 */
function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

/**
 * @notice 接收来自付款人的签名授权转账
 * @dev 此函数还有一个额外的检查,以确保收款人的地址与此函数的调用者匹配,以防止抢跑攻击。(请参阅安全注意事项)
 * @param from          付款人的地址(授权者)
 * @param to            收款人的地址
 * @param value         要转移的金额
 * @param validAfter    此授权生效后的时间(unix 时间)
 * @param validBefore   此授权失效前的时间(unix 时间)
 * @param nonce         唯一 nonce
 * @param v             签名的 v 值
 * @param r             签名的 r 值
 * @param s             签名的 s 值
 */
function receiveWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

可选:

event AuthorizationCanceled(
    address indexed authorizer,
    bytes32 indexed nonce
);

// keccak256("CancelAuthorization(address authorizer,bytes32 nonce)")
bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;

/**
 * @notice 尝试取消授权
 * @param authorizer    授权者的地址
 * @param nonce         授权的 Nonce
 * @param v             签名的 v 值
 * @param r             签名的 r 值
 * @param s             签名的 s 值
 */
function cancelAuthorization(
    address authorizer,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

参数 vrs 必须使用 EIP-712 类型消息签名规范获得。

示例:

DomainSeparator := Keccak256(ABIEncode(
  Keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
  ),
  Keccak256("USD Coin"),                      // name
  Keccak256("2"),                             // version
  1,                                          // chainId
  0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48  // verifyingContract
))

有了域分隔符、用于标识所使用的 EIP-712 消息类型的 typehash 以及参数值,您就可以导出一个 Keccak-256 哈希摘要,然后可以使用代币持有者的私钥对其进行签名。

示例:

// 授权转账
TypeHash := Keccak256(
  "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
)
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce }

// 授权接收
TypeHash := Keccak256(
  "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
)
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce }

// 取消授权
TypeHash := Keccak256(
  "CancelAuthorization(address authorizer,bytes32 nonce)"
)
Params := { Authorizer, Nonce }
// "‖" 表示连接。
Digest := Keecak256(
  0x1901 ‖ DomainSeparator ‖ Keccak256(ABIEncode(TypeHash, Params...))
)

{ v, r, s } := Sign(Digest, PrivateKey)

包装 receiveWithAuthorization 调用的智能合约函数可以选择减少参数的数量,方法是将 receiveWithAuthorization 调用的完整 ABI 编码参数集作为 bytes 类型的单个参数接受。

示例:

// keccak256("receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)")[0:4]
bytes4 private constant _RECEIVE_WITH_AUTHORIZATION_SELECTOR = 0xef55bec6;

function deposit(address token, bytes calldata receiveAuthorization)
    external
    nonReentrant
{
    (address from, address to, uint256 amount) = abi.decode(
        receiveAuthorization[0:96],
        (address, address, uint256)
    );
    require(to == address(this), "Recipient is not this contract");

    (bool success, ) = token.call(
        abi.encodePacked(
            _RECEIVE_WITH_AUTHORIZATION_SELECTOR,
            receiveAuthorization
        )
    );
    require(success, "Failed to transfer tokens");

    ...
}

与 web3 提供程序一起使用

可以使用 web3 提供程序的 eth_signTypedData{_v4} 方法获取授权签名。

示例:

const data = {
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    TransferWithAuthorization: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
    ],
  },
  domain: {
    name: tokenName,
    version: tokenVersion,
    chainId: selectedChainId,
    verifyingContract: tokenAddress,
  },
  primaryType: "TransferWithAuthorization",
  message: {
    from: userAddress,
    to: recipientAddress,
    value: amountBN.toString(10),
    validAfter: 0,
    validBefore: Math.floor(Date.now() / 1000) + 3600, // 有效期一小时
    nonce: Web3.utils.randomHex(32),
  },
};

const signature = await ethereum.request({
  method: "eth_signTypedData_v4",
  params: [userAddress, JSON.stringify(data)],
});

const v = "0x" + signature.slice(130, 132);
const r = signature.slice(0, 66);
const s = "0x" + signature.slice(66, 130);

原理

唯一随机 Nonce,而不是连续 Nonce

有人可能会说交易排序是首选连续 nonce 的一个原因。但是,实际上,连续 nonce 实际上并不能帮助实现元交易的交易排序:

  • 对于原生 Ethereum 交易,当一个 nonce 值过高的交易被提交到网络时,它将保持挂起状态,直到使用较低的未使用 nonce 的交易被确认。
  • 但是,对于元交易,当提交包含连续 nonce 值的交易时,它不会保持挂起状态,而是会立即回滚并失败,从而浪费 gas。
  • 矿工还可以重新排序交易,并按照他们想要的顺序将其包含在区块中的事实(假设每笔交易都是由不同的元交易中继器提交到网络的)也使得元交易即使使用的 nonce 正确也可能失败。(例如,用户提交 nonce 3、4 和 5,但矿工最终将它们作为 4、5、3 包含在区块中,导致只有 3 成功)
  • 最后,当同时使用不同的应用程序时,在没有任何链下 nonce 跟踪器的情况下,如果存在已使用但尚未由网络提交和确认的 nonce,则无法确定正确的下一个 nonce 值。
  • 在高 gas 价格条件下,交易通常会长时间“卡住”在池中。在这种情况下,同一个 nonce 更有可能被无意中重复使用两次。例如,如果您从一个应用程序进行使用连续 nonce 的元交易,然后在前一个交易确认之前切换到另一个应用程序进行另一个元交易,如果应用程序纯粹依赖于链上可用的数据,将会使用相同的 nonce,导致其中一个交易失败。
  • 总之,保证交易排序的唯一方法是中继器一次提交一个交易,等待每次提交之间的确认(并且它们应该提交的顺序可以是一些链下元数据的一部分),这使得连续 nonce 无关紧要。

Valid After 和 Valid Before

  • 依靠中继器为您提交交易意味着您可能无法精确控制交易提交的时间。
  • 这些参数允许用户安排仅在将来或特定截止日期之前有效的交易,从而保护用户免受提交过晚或过早可能造成的潜在不良影响。

EIP-712

  • EIP-712 确保生成的签名仅对该代币合约的特定实例有效,并且无法在具有不同链 ID 的不同网络上重放。
  • 这是通过将合约地址和链 ID 合并到称为域分隔符的 Keccak-256 哈希摘要中来实现的。用于导出域分隔符的实际参数集由实施合约决定,但强烈建议包括字段 verifyingContractchainId

向后兼容性

新的合约受益于能够直接利用 EIP-3009 以创建原子交易,但现有合约可能仍然依赖于传统的 ERC-20 授权模式 (approve/transferFrom)。

为了将 EIP-3009 的支持添加到使用 ERC-20 授权模式的现有合约(“父合约”)中,可以构建一个转发合约(“转发器”),该转发合约接受授权并执行以下操作:

  1. 从授权中提取用户和存款金额
  2. 调用 receiveWithAuthorization 以将指定资金从用户转移到转发器
  3. 批准父合约从转发器支出资金
  4. 在父合约上调用支出从转发器设置的授权的方法
  5. 将任何生成的代币的所有权转回给用户

示例:

interface IDeFiToken {
    function deposit(uint256 amount) external returns (uint256);

    function transfer(address account, uint256 amount)
        external
        returns (bool);
}

contract DepositForwarder {
    bytes4 private constant _RECEIVE_WITH_AUTHORIZATION_SELECTOR = 0xef55bec6;

    IDeFiToken private _parent;
    IERC20 private _token;

    constructor(IDeFiToken parent, IERC20 token) public {
        _parent = parent;
        _token = token;
    }

    function deposit(bytes calldata receiveAuthorization)
        external
        nonReentrant
        returns (uint256)
    {
        (address from, address to, uint256 amount) = abi.decode(
            receiveAuthorization[0:96],
            (address, address, uint256)
        );
        require(to == address(this), "Recipient is not this contract");

        (bool success, ) = address(_token).call(
            abi.encodePacked(
                _RECEIVE_WITH_AUTHORIZATION_SELECTOR,
                receiveAuthorization
            )
        );
        require(success, "Failed to transfer to the forwarder");

        require(
            _token.approve(address(_parent), amount),
            "Failed to set the allowance"
        );

        uint256 tokensMinted = _parent.deposit(amount);
        require(
            _parent.transfer(from, tokensMinted),
            "Failed to transfer the minted tokens"
        );

        uint256 remainder = _token.balanceOf(address(this);
        if (remainder > 0) {
            require(
                _token.transfer(from, remainder),
                "Failed to refund the remainder"
            );
        }

        return tokensMinted;
    }
}

测试用例

请参阅EIP3009.test.ts

实现

EIP3009.sol

abstract contract EIP3009 is IERC20Transfer, EIP712Domain {
    // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

    // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

    mapping(address => mapping(bytes32 => bool)) internal _authorizationStates;

    event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);

    string internal constant _INVALID_SIGNATURE_ERROR = "EIP3009: invalid signature";

    function authorizationState(address authorizer, bytes32 nonce)
        external
        view
        returns (bool)
    {
        return _authorizationStates[authorizer][nonce];
    }

    function transferWithAuthorization(
        address from,
        address to,
        uint256 value,
        uint256 validAfter,
        uint256 validBefore,
        bytes32 nonce,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        require(now > validAfter, "EIP3009: authorization is not yet valid");  // EIP3009:授权尚未生效
        require(now < validBefore, "EIP3009: authorization is expired"); // EIP3009:授权已过期
        require(
            !_authorizationStates[from][nonce],
            "EIP3009: authorization is used" // EIP3009:授权已被使用
        );

        bytes memory data = abi.encode(
            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
            from,
            to,
            value,
            validAfter,
            validBefore,
            nonce
        );
        require(
            EIP712.recover(DOMAIN_SEPARATOR, v, r, s, data) == from,
            "EIP3009: invalid signature" // EIP3009:无效签名
        );

        _authorizationStates[from][nonce] = true;
        emit AuthorizationUsed(from, nonce);

        _transfer(from, to, value);
    }
}

IERC20Transfer.sol

abstract contract IERC20Transfer {
    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal virtual;
}

EIP712Domain.sol

abstract contract EIP712Domain {
    bytes32 public DOMAIN_SEPARATOR;
}

EIP712.sol

library EIP712 {
    // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
    bytes32 public constant EIP712_DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;

    function makeDomainSeparator(string memory name, string memory version)
        internal
        view
        returns (bytes32)
    {
        uint256 chainId;
        assembly {
            chainId := chainid()
        }

        return
            keccak256(
                abi.encode(
                    EIP712_DOMAIN_TYPEHASH,
                    keccak256(bytes(name)),
                    keccak256(bytes(version)),
                    address(this),
                    bytes32(chainId)
                )
            );
    }

    function recover(
        bytes32 domainSeparator,
        uint8 v,
        bytes32 r,
        bytes32 s,
        bytes memory typeHashAndData
    ) internal pure returns (address) {
        bytes32 digest = keccak256(
            abi.encodePacked(
                "\x19\x01",
                domainSeparator,
                keccak256(typeHashAndData)
            )
        );
        address recovered = ecrecover(digest, v, r, s);
        require(recovered != address(0), "EIP712: invalid signature"); // EIP712:无效签名
        return recovered;
    }
}

可以在此存储库中找到 EIP-3009 的完整工作实现。该存储库还包括EIP-2612 的实现,该实现使用了上面介绍的 EIP-712 库代码。

安全注意事项

从其他智能合约调用时,请使用 receiveWithAuthorization 而不是 transferWithAuthorization。攻击者可能会监视交易池以提取转移授权,并抢先执行 transferWithAuthorization 调用以执行转移,而无需调用包装函数。这可能会导致未处理的、锁定的存款。receiveWithAuthorization 通过执行额外的检查(确保调用者是收款人)来防止这种情况。此外,如果有多个合约函数接受接收授权,则应用程序开发人员可以将 nonce 的一些前导字节用作标识符,以防止交叉使用。

同时提交多个转账时,请注意中继器和矿工将决定处理它们的顺序。如果交易彼此不依赖,这通常不是问题,但是对于彼此高度依赖的交易,建议一次提交一个签名的授权。

使用 ecrecover 时必须拒绝零地址,以防止未经授权的资金从零地址转移和批准。当提供格式错误的签名时,内置的 ecrecover 返回零地址。

版权

版权及相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Peter Jihoon Kim (@petejkim), Kevin Britz (@kbrizzle), David Knott (@DavidLKnott), "ERC-3009: 授权转账 [DRAFT]," Ethereum Improvement Proposals, no. 3009, September 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3009.