はじめに(Introduction)
USDC、JPYCなどが採用している、ERC3009を実装してみます。
ERC3009
モチベーションとしては、「ガスの支払いを他のユーザーに委任」できることです。
ネイティブトークンをもたないユーザーがERC20(USDC、JPYCなど)を移転したい場合に使用されます。
実装(Implementation)
OpenZeppelinのERC20に公式のImplementationを適用します。
※:一部不具合やOpenZeppelinと合致しない部分を修正します。
EIP712.sol
は EIP712のドメインセパレータの作成とリカバーを行うライブラリです。
公式のコードには不具合があるので注意してください。
bytes32(chainId)
、address(this)
の順が正しいです。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
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)
bytes32(chainId),
address(this)
)
);
}
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");
return recovered;
}
}
EIP712Domain.sol
はEIP12のドメインセパレータを格納する変数(DOMAIN_SEPARATOR
)を実装しています。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
abstract contract EIP712Domain {
bytes32 public DOMAIN_SEPARATOR;
}
IERC20Transfer.sol
は ERC20の _transfer
を上書きするためのインターフェースです。
OpenzeppelinのERC20が _transfer
を実装していえて上書き禁止のため __transfer
に変更しています。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
abstract contract IERC20Transfer {
// function _transfer(
function __transfer(
address sender,
address recipient,
uint256 amount
) internal virtual;
}
EIP3009.sol
はEIP3009の実装です。
インターフェースを変更しているので、_transfer(from, to, value);
を __transfer(from, to, value);
に変更しています。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "./IERC20Transfer.sol";
import "./EIP712Domain.sol";
import "./EIP712.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");
require(
block.timestamp > validAfter,
"EIP3009: authorization is not yet valid"
);
// require(now < validBefore, "EIP3009: authorization is expired");
require(
block.timestamp < validBefore,
"EIP3009: authorization is expired"
);
require(
!_authorizationStates[from][nonce],
"EIP3009: authorization is used"
);
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"
);
_authorizationStates[from][nonce] = true;
emit AuthorizationUsed(from, nonce);
// _transfer(from, to, value);
__transfer(from, to, value);
}
}
ERC20
Openzeppelin の ERC20 を利用して、ERC20(DemoToken)を作成します。
コンストラクタ で ドメインセパレータ を作成し、コントラクト作成者に1000000トークンを付与します。
EIP712のドメイン情報を返す、eip712Domain
という関数を提供します。
また、EIP3009の __transfer
の実装は ERC2 0の _transfer
を実行する実装とします。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "./EIP3009.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./EIP712.sol";
contract DemoToken is ERC20, EIP3009 {
string private _name = "DemoToken";
string private _symbol = "DTC";
string private _version = "1";
constructor() ERC20(_name, _symbol) {
DOMAIN_SEPARATOR = EIP712.makeDomainSeparator(_name, _version);
_mint(msg.sender, 1000000 * 10 ** 18);
}
function eip712Domain()
public
view
returns (
string memory name,
string memory version,
uint256 chainId,
address verifyingContract
)
{
name = _name;
version = _version;
chainId = block.chainid;
verifyingContract = address(this);
}
function __transfer(
address sender,
address recipient,
uint256 amount
) internal virtual override {
return super._transfer(sender, recipient, amount);
}
}
クライアント(Client)
実際に transferWithAuthorization
を利用して、トークンを移転してみます。
deployer
から user1
へ 100トークン移転を行いますが、トランザクションは user2
が送信します。
署名は deployer
が行います。
const CONTRACT_ADDRESS = ...; // コントラクトアドレス
const PROVIDER = new ethers.JsonRpcProvider('http://localhost:8545/'); // プロバイダー
const CONTRACT_ABI = [
// ERC-20
"function name() public view returns (string)",
"function symbol() public view returns (string)",
"function decimals() public view returns (uint8)",
"function totalSupply() public view returns (uint256)",
"function balanceOf(address _owner) public view returns (uint256 balance)",
"function transfer(address _to, uint256 _value) public returns (bool success)",
"function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)",
"function approve(address _spender, uint256 _value) public returns (bool success)",
"function allowance(address _owner, address _spender) public view returns (uint256 remaining)",
"event Transfer(address indexed _from, address indexed _to, uint256 _value)",
"event Approval(address indexed _owner, address indexed _spender, uint256 _value)",
// ERC-3009
"function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)",
];
// コントラクト取得
const contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, PROVIDER);
// 小数点以下の桁数を取得
const decimals = await contract.decimals();
// Deployer、User1、User2の残高を取得
await balance(PROVIDER, contract, decimals, [deployer.address, user1.address, user2.address]);
console.log('>>> transferWithAuthorization >>>');
// ERC-3009のTransferWithAuthorizationを実行
// EIP712のドメインを取得
const eip712Domain = await token.eip712Domain();
// EIP712のドメインを設定
const domain = {
name: eip712Domain.name, // ドメイン名
version: eip712Domain.version, // ドメインバージョン
verifyingContract: eip712Domain.verifyingContract, // コントラクトアドレス
chainId: eip712Domain.chainId, // チェーンID
};
// TransferWithAuthorizationのタイプを定義
const TYPES = {
TransferWithAuthorization: [
{ name: "from", type: "address" }, // 支払アドレス (承認者)
{ name: "to", type: "address" }, // 受取アドレス
{ name: "value", type: "uint256" }, // 送金金額
{ name: "validAfter", type: "uint256" }, // この時間の後で有効 (UNIX 時間)
{ name: "validBefore", type: "uint256" }, // この時間の前で有効 (UNIX 時間)
{ name: "nonce", type: "bytes32" } // 一意の nonce (32 バイト)
]
};
// TransferWithAuthorizationの値を定義
const value = {
from: deployer.address, // 支払アドレス (承認者)
to: user1.address, // 受取アドレス
value: ethers.parseUnits("100", decimals), // 送金金額
validAfter: 0n, // この時間の後で有効 (UNIX 時間)
validBefore: BigInt(Math.floor(Date.now() / 1000)) + 3600n, // この時間の前で有効 (UNIX 時間)
nonce: ethers.randomBytes(32), // 一意の nonce (32 バイト)
};
// EIP712の署名を取得
const sign = ethers.Signature.from(await deployer.signTypedData(domain, TYPES, value));
// TransferWithAuthorizationを実行
const tx = await token.connect(user2).transferWithAuthorization(
value.from, // 支払アドレス (承認者)
value.to, // 受取アドレス
value.value, // 送金金額
value.validAfter, // この時間の後で有効 (UNIX 時間)
value.validBefore, // この時間の前で有効 (UNIX 時間)
value.nonce, // 一意の nonce (32 バイト)
sign.v, // 署名の v 値
sign.r, // 署名の r 値
sign.s); // 署名の s 値
// トランザクションの待機
const receipt = await tx.wait();
// トランザクションの結果を表示
if (receipt) {
console.log('receipt#status', receipt.status, ethers.formatEther(receipt.gasUsed * receipt.gasPrice));
}
console.log('<<< transferWithAuthorization <<<');
await sleep(100); // 100ミリ秒待機
// Deployer、User1、User2の残高を取得
await balance(PROVIDER, contract, decimals, [deployer.address, user1.address, user2.address]);
以下に sleep
関数とトークンとネイティブトークンを表示する balance
関数を作成します。
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
};
async function balance(provider: Provider, contract: Contract, decimals: Numeric, addressList: string[]) {
console.log('>>> balanceOf >>>');
console.log('BlockNumber', await provider.getBlockNumber());
for (const address of addressList) {
const token = ethers.formatUnits(await contract.balanceOf(address), decimals);
const native = ethers.formatEther(await provider.getBalance(address));
console.log(address,
token.padStart(10 - token.indexOf('.') + token.length),
native.padStart(10 - native.indexOf('.') + native.length));
}
console.log('<<< balanceOf <<<');
}
実行結果は以下となります。
トークンの移転は、1番目のアドレス(deployer
)から2番目のアドレス(user1
)へ移転されていますが、ネイティブトークンが使用されていません。(9999.998047450228515625
のまま)
トランザクションを送信した3番目のアドレス(user2
)からネイティブトークンが減っています。
(10000.0
⇒ 9999.99991382376283858
)
差分はトランザクションのガス代(ガス量×ガス価格:0.00008617623716142
)と一致します。
>>> balanceOf >>>
BlockNumber 1
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000000.0 9999.998047450228515625
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 0.0 10000.0
0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC 0.0 10000.0
<<< balanceOf <<<
>>> transferWithAuthorization >>>
receipt#status 1 0.00008617623716142
<<< transferWithAuthorization <<<
>>> balanceOf >>>
BlockNumber 2
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 999900.0 9999.998047450228515625
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 100.0 10000.0
0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC 0.0 9999.99991382376283858
<<< balanceOf <<<
まとめ(Conclusion)
「ガスの支払いを他のユーザーに委任」することに成功しました。
実際はクライアントのコードのように一連の中で署名値を扱うことはなく、署名値はWalletConnectなどを経由して受け取る必要があります。