はじめに
『DApps開発入門』という本や色々記事を書いているかるでねです。
今回は、固定額の支払いと引き換えに、コントラクト内へ蓄積された資産を全量受け取れるようにするERC8017についてまとめていきます!
以下にまとめられている提案を解説しながらまとめていきます。
他にもいろいろなEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。
概要
ERC8017は、1種類の払い出し資産を入れておくバケットコントラクトと、固定額の支払いでそのバケット全量を受け取る purchase() 関数を定義する提案です。
バケットに入る資産はETHまたは1種類のERC20トークンで、購入者が支払う資産もETHまたは1種類のERC20トークンです。
ただし、ETHを支払ってETHを受け取る組み合わせは許されません。
ERC20は、Ethereum上で代替可能トークンを扱うための標準インターフェースです。
ERC8017では、払い出し側または支払い側のどちらかにERC20トークンを使えます。
ERC20については以下の記事を参考にしてください。
この仕組みは、継続的に入ってくる収益を別の資産へ変える用途に向いています。
たとえば、プロトコル収益でガバナンストークンを買い戻す、準備資産を積み立てる、インセンティブ予算を別資産へ変える、といった場面です。
ERC8017の中心は、オークション全体を標準化することではありません。
「この固定額を払えば、いまバケットにある資産を全量受け取れる」という最小のコントラクト面だけをそろえます。
価格判断や実行タイミングは、外側のコントローラーや探索者が担当します。
以下は、purchase() が行う支払いと払い出しの流れです。
購入者は requiredPayment と同じ額を支払い、支払いは paymentSink へ送られます。
同じトランザクションの中で、バケット内の payoutAsset は to へ全量払い出されます。
競争になるのは、経済的に割に合うタイミングで最初に purchase() を成功させた呼び出し元です。
動機
プロトコルには、継続的に発生する価値を別の資産へ変えたい場面があります。
収益で自プロトコルのトークンを買い戻す、準備資産を増やす、報酬原資を積み立てる、トレジャリーの資産配分を変える、といった用途です。
AMMを直接組み込む方法では、外部流動性、スリッページ、手数料、プール状態に結果が左右されます。
継続運用する場合は、プール条件が変わるたびに調整が必要になります。
一方で、一般的なオンチェーンオークションを常時運用すると、設定項目やガスコストが重くなります。
ERC8017は、ダッチオークションに近い考え方を、より小さな部品として切り出します。
バケットに資産が少しずつ貯まるほど、固定額を払って全量を受け取る購入者にとって条件が良くなります。
そのため、外側のコントローラーや探索者は、バケット残高と市場価格を見て、採算が合う時だけ purchase() を実行できます。
仕様
ERC8017は、払い出し資産、支払い資産、必要な支払い額、支払い先、購入関数、管理用セッター、イベントを定義します。
実装が準拠を名乗る場合、プロキシやクローンであっても、デプロイ済みコントラクトの外から見える挙動がこの仕様に合っている必要があります。
用語
| 用語 | 内容 |
|---|---|
payoutAsset |
バケットから払い出される資産。address(0) はETHを表す |
desiredAsset |
購入者が支払う資産。address(0) はETHを表す |
requiredPayment |
購入時に必要な固定額 |
paymentSink |
購入者の支払いを受け取る宛先 |
purchase(address to) |
支払いとバケット全量の払い出しを実行する関数 |
重要なのは、payoutAsset と desiredAsset がどちらも初期化後に変わらない点です。
この2つが途中で変わると、購入者が見ている資産ペアそのものが変わり、外部コントローラーや探索者の前提が崩れます。
資産ペアと経済条件は、変更できる範囲が分かれています。
payoutAsset と desiredAsset は資産ペアそのものなので固定します。
一方で、requiredPayment と paymentSink は運用上の経済条件であり、認可された管理者だけが変更できます。
IPayoutRace
インターフェース定義は以下です。
interface IPayoutRace {
function payoutAsset() external view returns (address);
function desiredAsset() external view returns (address);
function requiredPayment() external view returns (uint256);
function paymentSink() external view returns (address);
function purchase(address to) external payable returns (uint256 dispensed);
function setRequiredPayment(uint256 amount) external;
function setPaymentSink(address sink) external;
event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid);
event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink);
}
このインターフェースは、購入に必要な情報を外部から読めるようにし、購入後には Purchased イベントで実際に払い出した量と支払った額を記録します。
管理者が requiredPayment または paymentSink を変えた時は、PaymentConfigUpdated イベントを発行します。
関数
payoutAsset()
payoutAsset() は、バケット内から払い出す資産のアドレスを返します。
戻り値が address(0) の場合、払い出し資産はETHです。
function payoutAsset() external view returns (address);
| 戻り値 | 内容 |
|---|---|
address |
払い出し資産のアドレス。address(0) はETH |
desiredAsset()
desiredAsset() は、購入者が支払う資産のアドレスを返します。
戻り値が address(0) の場合、購入者はETHで支払います。
function desiredAsset() external view returns (address);
| 戻り値 | 内容 |
|---|---|
address |
支払い資産のアドレス。address(0) はETH |
requiredPayment()
requiredPayment() は、購入者が支払う固定額を返します。
この額は desiredAsset 建てで解釈されます。
function requiredPayment() external view returns (uint256);
| 戻り値 | 内容 |
|---|---|
uint256 |
購入に必要な固定額 |
paymentSink()
paymentSink() は、購入者の支払いを受け取る宛先を返します。
トレジャリー、分配コントラクト、burn 先などを指定できます。
function paymentSink() external view returns (address);
| 戻り値 | 内容 |
|---|---|
address |
支払いを受け取る宛先 |
purchase(address to)
purchase() は、購入者から固定額を集め、バケット内の払い出し資産を to へ全量送る関数です。
戻り値は実際に払い出した量です。
function purchase(address to) external payable returns (uint256 dispensed);
| 項目 | 型 | 内容 |
|---|---|---|
to |
address |
払い出し資産を受け取る宛先 |
dispensed |
uint256 |
実際に払い出した量 |
呼び出し時には、requiredPayment() と完全に同じ額を支払う必要があります。
ETH支払いの場合は msg.value == requiredPayment() でなければ失敗します。
ERC20支払いの場合は msg.value == 0 でなければ失敗し、desiredAsset に対して transferFrom(msg.sender, paymentSink(), requiredPayment()) を呼びます。
支払い資産がETHかERC20かで、固定額の回収方法が変わります。
ETH支払いでは msg.value がそのまま支払い額です。
ERC20支払いでは、コントラクトが購入者の allowance を使って paymentSink へ transferFrom() します。
払い出し量は、関数に入った時点のライブ残高から計算します。
ETH払い出しなら address(this).balance、ERC20払い出しなら balanceOf(address(this)) が基準です。
その量が0なら、購入は失敗しなければなりません。
固定額の過不足、バケット残高ゼロ、to や paymentSink の受け取り失敗は、購入全体を失敗させます。
成功時だけ、支払い回収と全量払い出しが同じトランザクションで完了します。
資産ペア
payoutAsset と desiredAsset は、ETHまたはERC20トークンを表します。
ただし、どちらも address(0) にする構成は禁止です。
以下は、使える資産ペアと禁止される資産ペアです。
ETHからETHへの構成を禁止している理由は、支払いとして入ってきた msg.value と、バケットから払い出すETH残高が同じETH残高として混ざるためです。
この禁止により、払い出し量を関数に入った時点のライブ残高として扱いやすくなります。
準拠要件
ERC8017に準拠するコントラクトは、購入処理、資産ペア、設定変更について外から見える挙動をそろえる必要があります。
ここでそろえる対象は、実装内部の書き方ではなく、購入者やインテグレーターが依存する実行結果です。
支払い額は固定額と一致させる
購入者は requiredPayment() と完全に同じ額を支払います。
ETH支払いでは msg.value が固定額と一致している必要があります。
ERC20支払いでは msg.value を0にし、固定額は transferFrom() で回収します。
ETH同士の資産ペアを禁止する
payoutAsset と desiredAsset の両方を address(0) にしてはいけません。
ETHを払ってETHを受け取る構成では、購入者が送った msg.value と、バケットに貯まっていた払い出し用ETHを分けにくくなります。
そのため、ETH同士の組み合わせだけを禁止します。
資産ペアは初期化後に変えない
payoutAsset と desiredAsset は、初期化後に変えてはいけません。
途中で資産ペアが変わると、外部コントローラーや探索者が見ている市場条件が別物になります。
購入判断の前提を固定するため、ERC8017に準拠するコントラクトはこの2つのセッターを公開しません。
バケット全量を一度に払い出す
purchase() は、関数に入った時点の払い出し資産残高を取得し、その全量を to へ1回で送ります。
部分購入や分割払い出しはこの提案の対象外です。
払い出し量が0の場合、購入は失敗します。
払い出し量は、途中で再計算せず、関数入口で確定した量を使います。
この仕様は、バケット内の資産を部分的に買う仕組みを定義しません。
購入が成功したら、その時点の払い出し資産残高は全量 to へ移ります。
支払いを paymentSink へ送る
ETH支払いでは、msg.value を paymentSink() へ送ります。
ERC20支払いでは、transferFrom() で requiredPayment() を paymentSink() へ送ります。
支払いをバケットコントラクト内へ残さないことで、払い出し資産の残高と購入者の支払いを分けて追跡できます。
管理操作を認可制にする
setRequiredPayment() と setPaymentSink() は、認可されたロールだけが呼べるようにします。
固定額や支払い先の変更は購入条件を直接変えるため、誰でも変更できる構成は準拠要件を満たしません。
また、requiredPayment または paymentSink を変更した時は、PaymentConfigUpdated イベントを発行します。
Purchased
購入が成功した時は、Purchased イベントを発行します。
event Purchased(address indexed buyer, address indexed to, uint256 dispensed, uint256 paid);
| 項目 | 型 | 内容 |
|---|---|---|
buyer |
address |
purchase() を呼んだアカウント |
to |
address |
払い出し資産の受取先 |
dispensed |
uint256 |
払い出した量 |
paid |
uint256 |
支払った量 |
このイベントにより、インデクサーや分析ツールは、どの購入者がいくら支払い、いくら受け取ったかを追跡できます。
PaymentConfigUpdated
requiredPayment または paymentSink が変わった時は、PaymentConfigUpdated イベントを発行します。
event PaymentConfigUpdated(address desiredAsset, uint256 requiredPayment, address sink);
| 項目 | 型 | 内容 |
|---|---|---|
desiredAsset |
address |
支払い資産 |
requiredPayment |
uint256 |
購入に必要な固定額 |
sink |
address |
支払いの受取先 |
desiredAsset 自体は変更できません。
そのため、このイベントに含まれる desiredAsset は、設定変更後の固定額や支払い先がどの資産建てかを外部から読みやすくするための情報です。
任意拡張
任意拡張は、中心仕様である固定額支払いと全量払い出しを変えず、周辺の利便性や運用上の回収だけを補います。
purchaseWithPermit
ERC20支払いでは、購入者が事前に approve() を実行する必要があります。
任意拡張として purchaseWithPermit(...) を用意すれば、署名による許可と購入を同じトランザクションで実行できます。
この拡張は、desiredAsset != address(0) の時だけ使えます。
実装する場合は、desiredAsset の permit() を呼び、許可が十分に付与されたことを確認したうえで、同じトランザクション内で transferFrom() による支払いを実行します。
desiredAsset がEIP2612に対応していない場合や、許可後の allowance が不足している場合は失敗しなければなりません。
EIP2612は、ERC20トークンの approve() 相当の許可を署名で与える仕組みです。
この拡張を使うと、購入者は承認トランザクションを別に出さずに購入できます。
EIP2612については以下の記事を参考にしてください。
rescue
任意拡張として、意図せず送られた資産を管理者が回収する rescue 関数を実装できます。
ただし、この関数は payoutAsset を移してはいけません。
payoutAsset を移せると、購入者が受け取るべきバケットの中身を管理者が抜けてしまいます。
rescue を実装する場合は、認可されたロールだけが呼べるようにし、Rescued(address token, address to, uint256 amount) イベントを発行します。
対象は、誤って送られたERC20トークンや、払い出し資産がERC20の時に直接送られたETHなどです。
補足
固定額とライブ残高
ERC8017の価格は、requiredPayment とバケット内のライブ残高の組み合わせで決まります。
コントラクトは市場価格を計算しません。
その代わり、外側のコントローラーや探索者が、バケット残高、外部価格、ガス代、手数料を見て購入するかを判断します。
バケット内の残高をライブ残高から読むため、リベーストークンや手数料付きトークンの変化も自然に反映されます。
一方で、第三者がバケットへ直接トークンを送った場合、その資産も次回の払い出しに含まれます。
購入者やコントローラーは、意図しない入金も含めて採算を判断する必要があります。
paymentSink の役割
paymentSink は、購入者が支払った資産を受け取る宛先です。
支払いをバケットコントラクト内に残さず、トレジャリー、分配コントラクト、burn 先へ直接送れます。
この分離により、バケットコントラクトが持つ残高は払い出し資産に寄ります。
監査時も、購入者の支払いがどこへ流れたかを paymentSink と Purchased イベントから追いやすくなります。
管理者権限
管理者は setRequiredPayment() と setPaymentSink() を呼べます。
この権限管理の方式は固定されていません。
EIP173形式の所有者管理や、ロールベースのアクセス制御など、必須挙動を満たす方式を選べます。
EIP173は、コントラクトの所有者を取得し、所有権を移転するための標準インターフェースです。
ERC8017では参考実装の説明に所有者管理が出てきますが、実装はEIP173だけに限定されません。
ERC173については以下の記事を参考にしてください。
requiredPayment 変更時の注意
requiredPayment は固定額ですが、管理者が変更できる構成も許されます。
ただし、すでにバケット内に払い出し資産が貯まっている時の変更は、購入者や探索者の行動に直接影響します。
以下は、requiredPayment を変更する時の主なリスクです。
requiredPayment を引き下げると、すでに大きくなったバケットを安く買われ、価値が購入者側へ漏れることがあります。
反対に引き上げると、購入が止まったり、既存の裁定戦略を動かしている探索者が損をしたりします。
そのため、タイムロック、告知期間、段階的な変更、ブロックごとの入金上限、変更後のクールダウン、時間加重平均価格を使った調整などが安全策になります。
複数資産の払い出しを扱わない理由
ERC8017は、払い出し資産を1種類に限定しています。
複数資産をまとめて払い出す設計にすると、許可リスト、各トークンの残高取得、転送失敗時の扱い、価値評価の方法が増えます。
単一資産に絞ることで、購入者は「固定額を払うと、いまある1種類の資産を全量受け取る」と理解できます。
複数資産の一括回収は、この小さな仕様の外側で別の仕組みとして組み立てる方が扱いやすいです。
互換性
ERC8017は、ERC20トークンと組み合わせて使えます。
ウォレットやdAppは、通常の approve() と transferFrom() の流れで支払いを準備できます。
任意拡張の purchaseWithPermit() がある実装では、EIP2612の署名を使って承認と購入をまとめられます。
既存のERC20トークン自体に変更は不要です。
必要なのは、支払い側のERC20トークンが transferFrom() で paymentSink へ送れることと、払い出し側のERC20トークンが transfer() で to へ送れることです。
参考実装
参考実装は、payoutAsset と desiredAsset を immutable にし、requiredPayment と paymentSink だけを所有者が更新できる構成です。
購入処理の骨格は以下です。
function purchase(address to) external payable nonReentrant returns (uint256 dispensed) {
uint256 toDispense;
if (payoutAsset == address(0)) {
toDispense = address(this).balance;
} else {
toDispense = IERC20(payoutAsset).balanceOf(address(this));
}
require(toDispense > 0, "empty");
if (desiredAsset == address(0)) {
require(msg.value == requiredPayment, "bad msg.value");
(bool ok, ) = paymentSink.call{value: msg.value}("");
require(ok, "sink transfer failed");
} else {
require(msg.value == 0, "unexpected ETH");
require(
IERC20(desiredAsset).transferFrom(msg.sender, paymentSink, requiredPayment),
"payment transfer failed"
);
}
if (payoutAsset == address(0)) {
(bool ok2, ) = to.call{value: toDispense}("");
require(ok2, "ETH payout failed");
} else {
require(IERC20(payoutAsset).transfer(to, toDispense), "token payout failed");
}
emit Purchased(msg.sender, to, toDispense, requiredPayment);
return toDispense;
}
読みどころは、外部呼び出しの前に toDispense を確定している点です。
その後、支払いを paymentSink へ送り、最後に to へ払い出します。
再入可能性を避けるため、参考実装では nonReentrant も使っています。
外部呼び出しを含む処理は、順序を固定して読みます。
支払い回収と全量払い出しは外部呼び出しを伴うため、その間に余計な外部呼び出しを挟まない設計が重要です。
先に払い出し量を固定し、支払い回収、払い出し、イベント記録へ進むことで、再入可能性の影響範囲を小さくできます。
ETHを受け取る receive() は、払い出し資産がETHの時だけ許可する設計です。
払い出し資産がERC20の時にETHを直接送られても、そのETHは仕様上の payoutAsset ではないため、通常の購入対象に含めません。
セキュリティ
払い出し量の確定
払い出し量は、関数に入った時点のライブ残高から計算します。
ETH同士の交換が禁止されているため、ETH支払いの msg.value とETH払い出し量が混ざる問題を避けられます。
実装は、計算した toDispense をその後の処理で使い回し、途中の外部呼び出しで払い出し量がずれないようにします。
再入可能性
purchase() は、ERC20の transferFrom()、ETH送金、ERC20の transfer() など、外部呼び出しを含みます。
そのため、再入可能性への対策が必要です。
払い出し量を先に確定し、支払いと払い出しのあいだに不要なコールバックを挟まず、再入防止を入れる設計が安全です。
受取先と支払い先
to は、払い出される資産を受け取れる必要があります。
ETH払い出しなら to がETHを受け取れること、ERC20払い出しなら transfer() を拒否しないことが必要です。
paymentSink も、支払い資産を受け取れる必要があります。
ETH支払いならETHを受け取れるアドレスであり、ERC20支払いなら transferFrom() の宛先として問題なく受け取れる必要があります。
意図しない入金
バケットへ第三者が直接送った資産は、払い出し資産であれば次回の purchase() に含まれます。
この性質は、ライブ残高を正とする設計の裏返しです。
運用側は、意図しない入金が購入条件に与える影響を考慮する必要があります。
払い出し資産ではない資産については、任意拡張の rescue を使って回収できる設計にできます。
承認と署名
ERC20支払いでは、購入者がコントラクトへ十分な allowance を与えている必要があります。
通常の allowance には、承認額の変更タイミングに由来する競合リスクがあります。
purchaseWithPermit() を実装する場合は、署名のドメイン、期限、nonce、許可後の allowance を確認し、不足していれば失敗させます。
署名を受け取っただけで購入が成立したとみなさず、実際に transferFrom() で支払いを回収できることが重要です。
管理者変更
setRequiredPayment() と setPaymentSink() は、経済条件と資金の流れを変える操作です。
そのため、管理者権限を持つアカウントやガバナンスは慎重に保護する必要があります。
タイムロック、告知期間、段階的な変更、固定化すべきパラメータの immutable 化などが対策になります。
プロキシとクローン
プロキシや最小クローンでは、コンストラクタがインスタンスごとに実行されません。
そのため、payoutAsset と desiredAsset は初期化時に1回だけ設定し、その後に変えられないようにする必要があります。
初期化関数は、複数回呼び出しや再入を防ぐ設計にします。
最後に
今回は「固定額の支払いと引き換えに、コントラクト内へ蓄積された資産を全量受け取れるようにするERC8017」についてまとめてきました!
ERC8017は、AMMや大きなオークション機構を標準化するのではなく、固定額の支払いとバケット全量の払い出しだけを小さく定義します。
この小ささにより、プロトコル収益の買い戻し、準備資産の積み立て、インセンティブ予算の変換などを、外側のコントローラーと組み合わせて扱えます。
一方で、requiredPayment の変更、意図しない入金、再入可能性、paymentSink の受け取り能力は、実装と運用で丁寧に管理する必要があります。
他でも色々記事を書いているのでぜひよろしければ読んでいってください!






