はじめに

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

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

ERC20のapproveで与えた利用許可は、利用後も所有者が取り消さない限り残ります。 ERC8255は、既存の承認操作を保ちながら、トークンを使える金額へ終了時刻を結び付ける仕組みです。

概要

利用者がアプリへトークンを動かす許可を出す時、使える量だけでなく終了時刻も一緒に記録します。 終了時刻までは許可された量を使え、時刻を過ぎると保存中の量が残っていても使えません。

画面から送る承認と、事前署名から作る承認の両方に同じ終了条件を付けます。 これにより、アプリを使い終えた後まで権限が残る期間を狭めます。

ERC8255で所有者が期限付き承認を作り利用先が期限内だけ利用する流れ

承認額と期限を一緒に記録する

ERC20の承認は、所有者、利用先、利用可能額の組で管理されます。 ERC8255は、この組へexpirationを加えます。

expirationは、利用許可が有効である最後のブロック時刻です。 例えば、所有者が利用先へ100トークンを時刻1,003,600まで許可した場合、トークンコントラクトは100という金額と1,003,600という期限を結び付けて保存します。 利用先は、期限と残額の両方を満たす範囲だけ消費できます。

期限後は利用可能額をゼロとして扱う

期限が来るたびに、保存データを削除するトランザクションを実行するわけではありません。 この時、所有者が利用先へ移動を許した残額をallowanceと呼びます。

トークンコントラクトは、残額を読み取る時や利用先がトークンを動かす時に、保存した期限と現在時刻を比較します。 期限切れなら、保存中の金額が残っていても利用可能額をゼロとして処理します。

署名による承認にも期限を付ける

所有者がチェーンへ直接承認を送る方法だけでなく、事前に署名し、別の送信者へ提出してもらう方法にも終了時刻を付けます。 署名を提出できる期限と、成立後の利用許可が終わる時刻は別に管理します。

動機

一般的なERC20の利用許可には終了時刻がありません。 過去に使ったアプリのコントラクトが侵害された場合、利用者のウォレットに残った承認が攻撃に使われる可能性があります。

期限を追加するだけなら新しい関数だけでも実現できます。 しかし、従来のapproveで作った承認が無期限のままでは、既存ウォレットを使う利用者に期限保護が届きません。

永続承認と期限付き承認で攻撃可能期間がどう変わるかの比較

利用後も承認が残り続ける

ERC20のapproveで設定した利用可能額は、transferFromで使い切るか、所有者が新しい値へ更新するまで残ります。 アプリの利用を終えても、承認が自動で消えることはありません。

ERC20は所有者が利用先へ利用可能額を与える仕組みです。 ERC8255では同じ呼び出しへ失効時刻の規則が加わります。

特にuint256型の最大値を指定する無制限承認は、何度利用しても残額が減らない実装が一般的です。 連携先の侵害、悪意あるアップグレード、偽のフロントエンドが後から発生すると、古い承認が長期間の攻撃面になります。

ERC20については以下の記事を参考にしてください。

既存の呼び出し方を維持する

ERC8255対応トークンでも、ウォレットやアプリは従来どおりapprove、allowance、transferFromを呼べます。 呼び出し形式を変えずに、トークン内部の保存値と期限判定を変更します。

通常のapproveにはトークンが定めた最大期間が使われます。 より短い期間を選びたいアプリは、追加関数のapproveForDurationを呼びます。

仕様

ERC8255の追加インターフェースと、既存ERC20関数へ加わる期限の意味を分けて確認します。 規格は草案段階であり、導入時には最新版も確認してください。

IERC8255

IERC8255は、1つのイベントと3つの関数を追加します。 既存のapprove、allowance、transferFromはインターフェース上の形を保ったまま、期限付きの意味へ変わります。

ApprovalExpiration

js
event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration);

所有者と利用先の組へ期限を設定する時に発行するイベントです。 所有者、利用先、設定後の終了時刻をイベントログへ記録します。

  • フィールド
フィールド 詳細
owner トークンの所有者を示すインデックス付きアドレスです。
spender 利用を許可されたアドレスを示すインデックス付きアドレスです。
expiration 承認の終了時刻です。

Approvalの発行は必須ですが、ApprovalExpirationの発行は任意です。 イベントログは現在状態の正本ではなく、時間経過だけの期限切れでも発行されないため、アプリは読み取り関数で現在値を確認します。

参考実装では、承認額と期限を保存した後にApprovalとApprovalExpirationを発行します。 この順序は参考実装の選択であり、ApprovalExpirationの発行を規格全体の必須要件にはしません。

maxApprovalDuration

js
function maxApprovalDuration() external pure returns (uint32);

トークンが許可する承認期間の最大値を秒数で返す関数です。 規格は具体的な期間を固定せず、トークン実装がuint32の範囲で選びます。

  1. 最大期間を取得

    トークンが定めた最大期間を取得します。

  2. 既定期間へ適用

    approveやpermitでは、その値を既定期間として使います。

  3. 指定期間を検証

    approveForDurationでは、指定期間が最大値以下か確認します。

  • 戻り値
戻り値 詳細
最大期間 トークンが許可する最大durationです。
  • 実行条件

  • どのアドレスからでも読み取れます。

  • 仕様上、正の下限はありません。

  • 値がゼロなら承認は極めて短くなり、uint32の最大値なら約136年になります。

allowanceAndExpiration

js
function allowanceAndExpiration(address owner, address spender) external view returns (uint64 expiration, uint256 allowance);

所有者と利用先の組に保存された期限と金額を返す関数です。 通常の利用先では、期限切れ後も保存値を確認できます。

  1. 承認情報を読み取り

    ownerとspenderに対応する承認情報を読みます。

  2. 保存値を復元

    保存形式からexpirationとallowanceを復元します。

  3. 互換値へ調整

    旧式の利用先に対する例外が有効なら、実装が定めた互換値へ調整します。

  4. 保存値を返却

    期限と保存額を返します。

  • 引数
引数 詳細
owner 承認を作った所有者です。
spender 利用を許可されたアドレスです。
  • 戻り値
戻り値 詳細
expiration 保存された承認期限です。
allowance 保存された承認額です。
  • 実行条件

  • どのアドレスからでも読み取れます。

  • 通常の利用先では、期限切れを理由に保存額をゼロへ変換しません。

  • 現在の利用可能額だけが必要な場合はallowanceを使うか、返された期限を現在時刻と比較します。

approveForDuration

js
function approveForDuration(address spender, uint256 amount, uint32 duration) external returns (bool success);

利用先、金額、期間を指定して期限付き承認を作る関数です。 指定期間がmaxApprovalDuration()を超える場合は、revertまたはfalseで失敗します。

  1. 期間を検証

    durationがトークンの最大期間以下か検証します。

  2. 金額を検証

    amountが実装の保存形式で表現できるか検証します。

  3. 期限を計算

    非ゼロ承認ならblock.timestamp + durationを期限にします。

  4. 承認を保存

    承認額と期限を保存し、必要なイベントを発行します。

  5. 成功を返却

    成功した場合はtrueを返します。

  • 引数
引数 詳細
spender 利用を許可するアドレスです。
amount 利用を許可する金額です。
duration 現在時刻から期限までの秒数です。
  • 戻り値
戻り値 詳細
success 承認が成功した場合はtrueです。
  • 実行条件

  • トークン所有者が自身の承認として呼び出します。

  • durationはmaxApprovalDuration()以下にします。

  • 呼び出し側はrevertだけでなく、falseも失敗として処理します。

  • falseを返す独自実装の内部状態について、規格は追加の保証を置いていません。

承認期限の判定

承認が期限切れになる条件は、expiration < block.timestampです。 等号を含まないため、expiration == block.timestampの時点ではまだ有効です。

期限の直前、同値、1秒後における承認の有効性

approveとpermitの既定期限

非ゼロ金額のapproveが成功すると、期限は以下の式で決まります。

js
expiration = block.timestamp + maxApprovalDuration();

ERC8255対応トークンのpermitにも同じ既定期間が使われます。 例えば、時刻1,000,000でpermitが成功し、最大期間が86,400秒なら、承認期限は1,086,400です。

permitのdeadlineは署名をいつまで提出できるかを決めます。 成功後のallowanceにはERC8255の既定期限が別に設定されます。

そのため、署名のdeadlineが1,200,000でも、承認期限は1,200,000になりません。 deadlineは署名を提出できる時刻、expirationは成立した承認を使える時刻です。

ERC2612については以下の記事を参考にしてください。

durationがゼロの承認

approveForDurationへゼロを渡すと、期限は承認時のblock.timestampと同じです。 期限判定が<であるため、同じブロック時刻の処理では有効になり得ます。

後続のブロックでもブロック時刻が同じなら、そこでも有効です。 時刻が1秒でも進めば期限切れになるため、同一ブロックだけを保証する仕組みとは扱えません。

ゼロ金額と最大金額

amount == 0は承認の解除です。 利用可能額は必ずゼロになり、実装は期限もゼロへ消去することが推奨されています。

uint256型の最大値は、期限内に減算しない最大承認として特別に扱えます。 ただし無期限になるわけではなく、expiration < block.timestampになれば利用可能額はゼロです。

利用可能額の計算

保存額と現在の利用可能額は同じとは限りません。 期限切れ後も保存値を残し、読み取りや消費の経路で有効性を判定できるためです。

ストレージに残る承認額と、現在時刻に実際に使える承認額を区別します。 保存額が100でも、期限後に実際に使える承認額は0です。

allowanceの返却値

通常の利用先に対するallowanceは、以下の条件で値を切り替えます。

条件 返す値
expiration >= block.timestamp 保存された金額
expiration < block.timestamp 0
旧式の利用先に対する例外が有効 実装の互換規則に基づく金額

期限切れは読み取りエラーではありません。 保存値を知りたい場合はallowanceAndExpiration、現在使える額を知りたい場合はallowanceと読み分けます。

transferFromの消費

transferFromは期限判定後の利用可能額から送金額を消費します。 期限切れなら利用可能額がゼロのため、非ゼロの送金は失敗します。

通常金額の一部を消費した場合は、残額だけを減らし、元のexpirationを維持します。 全額を消費した時にストレージ領域を消す実装では、期限もゼロになる場合があります。

uint256型の最大値を最大承認として扱う実装は、期限内の消費で金額を減らさなくても構いません。 この例外にも期限判定は適用されます。

旧式の利用先との互換処理

期限を理解しない既存の利用先向けに、期限を無視する互換処理を設けられます。 この仕組みは任意であり、既定では無効にします。

管理者が対象を指定する方式、利用先が自身を指定・解除する方式、その両方を組み合わせる方式を選べます。 登録インターフェースとアクセス制御は規格の範囲外です。

互換対象になった利用先に対して、allowanceAndExpirationが現在のブロック時刻を期限として返す実装も認められます。 これは保存済みの期限を書き換える処理ではありません。

互換指定を解除すると、保存済みの期限による通常判定へ戻ります。 保存期限がすでに現在時刻より前なら、allowanceはゼロを返します。

イベントログ

承認の更新時には、ERC20のApprovalイベントを発行します。 ApprovalExpirationは期限を追跡しやすくする補助イベントですが、規格上は任意です。

時計が進んだだけの期限切れでは、イベントが発行されません。 transferFrom後の残額をイベントだけで再構築できるかも、実装のイベント運用に依存します。

インデクサーはイベントを変更通知として使い、現在値はallowanceAndExpirationと現在時刻から再同期します。 コントラクトのストレージとイベントログを同じ正本として扱わないことが重要です。

1つのストレージ領域へ保存

規格は、1つの256ビット領域へ期限と金額をまとめる任意の保存方法を示しています。 上位64ビットへexpiration、下位192ビットへamountを配置します。

256ビットの上位64ビットへ期限、下位192ビットへ承認額を保存する構造

text
255                              192 191                                  0
+----------------------------------+---------------------------------------+
|       expiration: 64 bits        |            amount: 192 bits           |
+----------------------------------+---------------------------------------+

保存値は以下の式で作れます。

js
packed = (uint256(expiration) << 192) | storedAmount;

192ビットに収まらない通常金額は、別の保存表現を用意できます。 別表現を持たない圧縮保存の実装では、表現できない中間値を拒否することが推奨されています。

uint256型の最大値だけは、下位192ビットがすべて1の予約値へ変換できます。 圧縮保存は実装例であり、ERC8255が特定のストレージレイアウトや具体的なガス代削減量を保証するわけではありません。

関連方式との使い分け

期限付き承認以外にも、残存権限を減らす方法があります。 必要な利用期間、対応トークン、信頼するコントラクトの位置で選択肢が変わります。

permitへdurationを追加しない理由

ERC2612のpermitへdurationを追加すると、署名型と関数識別値が変わります。 既存ウォレットや署名ライブラリとの互換性を保つため、ERC8255は既存形式を維持します。

署名、nonce、deadlineの検証に成功した後、トークン側のmaxApprovalDuration()を承認期間に使います。 利用者がpermitごとに短い期間を選ぶ機能は、この互換処理にはありません。

承認を残さない方式との比較

ERC7674は承認を同一トランザクション内に閉じます。 複数トランザクションで使う短期権限にはERC8255の期間指定を使います。

ERC3009は期限付き署名から1回のトークン送金を実行します。 継続的な利用許可を作らないため承認残額がありません。

EIP5792はウォレットへ複数の呼び出しをまとめて送るインターフェースです。 アトミック実行に対応する場合は承認と利用と取消しを1つの実行へまとめられます。

EIP7702は外部所有アカウント(EOA)が別のコードへ実行を委ねる基盤です。 一括実行機能は委任先コードが実装し、EIP7702自体がウォレット向けの呼び出し形式を定めるわけではありません。

一括実行で残額を避けるには、正確な利用額だけを承認するか、最後にapprove(spender, 0)を含めます。 複数トランザクションにまたがる利用窓が必要なら、期限まで承認を維持できるERC8255が候補になります。

ERC7674については以下の記事を参考にしてください。

EIP5792については以下の記事を参考にしてください。

EIP7702については以下の記事を参考にしてください。

ERC3009については以下の記事を参考にしてください。

Permit2との比較

Permit2は、既存トークンから共有コントラクトへの事前承認を土台にします。 その共有コントラクトが期限付きの利用許可や1回限りの署名送金を管理します。

ERC8255ではトークン自身が、期限切れの利用可能額をゼロとして返し、非ゼロの送金を失敗させます。 既存トークンへ後から導入しやすいPermit2と、トークンの権限モデル自体を変えるERC8255では、信頼するコントラクトと導入位置が異なります。

ERC5827との比較

ERC5827Expirableは回復型の利用可能額に絶対時刻の期限を加えます。 通常のERC20承認を既定で期限付きにするERC8255とは更新規則が異なります。

ERC8255の通常承認は、消費した分だけ残額が減り、時間で回復しません。 一定期間に一定量を繰り返し使える上限ならERC5827、通常の承認を一定時刻で止めるならERC8255という違いがあります。

ERC5827については以下の記事を参考にしてください。

互換性

ERC8255は既存関数の識別値を維持しますが、期限後の結果は変わります。 呼び出し形式の互換性と挙動の互換性を分けて確認します。

既存のERC20呼び出し

既存ウォレットは従来のapproveを呼べます。 既存アプリもallowanceとtransferFromを同じ呼び出し形式で使えます。

一方、承認が長期間残る前提のプロトコルでは、後日のtransferFromが期限切れで失敗します。 アプリは失敗を異常なトークン動作と決め付けず、現在値を読み直して再承認へ案内します。

既存ストレージの移行

アップグレード可能なトークンの旧承認額には期限情報がありません。 そのまま圧縮形式として読む場合、uint192型の最大値未満の旧値はexpiration == 0の期限切れ承認として解釈できます。

この時、allowanceはゼロでも、allowanceAndExpirationから旧保存額を確認できます。 旧ストレージ領域を予備の読み取り元として残すなら、新しい承認、消費、解除のいずれかで旧値を消去するか、以後無視します。

uint192型の最大値以上の旧値は、下位192ビットと上位64ビットへ分かれて別の意味になります。 期限切れ扱い、拒否、明示的な再配置、最大承認の予約値への変換のいずれかを移行仕様として固定します。

旧uint256型の最大値を遠い未来の期限として自動受理すると、意図しない永続承認を作ります。 revertまたは明示的な移行処理で扱います。

草案段階で残る導入判断

maxApprovalDurationの具体値、旧式の利用先一覧の管理権限、標準的な対応検出方法は規格で固定されていません。 最大期間にゼロや約136年を選べるため、期限保護の実効性はトークンごとに異なります。

2026年10月時点で、OpenZeppelinとSoladyの提案実装は未マージのプルリクエストです。 確定リリースやウォレット横断の採用を前提にせず、草案の変更と利用する実装の監査状況を確認します。

アプリへの統合

ERC8255はERC165のような必須の対応検出方法を定めていません。 関数を呼べたことと、ERC8255の意味を正しく実装していることを分けて扱います。

対応可否を確認する

アプリは、トークンの公式情報、検証済みソースコードやバイトコード、管理する許可リストから対応状況を確定します。 maxApprovalDurationの読み取り呼び出しが成功したことは、呼び出し形式をデコードできるという補助情報に留めます。

同じ関数識別値を偶然持つコントラクトや、異なる意味で値を返すコントラクトは読み取り呼び出しだけでは除外できません。 対応確認が取れないトークンへapproveForDurationを前提とした画面を出さない設計にします。

期限を選んで承認する

最初にmaxApprovalDuration()を読み、アプリの処理時間と想定されるメンプール遅延を含む期間を選びます。 短すぎる期間は失敗を増やし、長すぎる期間は放置承認の危険を長く残します。

コントラクトから呼び出す場合は、戻り値も確認します。

js
interface IERC8255Like {
    function maxApprovalDuration() external view returns (uint32);
    function approveForDuration(address spender, uint256 amount, uint32 duration)
        external
        returns (bool success);
    function allowanceAndExpiration(address owner, address spender)
        external
        view
        returns (uint64 expiration, uint256 allowance);
}

function approveWithCheckedDuration(
    IERC8255Like token,
    address spender,
    uint256 amount,
    uint32 duration
) external returns (uint64 expiration) {
    require(duration <= token.maxApprovalDuration(), "duration too long");
    require(token.approveForDuration(spender, amount, duration), "approval failed");

    uint256 storedAmount;
    (expiration, storedAmount) = token.allowanceAndExpiration(address(this), spender);
    require(storedAmount == amount, "unexpected allowance");
}

この例は、上限を先に確認し、revertとfalseの両方を失敗として扱います。 最後に保存値を読み直し、意図した金額が記録されたことを確認します。

期限切れから回復する

送信前にallowanceが十分でも、実行されるブロックでは期限切れになっている場合があります。 transferFromが失敗したら、保存額だけを見て同じ処理を繰り返しません。

allowanceで現在の利用可能額を確認し、allowanceAndExpirationで保存値と期限を確認します。 期限切れなら、所有者へ新しい期間の承認を求め、承認成功後に元の操作を再送します。

テスト

期限付き承認は、境界時刻と保存表現の組み合わせで挙動が変わります。 規格上許される複数の失敗方法と、特定実装の挙動を分けて検証します。

期限の境界

最低限、以下の時刻を固定して確認します。

ケース 期待結果
block.timestamp < expiration 保存額が利用可能
block.timestamp == expiration 保存額が利用可能
block.timestamp == expiration + 1 利用可能額はゼロ
duration == 0かつ同じブロック時刻 利用可能になり得る
duration == maxApprovalDuration() 成功
duration > maxApprovalDuration() revertまたはfalse

テストフレームワークでブロック時刻を明示し、ブロック生成による偶然の時刻変化を避けます。 上限超過について、規格テストではrevertとfalseの両方を許し、参考実装のテストではrevertを期待します。

金額の消費と最大値

通常金額では、部分消費後の残額と期限が維持されることを確認します。 全額消費後は、実装がストレージ領域を保持するか消去するかに合わせ、金額ゼロと期限の扱いを検証します。

圧縮保存する実装では、以下の境界を別々にテストします。

金額 参考実装での扱い
0 金額と期限をゼロにして解除
uint192型の最大値 - 1 通常金額として保存
uint192型の最大値 予約値と衝突するためrevert
uint192型の最大値 + 1 別表現がないためrevert
uint256型の最大値 予約値へ変換して保存

最大承認額でも期限後にallowanceがゼロになることを確認します。 減算を省く挙動と無期限承認を混同しないためです。

permitと旧式の利用先

permitでは、署名のdeadlineと承認のexpirationを異なる値にして独立性を確認します。 署名、nonce、deadlineの検証後に、成立時刻から既定期間が設定されることをテストします。

旧式の利用先を実装する場合は、管理者指定と自己指定を分けます。 指定中の返却値、保存期限が変わっていないこと、指定解除後に元の期限判定へ戻ることを確認します。

参考実装

以下は、ERC8255仕様に掲載された期限付き利用許可のコア実装です。 残高、総供給、transfer、transferFrom本体、permitの署名検証、旧式の利用先一覧は含まれないため、完成したERC20としてそのまま使うコードではありません。

3つの承認入口から検証、圧縮保存、イベント発行までの処理の流れ

js
abstract contract ERC20ExpiringApprovals {
    uint32 internal constant _MAX_APPROVAL_DURATION = 86400;
    uint256 internal constant _AMOUNT_MASK = (uint256(1) << 192) - 1;
    uint256 internal constant _MAX_AMOUNT_SENTINEL = _AMOUNT_MASK;

    mapping(address owner => mapping(address spender => uint256 packed)) internal _allowances;

    event Approval(address indexed owner, address indexed spender, uint256 value);
    event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration);

    function maxApprovalDuration() public pure returns (uint32) {
        return _MAX_APPROVAL_DURATION;
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        (uint64 expiration, uint256 amount) = allowanceAndExpiration(owner, spender);
        return expiration < block.timestamp ? 0 : amount;
    }

    function allowanceAndExpiration(address owner, address spender)
        public
        view
        returns (uint64 expiration, uint256 amount)
    {
        uint256 packed = _allowances[owner][spender];
        expiration = uint64(packed >> 192);
        amount = packed & _AMOUNT_MASK;

        if (amount == 0) {
            return (0, 0);
        }

        if (amount == _MAX_AMOUNT_SENTINEL) {
            amount = type(uint256).max;
        }
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        _approve(msg.sender, spender, amount, maxApprovalDuration());
        return true;
    }

    function approveForDuration(address spender, uint256 amount, uint32 duration) public returns (bool) {
        _approve(msg.sender, spender, amount, duration);
        return true;
    }

    // ERC2612 implementations call this after validating the permit signature and nonce.
    function _approveWithDefaultDuration(address owner, address spender, uint256 amount) internal {
        _approve(owner, spender, amount, maxApprovalDuration());
    }

    function _approve(address owner, address spender, uint256 amount, uint32 duration) internal {
        require(duration <= maxApprovalDuration(), "duration exceeds maximum");
        require(
            amount < type(uint192).max || amount == type(uint256).max,
            "unsupported allowance"
        );

        uint256 expirationValue = amount == 0 ? 0 : block.timestamp + duration;
        require(expirationValue <= type(uint64).max, "expiration exceeds 64 bits");

        uint64 expiration = uint64(expirationValue);
        uint256 storedAmount = amount == type(uint256).max ? _MAX_AMOUNT_SENTINEL : amount;
        _allowances[owner][spender] = (uint256(expiration) << 192) | storedAmount;

        emit Approval(owner, spender, amount);
        emit ApprovalExpiration(owner, spender, expiration);
    }
}

保存形式と最大期間

最初のブロックは、1つのストレージ領域へ保存する形式と、既定の最大期間を定義します。

js
uint32 internal constant _MAX_APPROVAL_DURATION = 86400;
uint256 internal constant _AMOUNT_MASK = (uint256(1) << 192) - 1;
uint256 internal constant _MAX_AMOUNT_SENTINEL = _AMOUNT_MASK;

mapping(address owner => mapping(address spender => uint256 packed)) internal _allowances;

function maxApprovalDuration() public pure returns (uint32) {
    return _MAX_APPROVAL_DURATION;
}

_AMOUNT_MASKは、1を192ビット左へ移動してから1を引いた値です。 下位192ビットがすべて1になり、圧縮値から金額部分だけを取り出せます。

同じ値を_MAX_AMOUNT_SENTINELにも使います。 このため、通常金額のuint192型の最大値は最大承認の印と衝突し、参考実装では保存できません。

_allowances[owner][spender]の1つのuint256へ、上位64ビットの期限と下位192ビットの金額を入れます。 所有者A、利用先B、金額100、期限1,003,600なら、保存値は以下の形です。

js
(uint256(1_003_600) << 192) | 100

maxApprovalDuration()はストレージを読まず、86,400秒を返します。 approveとpermit用の内部関数はこの値を既定期間に使い、共通処理の_approveは期間上限の検証に使います。

24時間という期間と圧縮レイアウトは参考実装固有の選択です。 この宣言とpure関数はストレージを変更せず、イベントも発行しません。

保存値と利用可能額の読み取り

読み取りでは、保存値を復元するallowanceAndExpirationを先に実行します。 続いて、復元した期限と金額からallowanceが現在の利用可能額を決めます。

js
function allowanceAndExpiration(address owner, address spender)
    public
    view
    returns (uint64 expiration, uint256 amount)
{
    uint256 packed = _allowances[owner][spender];
    expiration = uint64(packed >> 192);
    amount = packed & _AMOUNT_MASK;

    if (amount == 0) {
        return (0, 0);
    }

    if (amount == _MAX_AMOUNT_SENTINEL) {
        amount = type(uint256).max;
    }
}

function allowance(address owner, address spender) public view returns (uint256) {
    (uint64 expiration, uint256 amount) = allowanceAndExpiration(owner, spender);
    return expiration < block.timestamp ? 0 : amount;
}

まずmappingから圧縮値を1つ読みます。 packed >> 192で上位64ビットを右端へ移し、uint64へ変換して期限を復元します。

packed & _AMOUNT_MASKは上位64ビットを消し、下位192ビットの金額だけを残します。 復元した金額がゼロなら、期限が残っていた場合でも返却値を(0, 0)へ正規化します。

金額が予約値なら、外部へ返す時だけuint256型の最大値へ展開します。 ストレージ上の192ビット値と外部へ返す256ビット値を分ける処理です。

圧縮値が(uint256(1_003_600) << 192) | 100なら、allowanceAndExpirationは期限1,003,600と金額100を返します。 現在時刻が1,003,600なら比較は偽なので、allowanceも100を返します。

現在時刻が1,003,601になると、expiration < block.timestampが真になり、allowanceはゼロを返します。 圧縮値は書き換わらないため、allowanceAndExpirationは引き続き1,003,600と100を返します。

どちらもview関数であり、期限切れを理由にrevertしません。 ストレージの変更もイベント発行もありません。

承認を作る3つの入口

承認を作る入口は、通常のapprove、期間指定のapproveForDuration、permit成功後に使う内部関数の3つです。 3つとも最終的に_approveへ所有者、利用先、金額、期間を渡します。

js
function approve(address spender, uint256 amount) public returns (bool) {
    _approve(msg.sender, spender, amount, maxApprovalDuration());
    return true;
}

function approveForDuration(address spender, uint256 amount, uint32 duration) public returns (bool) {
    _approve(msg.sender, spender, amount, duration);
    return true;
}

// ERC2612 implementations call this after validating the permit signature and nonce.
function _approveWithDefaultDuration(address owner, address spender, uint256 amount) internal {
    _approve(owner, spender, amount, maxApprovalDuration());
}

approveはmsg.senderを所有者にし、86,400秒を渡します。 approveForDurationも所有者はmsg.senderですが、呼び出し側が指定したdurationを渡します。

permit用の内部関数は、署名から復元して検証済みの所有者アドレスを受け取り、86,400秒を渡します。 この内部関数自身は署名、deadline、nonceを検証しません。

時刻1,000,000、金額100の場合、既定期間の入口は期限1,086,400を作ります。 duration == 3_600なら期限は1,003,600です。

下流の_approveで検証に失敗するとrevertが各入口まで伝播します。 古いストレージ領域は残り、イベントは発行されず、公開2関数はtrueへ到達しません。

検証からイベント発行まで

_approveは、入力検証、期限計算、圧縮保存、イベント発行を1つの順序で実行します。

js
event Approval(address indexed owner, address indexed spender, uint256 value);
event ApprovalExpiration(address indexed owner, address indexed spender, uint64 expiration);

function _approve(address owner, address spender, uint256 amount, uint32 duration) internal {
    require(duration <= maxApprovalDuration(), "duration exceeds maximum");
    require(
        amount < type(uint192).max || amount == type(uint256).max,
        "unsupported allowance"
    );

    uint256 expirationValue = amount == 0 ? 0 : block.timestamp + duration;
    require(expirationValue <= type(uint64).max, "expiration exceeds 64 bits");

    uint64 expiration = uint64(expirationValue);
    uint256 storedAmount = amount == type(uint256).max ? _MAX_AMOUNT_SENTINEL : amount;
    _allowances[owner][spender] = (uint256(expiration) << 192) | storedAmount;

    emit Approval(owner, spender, amount);
    emit ApprovalExpiration(owner, spender, expiration);
}

最初のrequireは、指定期間が最大期間以下であることを確認します。 2つ目のrequireは、金額が192ビット未満か、特別扱いするuint256型の最大値であることを確認します。

金額がゼロなら期限もゼロです。 非ゼロなら現在時刻へ期間を足し、結果がuint64へ収まることを3つ目のrequireで確認します。

すべての検証後にuint64へ型変換します。 先に型変換すると上位ビットが切り捨てられるため、検証と変換の順序が重要です。

uint256型の最大値だけを保存用の予約値へ変換し、期限を192ビット左へ移動して金額とビット論理和で結合します。 時刻1,000,000、金額100、期間3,600なら、expiration == 1_003_600、storedAmount == 100です。

境界値の結果は以下のとおりです。

amount 検証結果 保存内容
0 成功 expiration == 0、storedAmount == 0
uint192型の最大値 失敗 予約値との衝突を防ぐ
uint192型の最大値 + 1 失敗 192ビットへ収まらない
uint256型の最大値 成功 _MAX_AMOUNT_SENTINELへ変換

保存後、Approvalには呼び出し側が渡した元のamountを記録します。 ApprovalExpirationには、uint64へ変換した期限を記録します。

3つのrequireはストレージ更新より前にあります。 どれかが失敗すると古いストレージ領域が残り、2つのイベントは発行されません。

時間経過だけで期限切れになった時もイベントは発行されません。 また、参考実装はApprovalExpirationを毎回発行しますが、規格上、このイベント自体は任意です。

セキュリティ

期限は放置承認が悪用される期間を狭めます。 期限内の悪用、ERC20由来の競合、互換例外、時刻依存は残るため、別々に対処します。

承認額の変更競合

非ゼロの承認額を別の非ゼロ値へ変更する時、利用先が変更前後の両方を使う競合は残ります。 期限を追加しても、このERC20由来の競合は解消されません。

この承認変更競合は、所有者が旧額から新額への変更トランザクションを送信し、利用先がその確定前に旧額を消費できる時に成立します。 その後に新額の承認が確定すると、所有者が想定した変更量を超えてトークンを移動される可能性があります。

画面では、現在の承認をゼロへ変更し、その確定後に新しい値を設定する手順を選べます。 一連の操作をアトミックにできない場合は、競合が起こり得ることと、処理中に利用先を信頼することを明示します。

短い期限は旧承認を使える時間を制限しますが、期限内の承認変更競合は残ります。 ERC8255は競合そのものを解消せず、利用先が変更前に使った金額を取り戻す機能も提供しません。

期限切れとトランザクション遅延

アプリが送信前に有効な承認を確認しても、ブロックへ取り込まれる前に期限が過ぎる場合があります。 公開メンプール、混雑、遅延実行では時間差が大きくなります。

短い期間を選び、期限に近い時刻でtransferFromを送ることが前提になると、作成時に有効でも実行時に失効します。 実行時に期限切れになると、transferFromと同じトランザクション内の後続処理もrevertします。 この時はトランザクション送信者がガス代を負担し、誰が再送するかはアプリの構成に依存します。

期間には想定する処理遅延、ウォレット確認時間、ブロック取り込みまでの余裕を含めます。 送信直前にもallowanceを読み、期限までの残り時間が基準未満なら先に再承認を求めます。

それでもブロック取り込みの時刻はアプリが保証できません。 失敗時は利用可能額と期限を再読し、再承認から元の操作へ戻れる回復経路を用意します。

旧式の利用先に対する例外

旧式の利用先に対する互換処理は、選んだ利用先に対して期限保護を後退させます。 対象が侵害されれば、通常の永続承認と同じ危険が戻ります。

旧式互換として指定された利用先は、保存済みの期限を過ぎても承認額を使える場合があります。 対象を広く登録するか、登録権限が侵害されると、期限による被害期間の制限が失われます。

例外は既定で無効とし、明示的なアクセス制御で登録権限を限定します。 対象、登録理由、解除条件、変更履歴を監視し、互換性が不要になった利用先は一覧から外します。

利用先自身による指定を許す場合は、利用者が例外状態を確認できる手段も用意します。 具体的な登録インターフェースと権限モデルは規格外であるため、ERC8255対応という表示だけでは例外運用の安全性を保証できません。

ストレージ圧縮と時刻

金額と期限を狭い型へ変換する前に、範囲を検証します。 検証なしの型変換は上位ビットを切り捨て、意図しない金額や期限を保存します。

金額を192ビットへ、期限をuint64へ変換する前の上限確認を省くと切り捨てが起きます。 期限が短い値へ折り返せば承認がすぐ失効し、金額が小さい値へ折り返せばアプリの期待と保存値が一致しません。

保存形式へ変換する前に金額の表現範囲、予約値との衝突、加算後の期限がuint64へ収まることを検証します。 別表現を持たない実装は、保存できない金額を黙って切り詰めずrevertします。

期限判定はblock.timestampへ依存します。 ブロック生成者が合意規則の範囲でブロック時刻へ影響できるため、数秒単位の厳密な実時間保証には使いません。 短い期間では時刻差の影響が相対的に大きくなるため、利用者が期待する処理時間に余裕を足します。

最後に

ERC8255は既存のERC20呼び出しを保ちながら、利用許可へ終了条件を加えます。 導入時は期限の等号境界、permitに存在する2種類の時刻、旧式の利用先に対する例外、既存ストレージの移行を分けて確認します。 草案段階の規格であるため、実装時には仕様とライブラリの更新状況も追跡します。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!