ERC-2612 とは何ですか#
ERC-2612 は erc20 の approve の最適化であり、従来の approve は EOA によって開始されなければなりません。EOA にとって approve とその後の操作は少なくとも 2 つのトランザクションを必要とし、追加のガスコストが発生します。erc2612 は erc20 の拡張であり、approve を実現するための新しいメソッドを導入します。
erc-2612 は 3 つの新しいメソッドを実装する必要があります:
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external
function nonces(address owner) external view returns (uint)
function DOMAIN_SEPARATOR() external view returns (bytes32)
重要なのは permit メソッドであり、このメソッドも erc20 の approval 構造を変更し、event を発火させます。
permit メソッドには 7 つのパラメータを渡す必要があります:
- owner:現在のトークンのオーナー
- spender:承認者
- value:承認の額
- deadline:トランザクション実行の締切
- v:トランザクション署名データ
- r:トランザクション署名データ
- s:トランザクション署名データ
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");
// オーバーフローは現実的に発生しないため、未チェック
unchecked {
address recoveredAddress = ecrecover(
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
)
)
),
v,
r,
s
);
require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");
allowance[recoveredAddress][spender] = value;
}
emit Approval(owner, spender, value);
}
ERC-2612 の使用フロー#
permit#
permit の主なフローは以下を含みます:
- 現在のトランザクションの締切を検証
- ecrecover を使用して recoveredAddress アドレスを解析
- recoveredAddress が渡された owner アドレスと一致するか検証
- approve ロジックを実行し、allowance を変更し、Approval イベントを発火
重要なフローは 2 番目のステップであり、渡されたパラメータから recoveredAddress を解析し、渡された owner と一致するかを検証し、その後token.approve(spender, value)
のようなロジックを実行して approve を完了します。
まず、内部のロジックをunchecked
でラップし、unchecked を使用する理由はいくつかあります:
- Solidity 0.8.0 以降のオーバーフローに対するデフォルトのリバートと以前のコードの衝突を解決するため。自分の計算がオーバーフローしないことを確信できる場合、外側で unchecked でラップすることで、Solidity にオーバーフローの判断をデフォルトで行わせず、追加のガスコストを引き起こさないようにします。
- 0.8.0 以降は SafeMath が不要になります。
次に、ecrecover
というキーワードが使用されています。このメソッドの入力パラメータは:
- bytes32:署名されたメッセージ
- uint8: v
- bytes32: r
- bytes32: s
返されるパラメータは署名されたアドレス recoveredAddress です。
r、v、s はすべて渡されたパラメータであり、署名されたメッセージが何であるかを見てみましょう。
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
)
)
)
keccak256
は Solidity でハッシュを計算するための組み込みメソッドであり、abi.encodePacked
は渡されたパラメータを密にエンコードします(0 のパディングを省略し、スペースを節約し、コントラクトと相互作用しない場合に使用します)。
DOMAIN_SEPARATOR#
もう一つのメソッドは:DOMAIN_SEPARATOR()
であり、これも erc-2612 で実装する必要があるメソッドです。このフィールドの目的は、各チェーン上の各コントラクトに一意の識別子を与え、EIP-712 の要件を満たすことです(標準的な構造化署名プロトコルであり、オフチェーン署名とオンチェーン検証の安全性を保証します。その中の domain separator は再放送攻撃を防ぐための一意の識別子です)。
function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator();
}
function computeDomainSeparator() internal view virtual returns (bytes32) {
return
keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256("1"),
block.chainid,
address(this)
)
);
}
この例のDOMAIN_SEPARATOR()
メソッドは、keccak256 を使用してコントラクト名、chainID、バージョン番号、現在のコントラクトアドレスなどの情報をハッシュ計算して得られる一意の識別子を生成します。
DOMAIN_SEPARATOR
の計算が終了した後、再び abi.encode を使用してこの部分をエンコードします:
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
ここでのパラメータはすべて permit から渡されたものであり、nonce
というフィールドに注意が必要です。これはnonces[owner]++
を使用しており、カスタム配列を使用して自動増分のユーザー nonce を維持し、同じ署名のトランザクションが再利用されるのを防ぎます。
このフローにより、ハッシュされた署名情報から recoveredAddress アドレスが解析されますが、なぜ解析された recoveredAddress アドレスが owner と一致する必要があるのでしょうか?
ERC-2612 の使用方法#
前述のように、permit メソッドを呼び出すことで approve の代理呼び出しを完了し、EOA の前置 approve 操作を省略できます。permit を呼び出す際には非常に重要な 3 つのパラメータ r、s、v があり、これらのパラメータは EOA によって前もって署名されて提供される必要があります。
以下の forge のコードを例に見てみましょう:
address alice = vm.addr(1);
bytes32 hash = keccak256("Signed by Alice");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash);
address signer = ecrecover(hash, v, r, s);
assertEq(alice, signer); // [PASS]
alice は hash に署名し、v、r、s の 3 つのパラメータを返します。その後、ecrecover を使用して signer を alice として取得します。
permit の検証ロジックを通過するためには、一致する署名内容を構築し、EOA に署名させる必要があります。rsv を持って permit を呼び出すことができます。
以下のコードは permit 署名メソッドのラッピングであり、Permit 構造体を定義し、erc-2612 の計算方法と一致しています。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract SigUtils {
bytes32 internal DOMAIN_SEPARATOR;
constructor(bytes32 _DOMAIN_SEPARATOR) {
DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
}
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
struct Permit {
address owner;
address spender;
uint256 value;
uint256 nonce;
uint256 deadline;
}
// permitのハッシュを計算します
function getStructHash(Permit memory _permit)
internal
pure
returns (bytes32)
{
return
keccak256(
abi.encode(
PERMIT_TYPEHASH,
_permit.owner,
_permit.spender,
_permit.value,
_permit.nonce,
_permit.deadline
)
);
}
// ドメインのために完全にエンコードされたEIP-712メッセージのハッシュを計算し、署名者を復元するために使用できます
function getTypedDataHash(Permit memory _permit)
public
view
returns (bytes32)
{
return
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
getStructHash(_permit)
)
);
}
}
ERC-2612 のセキュリティ問題#
ERC-2612 は本質的に approve の上に一連の検証ロジックを追加し、ユーザーがオフチェーンでトランザクションの署名を事前に生成し、そのトランザクション署名の rsv をパラメータとして permit を呼び出すことを許可します。
考えられるセキュリティの脆弱性は以下の通りです:
再放送攻撃:DOMAIN_SEPARATOR
を計算する際に chainID が識別子として使用されます。チェーンが分岐した場合、chainID がコンストラクタで設定されていると、2 つのチェーン上に同じ chainID のコントラクトが存在することになります。チェーン A で署名し、チェーン B で再放送することが可能です。