はじめに

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

今回は、署名した支払いメッセージに基づいて、トークン発行者(issuer)のみがトークンを請求できる仕組みをERC20トークンに組み込み、オフチェーンでのマイクロペイメントを実現する提案しているERC3135についてまとめていきます!

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

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

概要

ERC3135は、ERC20のような既存のトークン標準に追加機能を加え、オンラインおよびオフラインのサービス提供者が多数のユーザーと簡単にマイクロペイメントチャネル(少額決済のためのチャネル)を構築できる仕組みを提供しています。

マイクロペイメントチャネルでは、ユーザーがオフチェーン(ブロックチェーン外)でトークンの消費に関するメッセージに署名し、サービス提供者はその署名を検証するだけで済むため、ブロックチェーンとのやり取りは最小限に抑えられ、ガス代が節約されてパフォーマンスが向上します。

動機

ERC3135の主な目的は以下の2つです。

  • ブロックチェーンとのやり取り(オンチェーン処理)を減らすこと
  • Ethereumを現実世界の支払い問題に適用できるようにすること

特に、小規模事業者がブロックチェーンベースの支払いシステムを導入したいと考えても、以下のような多くの課題に直面しています。

  • 直接トークンで支払う場合

ユーザーはウォレットを使ってトークンを送ることはできますが、ガス代がかかり、確認に時間がかかる。

  • スマートコントラクトによるマイクロペイメントチャネルを使う場合

トークンをロックしておき、署名メッセージを使って支払い処理をする方法もありますが、事業者がDAppを開発・運用する必要があり、多くの中小企業にとって負担が大きい。 また、ユーザー側にもDApp対応のウォレットや知識が必要です。

ERC3135はこうした課題を解決するために、署名メッセージの標準化を目指しています。 将来的には、ウォレットがこの仕組みに対応した共通のユーザーインターフェース(UI)を提供できるようになることを見据えています。

仕様

ERC3135では、トークンの消費をユーザーの署名によって認証し、トークン発行者(issuer)のみがそれを請求できる仕組みを定めています。 これにより、オフチェーンでのトークン使用とオンチェーンでの最小限の処理が可能となり、ガス代の節約と高速なサービス提供が実現されます。

コア機能

  • ユーザーがトークンをデポジット(預け入れ)し、サービス提供者はその署名に基づいて請求(claim)します。
  • 消費量と署名はオフチェーンで処理され、ブロックチェーンとのやりとりは最小限です。
  • 「前払いモデル(prepayment)」と「ロック・リリースモデル(lock-release)」という2つの運用方式に対応しています。

ECDSA署名による認証

署名は以下のように生成されます。

text
sign(keccak256(abi_encode(
    "\x19Ethereum Signed Message:\n32", 
    keccak256(abi_encode(
        token_address,
        payer_address,
        token_issuer,
        token_consumption,
        epoch
    ))
)), private_key)

この署名は claim 関数で検証され、署名者(payer)と消費量(consumption)の整合性を確保します。

使用モデル

前払いモデル(Prepayment Model)

  • ユーザーはトークンを事前にデポジット。
  • サービス提供者が消費ごとに署名を受け取り、オフチェーンで照合。
  • 最後にユーザーが残高を返金要求した場合、issuerがwithdrawを実行。

ロック・リリースモデル(Lock-Release Model)

  • ユーザーは利用分のトークンをロック。
  • 信用できる限り署名でサービス利用。
  • 信用が損なわれた場合、ユーザーは署名をやめ、未使用のロック分を自身でwithdraw。

インターフェース

js
/// @return Image url of this token or descriptive resources
function iconUrl() external view returns (string memory);

/// @return Issuer of this token. Only issuer can execute claim function
function issuer() external view returns (address);

/**
 *  @notice   Remove consumption from payer's deposite
 *  @dev      Check if msg.sender == issuer
 *  @param    from          Payer's address
 *  @param    consumption   How many token is consumed in this epoch, specified
 *  @param    epoch         Epoch increased by 1 after claim or withdraw, at the beginning of each epoch, consumption goes back to 0
 *  @param    signature     Signature of payment message signed by payer
*/
function claim(address from, uint256 consumption, uint256 epoch, bytes calldata signature) external;

function transferIssuer(address newIssuer) external;

/// @notice   Move amount from payer's token balance to deposite balance to ensure payment is sufficient
function deposit(uint256 amount) external;

/**
 *  @notice   Give remaining deposite balance back to "to" account, act as "refund" function
 *  @dev      In prepayment module, withdraw is executed from issuer account
 *            In lock-release module, withdraw is executed from user account
 *  @param    to            the account receiving remaining deposite
 *  @param    amount        how many token is returned
*/
function withdraw(address to, uint256 amount) external;

function depositBalanceOf(address user) external view returns(uint256 depositBalance, uint256 epoch);

event Deposit(
    address indexed from,
    uint256 amount
);

event Withdraw(
    address indexed to,
    uint256 amount
);
    
event TransferIssuer(
    address indexed oldIssuer,
    address indexed newIssuer
);

event Claim(
    address indexed from,
    address indexed to,
    uint256 epoch,
    uint256 consumption
);

関数

iconUrl
js
function iconUrl() external view returns (string memory);

トークンのアイコンや説明リソースのURLを取得する関数。 この関数は、ユーザーやウォレットがトークンの外部リソース(例:画像URL)を表示するために使用します。

戻り値

  • string memory
    • トークンに関連する画像や情報のURL。
issuer
js
function issuer() external view returns (address);

このトークンの発行者のアドレスを取得する関数。 claim関数の実行者はこのissuerアドレスでなければなりません。 信頼できるサービス提供者の識別に使用されます。

戻り値

  • address
    • 現在のトークン発行者のアドレス。
claim
js
function claim(address from, uint256 consumption, uint256 epoch, bytes calldata signature) external;

署名されたメッセージに基づき、ユーザーのデポジットから消費分を引き出す関数。 この関数はissuerのみが呼び出すことができ、署名によってトークンの消費が認証されます。 epochごとに消費の区切りを管理します。

引数

  • from
    • 消費者(支払者)のアドレス。
  • consumption
    • 今回のエポックにおける消費量。
  • epoch
    • 現在のエポック番号(更新単位)。
  • signature
    • 消費を認証する署名データ。
transferIssuer
js
function transferIssuer(address newIssuer) external;

トークン発行者を別のアドレスに変更する関数。 サービス提供者の交代などのケースで、現在の発行者が次の発行者を指定して移譲できます。

引数

  • newIssuer
    • 新しいトークン発行者のアドレス。
deposit
js
function deposit(uint256 amount) external;

ユーザーがトークンをデポジット(預け入れ)する関数。 支払いの保証のためにトークンを預け入れます。 これにより、サービス提供者は安全にマイクロペイメントを提供できます。

引数

  • amount
    • 預け入れるトークンの量。
withdraw
js
function withdraw(address to, uint256 amount) external;

デポジット残高から残りのトークンを返金する関数。 前払いモデルではissuerが返金処理を行い、ロック・リリースモデルではユーザーが自身で引き出します。

引数

  • to
    • 返金先のアカウントアドレス。
  • amount
    • 返金するトークンの量。
depositBalanceOf
js
function depositBalanceOf(address user) external view returns(uint256 depositBalance, uint256 epoch);

指定したユーザーのデポジット残高と現在のエポックを取得する関数。 サービス提供者やユーザーが、現在の預け入れ状況や支払いの状態を確認するために使用します。

引数

  • user
    • 残高を確認したいアドレス。

戻り値

  • depositBalance
    • 現在の預け入れ残高。
  • epoch
    • 現在のエポック番号。

イベント

Deposit
js
event Deposit(address indexed from, uint256 amount);

トークンがデポジットされた時に発行されるイベント。 ユーザーがdepositを実行した時に、ウォレットやフロントエンドで検出するために使用します。

パラメータ

  • from
    • トークンを預け入れたアドレス。
  • amount
    • 預け入れたトークンの量。
Withdraw
js
event Withdraw(address indexed to, uint256 amount);

トークンが引き出された時に発行されるイベント。 ユーザーやissuerがwithdrawを実行した際の通知用です。

パラメータ

  • to
    • 引き出し先のアドレス。
  • amount
    • 引き出されたトークンの量。
TransferIssuer
js
event TransferIssuer(address indexed oldIssuer, address indexed newIssuer);

発行者の権限が移譲された時に発行されるイベント。 issuerの変更をウォレットや監視システムに通知します。

パラメータ

  • oldIssuer
    • 以前の発行者のアドレス。
  • newIssuer
    • 新しい発行者のアドレス。
Claim
js
event Claim(address indexed from, address indexed to, uint256 epoch, uint256 consumption);

署名された消費がissuerによって請求された時に発行されるイベント。 claim関数の実行時に、消費の証拠としてログに残します。

パラメータ

  • from
    • 支払者のアドレス。
  • to
    • 請求先(発行者)のアドレス。
  • epoch
    • 消費が発生したエポック。
  • consumption
    • 請求された消費量。

補足

ERC3135は、広く普及しているトークン標準であるERC20を対象としています。 しかし、ERC3135は他のトークン標準とも互換性を保てるように設計されています。

ERC3135で提案されている各関数は、別の補助コントラクトではなく、トークンコントラクト本体に実装するという方針を採用しています。 その理由は以下のとおりです。

  • トークンの転送(transfer)機能は、DAppとの直接的なやり取りよりも一般的かつ便利

トークンはウォレットや既存のインフラに自然と統合されており、ユーザーにとって馴染みがあります。

  • トークンコントラクトは標準化されており、UIサポートが豊富

多くのウォレットやサービスがERC20トークンの表示・操作に対応しており、ユーザー体験が統一されやすいです。

  • 「トークン = サービス」という形を明確にしてトークン経済の活性化に貢献

トークン自体がサービス利用権と結びつくことで、分かりやすいユースケースが生まれます。

  • approveプロセス(利用前に許可を出す処理)を省略

通常のERC20ではapprove → transferFromという2段階が必要ですが、ERC3135では署名により直接的な支払いが可能となり、ガス代やUXの面でも優れています。

このように、関数をトークンコントラクトに組み込むことで、標準トークンの利便性と柔軟性を最大限に活かすことができるとされています。

参考実装

js

mapping (address => StampBalance) private _depositBalance;
    
struct StampBalance{
    uint256 balance;
    uint256 epoch;
}
    
function deposit(uint256 value) override external{
    require(value <= _balances[msg.sender]);
    _balances[msg.sender] = _balances[msg.sender].sub(value);
    _depositBalance[msg.sender].balance = _depositBalance[msg.sender].balance.add(value);
    emit Deposit(msg.sender, value);
}

function withdraw(address to, uint256 value) override onlyIssuer external{
    require(value <= _depositBalance[to].balance);
    _depositBalance[to].balance = _depositBalance[to].balance.sub(value);
    _depositBalance[to].epoch += 1;
    _balances[to] = _balances[to].add(value);
    emit Withdraw(to, value);
}
    
function depositBalanceOf(address user) override public view returns(uint256 depositBalance, uint256 epoch){
    return (_depositBalance[user].balance, _depositBalance[user].epoch);
}

// prepayment model
function claim(address from, uint credit, uint epoch, bytes memory signature) override onlyIssuer external{
    require(credit > 0);
    require(_depositBalance[from].epoch + 1 == epoch);
    require(_depositBalance[from].balance >= credit);
    bytes32 message = keccak256(abi.encode(this, from, _issuer, credit, epoch));
    bytes32 msgHash = prefixed(message);
    require(recoverSigner(msgHash, signature) == from);
    _depositBalance[from].balance = _depositBalance[from].balance.sub(credit);
    _balances[_issuer] = _balances[_issuer].add(credit);
    _depositBalance[from].epoch += 1;
    emit Claim(from, msg.sender, credit, epoch);
}

function prefixed(bytes32 hash) internal pure returns (bytes32) {
    return keccak256(abi.encode("\x19Ethereum Signed Message:\n32", hash));
}

function recoverSigner(bytes32 message, bytes memory sig) internal pure  returns (address) {
    (uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
    return ecrecover(message, v, r, s);
}

function splitSignature(bytes memory sig) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
    require(sig.length == 65);
    assembly {
        r := mload(add(sig, 32))
        s := mload(add(sig, 64))
        v := byte(0, mload(add(sig, 96)))
    }
    return (v, r, s);
}

セキュリティ

ERC3135では、claim関数の実行者をトークンの発行者(issuer)に限定することで、オンチェーンにおける同時実行の問題を防いでいます。 つまり、他のユーザーが同時にclaimを呼び出すことによる競合は発生しません。

しかし、オフチェーンでの署名メッセージに関しては「二重支払い(二重消費)」のリスクが存在します。 これは以下のような状況で起こり得ます。

  • 複数の検証サーバ(verifier)を使っているサービス提供者が、同じユーザー(payer)から同時に複数の支払いメッセージを受け取る。
  • それぞれのメッセージが異なる「消費量(consumption)」を持ち、別々のサーバで署名検証が行われる。
  • 本来は「最大の消費量」を示すメッセージだけがclaimされるべきだが、複数のメッセージが一時的に有効と判定されてしまう可能性がある。

このような事態に対処するために、「エコーサーバ(echo server)」の導入が推奨されています。 エコーサーバの役割は以下の通りです。

  • 検証サーバからのメッセージを一元的に受け付ける。
  • 最も大きな消費量(consumption)とエポック番号(epoch)を持つメッセージのみを「最新のメッセージ」として返す。
  • 検証サーバはエコーサーバから返された内容と自身が受け取ったメッセージを比較し、一致しなければ状態をエコーサーバのものに更新。
  • その後、検証サーバはユーザーに対し、改めて正しい署名付きメッセージを要求する。

このようにすることで、検証サーバ間での整合性を保ち、オフチェーンにおける二重請求や署名の悪用を防ぐ仕組みを構築することができます。 エコーサーバの導入は、分散環境における信頼性とセキュリティを高める重要な要素となります。

引用

Zhenyu Sun (@Ungigdu), "ERC-3135: Exclusive Claimable Token [DRAFT]," Ethereum Improvement Proposals, no. 3135, August 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3135.

最後に

今回は「署名した支払いメッセージに基づいて、トークン発行者(issuer)のみがトークンを請求できる仕組みをERC20トークンに組み込み、オフチェーンでのマイクロペイメントを実現する提案しているERC3135」についてまとめてきました! いかがだったでしょうか?

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

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