はじめに

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

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

今回は「ERC-8023」についてまとめていきます。

ERC-8023は、コントラクトの所有権移転を initiate、confirm、accept の3段階に分ける提案です。 単発の transferOwnership() ではなく、待機時間と再確認をはさむことで、誤送信や鍵漏洩時の被害を抑えやすくします。

概要

ERC-8023は、スマートコントラクトの owner を新しいアドレスへ移す操作を、すぐに完了させないためのインターフェースです。 現在の owner が移転を開始し、バッファ期間の後に同じ移転先を確認し、最後に新しい owner が受け入れることで所有権が移ります。

従来の transferOwnership() では、指定したアドレスが間違っていても、その呼び出しで所有権が移ってしまいます。 ERC-8023では、移転先アドレスを2回指定させ、さらに最後は移転先本人に受け入れさせます。 これにより、アドレスポイズニングのような誤指定や、秘密鍵が漏れた直後の即時移転に対して、確認と取り消しの余地を作ります。

ERC-173は、コントラクトの owner() と OwnershipTransferred を定義する所有権管理の基本規格です。 ERC-8023は owner() をそのまま使うため、既存の所有権確認フローと組み合わせやすい設計になっています。

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

従来方式との違いは以下です。

ERC-8023 と従来方式の比較

左側の従来方式では、transferOwnership() の1回の呼び出しで owner が変わります。 右側のERC-8023では、移転先の記録、待機、再確認、移転先本人による受け入れを分けています。

所有権移転の正常な流れは以下です。

ERC-8023 の所有権移転フロー

重要なのは、acceptOwnershipTransfer() が実行されるまで owner() が返すアドレスは変わらない点です。 preConfirmedOwner() や pendingOwner() は移転手続きの状態を示すだけで、管理権限を持ちません。

動機

スマートコントラクトの owner は、アップグレード、管理設定、資金移動、緊急停止などの強い権限を持ちます。 DeFiプロトコル、スマートコントラクトウォレット、オンチェーンユーティリティでは、この owner の安全性がシステム全体の安全性に直結します。

単発の transferOwnership() は実装しやすい一方で、操作ミスに弱いです。 移転先アドレスを1回間違えるだけで、管理権限が意図しないアドレスへ移ります。 また、owner の秘密鍵が漏れた時、攻撃者がすぐに所有権を奪える設計では、防御側が資金退避や設定変更を行う時間を取りにくくなります。

ERC-8023は、所有権移転に確認の段階を増やします。 現在の owner は、まず移転を開始し、バッファ期間の後に同じ newOwner を再度指定して確認します。 移転先が間違っていれば、cancelPendingOwnershipTransfer() で手続きを取り消し、最初からやり直せます。

この提案が重視している目的は大きく4つです。

  • 操作ミスの確率を下げる。
  • 移転先アドレスをオンチェーンで確認する運用を促す。
  • 移転処理の途中で取り消せるようにする。
  • 所有権管理の仕組みを複雑にしすぎない。

特にバッファ期間は、鍵漏洩時の防御時間として機能します。 攻撃者が initiateOwnershipTransfer() を実行しても、すぐに confirmOwnershipTransfer() へ進めないため、防御側の owner は同じ期間内に再度 initiateOwnershipTransfer() を実行して移転先を上書きできます。

鍵漏洩時の時間差は以下です。

ERC-8023 の鍵漏洩時バッファ

攻撃者が悪意ある newOwner を指定しても、バッファ期間があるため即時には確認まで進めません。 防御側の owner は、その間に移転先の上書き、資金退避、設定変更などを行えます。

仕様

ERC-8023の仕様は、必須の MultiStepOwnable と、任意拡張の UpdateableOwnershipTransferBuffer に分かれます。 中心になるのは所有権移転の3段階です。 現在の owner が initiateOwnershipTransfer() と confirmOwnershipTransfer() を呼び、確認済みの pendingOwner が acceptOwnershipTransfer() を呼ぶことで、最後に owner() の返すアドレスが変わります。

MultiStepOwnable

複数段階の所有権移転に対応するコントラクトは、以下のインターフェースを実装します。

js
/// @title Multistep Ownership Standard
interface MultiStepOwnable {
    event OwnershipTransferInitiated(address indexed prevOwner, address indexed newOwner);
    event OwnershipTransferConfirmed(address indexed prevOwner, address indexed newOwner);
    event OwnershipTransferred(address indexed prevOwner, address indexed newOwner);

    function initiateOwnershipTransfer(address newOwner) external;
    function confirmOwnershipTransfer(address newOwner) external;
    function cancelPendingOwnershipTransfer() external;
    function acceptOwnershipTransfer() external;

    function owner() external view returns (address);
    function pendingOwner() external view returns (address);
    function preConfirmedOwner() external view returns (address);
    function getOwnershipTransferBuffer() external view returns (uint256);
}

インターフェースは、移転操作、取り消し、参照、イベントをまとめて定義します。 ただし、管理権限を持つのは owner() が返すアドレスだけです。

関数

initiateOwnershipTransfer

initiateOwnershipTransfer() は、所有権移転の1段階目です。 現在の owner が新しい所有者候補を指定し、そのアドレスを preConfirmedOwner として記録します。

js
function initiateOwnershipTransfer(address newOwner) external;
  • 引数
項目 型 内容
newOwner address 新しい所有者候補のアドレス

この呼び出しは、所有権をまだ移しません。 owner() は引き続き現在の owner を返します。 同じ移転先で先へ進めるには、バッファ期間の後に confirmOwnershipTransfer(newOwner) を呼びます。

移転先を間違えた時や、攻撃者が別のアドレスを指定した時は、再度 initiateOwnershipTransfer() を呼ぶことで手続きをやり直す設計です。 提案では、再実行によって待機時間が最初から数え直されるため、十分に長いバッファ期間があれば防御側が時間を稼げます。

initiateOwnershipTransfer() が成功した時は、以下のイベントが発行されます。

js
event OwnershipTransferInitiated(address indexed prevOwner, address indexed newOwner);
  • 引数
項目 型 内容
prevOwner address 現在の owner
newOwner address 所有者候補として記録されたアドレス

confirmOwnershipTransfer

confirmOwnershipTransfer() は、所有権移転の2段階目です。 現在の owner が、開始時と同じ newOwner をもう一度指定して、移転先を確認します。

js
function confirmOwnershipTransfer(address newOwner) external;
  • 引数
項目 型 内容
newOwner address preConfirmedOwner と一致すべきアドレス

この呼び出しは、initiateOwnershipTransfer() からバッファ期間が経過した後に実行されます。 指定された newOwner は、開始時に記録した preConfirmedOwner と一致する必要があります。 別のアドレスへ移したい時は、確認だけを変えるのではなく、initiateOwnershipTransfer() からやり直します。

確認が成功すると、newOwner は pendingOwner として扱われます。 ただし、この時点でも owner() は変わりません。 最終段階の acceptOwnershipTransfer() が実行されるまで、現在の owner が管理権限を持ち続けます。

confirmOwnershipTransfer() が成功した時は、以下のイベントが発行されます。

js
event OwnershipTransferConfirmed(address indexed prevOwner, address indexed newOwner);
  • 引数
項目 型 内容
prevOwner address 現在の owner
newOwner address pendingOwner として確認されたアドレス

cancelPendingOwnershipTransfer

cancelPendingOwnershipTransfer() は、最終受け入れの前に所有権移転を取り消す関数です。 提案では、pendingOwner を消し、移転処理をキャンセルすることを求めています。

js
function cancelPendingOwnershipTransfer() external;

取り消し後に所有権を移したい時は、initiateOwnershipTransfer() からやり直します。

この関数は、移転先アドレスの誤指定、運用判断の変更、鍵漏洩時の防御で使われます。 提案のセキュリティ条件では、initiateOwnershipTransfer()、confirmOwnershipTransfer()、cancelPendingOwnershipTransfer() を呼べるのは owner だけです。

誤った移転先を指定した時の戻し方は以下です。

ERC-8023 の誤指定時リカバリー

cancelPendingOwnershipTransfer() を使うと、記録された候補を消して現在の owner のまま止められます。 別の移転先へ進めたい時は、再度 initiateOwnershipTransfer() を呼び、待機時間を最初から数え直します。

acceptOwnershipTransfer

acceptOwnershipTransfer() は、所有権移転の最終段階です。 pendingOwner として確認されたアドレスだけが呼び出せます。

js
function acceptOwnershipTransfer() external;

実装では、たとえば msg.sender == pendingOwner() のようなアクセス制御を行います。

この呼び出しが成功した時に、owner() が返すアドレスが新しい所有者へ変わります。 そのため、acceptOwnershipTransfer() の前後で管理権限の所在が切り替わります。

acceptOwnershipTransfer() が成功した時は、以下のイベントが発行されます。

js
event OwnershipTransferred(address indexed prevOwner, address indexed newOwner);
  • 引数
項目 型 内容
prevOwner address 以前の owner
newOwner address 新しい owner

参照関数

ERC-8023では、移転手続きの状態を外部から読めるように、3つのアドレス参照関数と1つのバッファ参照関数を定義しています。

owner

owner() は、現在の管理権限を持つアドレスを返します。

js
function owner() external view returns (address);
  • 戻り値
項目 型 内容
owner address 現在の所有者アドレス

提案では、権限を持つのは owner() が返すアドレスだけです。 preConfirmedOwner() や pendingOwner() が値を返していても、管理権限は移っていません。

pendingOwner

pendingOwner() は、確認済みで、受け入れ待ちになっているアドレスを返します。

js
function pendingOwner() external view returns (address);
  • 戻り値
項目 型 内容
pendingOwner address 受け入れ待ちの新しい所有者候補

pendingOwner は acceptOwnershipTransfer() を呼ぶ候補です。 一方で、acceptOwnershipTransfer() が成功するまでは管理操作を実行できません。

preConfirmedOwner

preConfirmedOwner() は、開始済みで、まだ確認前の所有者候補を返します。

js
function preConfirmedOwner() external view returns (address);
  • 戻り値
項目 型 内容
preConfirmedOwner address 開始段階で記録された所有者候補

preConfirmedOwner は、confirmOwnershipTransfer(newOwner) で照合される値です。 このアドレスも管理権限を持ちません。

getOwnershipTransferBuffer

getOwnershipTransferBuffer() は、initiateOwnershipTransfer() と confirmOwnershipTransfer() の間に置くバッファ時間を秒数で返します。

js
function getOwnershipTransferBuffer() external view returns (uint256);
  • 戻り値
項目 型 内容
buffer uint256 所有権移転の開始から確認までに必要な秒数

提案では値の範囲を強制していません。 一方で、2日から14日の範囲が強く推奨されています。 短すぎる値では確認や防御の時間が足りず、長すぎる値では正当な所有権移転の運用が重くなります。

権限境界は以下です。

ERC-8023 の権限境界

この図で見るべきなのは、owner、preConfirmedOwner、pendingOwner が同じ「所有者候補」に見えても、権限がまったく違う点です。 管理操作を実行できるのは owner だけで、pendingOwner ができるのは受け入れの実行だけです。

UpdateableOwnershipTransferBuffer

MultiStepOwnable コントラクトは、任意で UpdateableOwnershipTransferBuffer を実装できます。 この拡張は、所有権移転に使うバッファ期間を後から変更するためのインターフェースです。

js
/// @title UpdateableOwnershipTransferBuffer. Extension of MultiStepOwnable.
interface UpdateableOwnershipTransferBuffer {
    function initiateOwnershipBufferUpdate(uint256 newBuffer) external;
    function confirmOwnershipBufferUpdate(uint256 newBuffer) external;
}

バッファ期間の変更も、開始と確認の2段階で行います。 確認は、既存のバッファ期間が経過してから実行されます。 提案では、この待機を強制しない実装は、owner の鍵漏洩時に ownershipTransferBuffer の安全性を崩すと説明しています。

バッファ更新関数

initiateOwnershipBufferUpdate

initiateOwnershipBufferUpdate() は、バッファ期間の変更を開始する関数です。

js
function initiateOwnershipBufferUpdate(uint256 newBuffer) external;
  • 引数
項目 型 内容
newBuffer uint256 新しく設定したいバッファ秒数

この呼び出しだけでは、バッファ期間を変更しません。 変更内容を記録し、既存のバッファ期間が経過するのを待ちます。

confirmOwnershipBufferUpdate

confirmOwnershipBufferUpdate() は、バッファ期間の変更を確定する関数です。

js
function confirmOwnershipBufferUpdate(uint256 newBuffer) external;
  • 引数
項目 型 内容
newBuffer uint256 開始時に指定した新しいバッファ秒数

提案では、既存のバッファ期間が経過していない時は revert するべきだとしています。 これにより、攻撃者が owner の鍵を得た直後にバッファ期間を短くして、所有権移転を一気に進める動きを防ぎます。

バッファ更新の流れは以下です。

ERC-8023 のバッファ更新フロー

所有権移転だけでなく、待機時間の変更にも待機時間をかける点が重要です。 この制約がないと、バッファ期間そのものが攻撃者に短縮され、3段階移転の防御効果が弱くなります。

補足

バッファ期間が任意である理由

ERC-8023のバッファ期間は、移転先アドレスをオンチェーンで確認するために導入されています。 ただし、すべてのコントラクトで同じ待機時間が必要とは限りません。 個人管理の小さなコントラクト、DAOが管理する大きなプロトコル、緊急対応が必要なコントラクトでは、適切な待機時間が違います。

そのため提案は、バッファ期間を柔軟に扱えるようにしています。 バッファを使わない実装にすると、Ownable2Step に確認段階を1つ加えた設計に近くなります。 一方で、鍵漏洩時の防御時間を重視するなら、十分なバッファ期間を設定する必要があります。

owner 互換性

ERC-8023は、所有者の取得に owner() を使います。 この設計により、既存の所有権確認の仕組みと合わせやすくなります。

外部ツールや別コントラクトが owner() を見て管理者を判定している場合でも、ERC-8023は acceptOwnershipTransfer() まで owner() を変えません。 そのため、移転手続きの途中にある preConfirmedOwner や pendingOwner が、既存の管理者判定をすり抜けて権限を得ることはありません。

互換性

提案の互換性の節は TBD とされています。 ただし、仕様上は owner() を維持しているため、既存の所有者参照と合わせやすい方向で設計されています。

注意すべき点は、単発の transferOwnership() を前提にした運用フローです。 所有権移転がすぐに完了することを前提にした管理画面、スクリプト、監視処理は、開始、確認、受け入れの3段階を扱う必要があります。 また、pendingOwner が表示されていても owner() は変わっていないため、UIでは「受け入れ待ち」と「所有者変更済み」を分けて表示する必要があります。

セキュリティ

ERC-8023のセキュリティで最も重要なのは、移転処理の途中で権限を移さないことです。 preConfirmedOwner() と pendingOwner() は状態の記録であり、管理権限ではありません。 管理権限を持つのは、常に owner() が返すアドレスです。

initiateOwnershipTransfer()、confirmOwnershipTransfer()、cancelPendingOwnershipTransfer() は owner だけが呼べるようにします。 第三者が開始、確認、取り消しを呼べると、所有権移転の状態を勝手に動かせます。 特に cancelPendingOwnershipTransfer() は防御のための関数でもあるため、呼び出し権限を広げると正当な移転を止められます。

OwnershipTransferBuffer は、owner を設定する時に合わせて設定します。 コンストラクタや initialize() で初期化しないまま運用すると、実装によっては待機時間が意図せず短くなります。 バッファ期間を使う実装では、initiateOwnershipTransfer() と confirmOwnershipTransfer() の間で厳密に待機を強制します。

新しい owner が acceptOwnershipTransfer() を実行するまでは、既存の owner がすべての管理権限を持ち続けます。 これは、移転先がまだ受け入れていない段階で、管理権限だけが宙に浮く状態を避けるためです。

バッファ期間の更新を実装する場合も、変更を即時反映しないことが重要です。 鍵漏洩時に攻撃者がバッファ期間を0へ近づけられると、所有権移転の確認時間が消えます。 そのため confirmOwnershipBufferUpdate() では、既存のバッファ期間が経過していることを確認します。

最後に

今回は「ERC-8023」についてまとめてきました。

ERC-8023は、コントラクト所有権の移転を開始、確認、受け入れの3段階に分ける提案です。 owner() の互換性を残しつつ、移転先の再確認、取り消し、バッファ期間を組み合わせることで、誤操作や鍵漏洩時の被害を抑えやすくします。 特に、pendingOwner が存在しても acceptOwnershipTransfer() までは管理権限が移らない点が、この提案の中心です。

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