再入攻撃、その深淵と進化:マルチコントラクト間攻撃とDefiプロトコルの盲点
我々セキュリティリサーチャーがDeFiの世界で日々直面する脅威は、単なるスマートコントラクトのコードミスに留まらない。そこには、複数のプロトコルを跨ぎ、複雑なインタラクションの隙間を縫う、悪魔的な攻撃者の意図が潜んでいる。古典的な再入攻撃はもはや序章に過ぎず、今日では、目に見えない鎖で繋がれたコントラクト間の状態不整合こそが、我々が本当に恐れるべき盲点となっている。
本稿では、セキュリティアーキテクトやチーフホワイトハッカー、そして最前線でコードを監査するテックリードの諸君に向けて、再入攻撃の高度な変種、特にマルチコントラクト間での攻撃メカニズムを深掘りし、その検知と防衛のための最高峰の技術と監査の観点を提供したい。教科書的なガイドラインの繰り返しはしない。サイバー犯罪の裏側で蠢く攻撃者の思考を逆算し、その一歩先を行くための知見を共有する。
1. 古典的再入攻撃の再評価と限界:なぜThe DAOは繰り返されるのか
2016年、The DAO事件はイーサリアムコミュニティに深い爪痕を残した。単一のコントラクト内で、引き出し処理が完了する前に再度引き出し関数を呼び出すことで、資金が無限に流出するという、シンプルな再入攻撃が原因だった。この事件を教訓に、スマートコントラクト開発の黄金律として「Checks-Effects-Interactions(C-E-I)パターン」が確立された。
- Checks (チェック): 前提条件の確認(例: 残高、権限)。
- Effects (効果): コントラクトの状態変更(例: 残高の減算)。
- Interactions (相互作用): 外部コントラクトへの呼び出し(例: 資金送金)。
このパターンは、外部呼び出しを行う前に自身のコントラクトの状態を更新することで、再入攻撃の窓を閉じることを意図している。例えば、送金前にユーザーの残高を減らす、といった具合だ。
// 古典的な再入攻撃に脆弱な例 (概念的なコード)
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 問題点: 外部呼び出しが状態変更 (balances[msg.sender] -= _amount) の前に行われる
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Failed to send Ether");
balances[msg.sender] -= _amount; // ここに到達する前に再入される可能性がある
}
}
// C-E-I パターンを適用した例
contract SecureBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 解決策: まず自身の状態を変更 (Effects)
balances[msg.sender] -= _amount;
// 次に外部呼び出し (Interactions)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "Failed to send Ether");
}
}
しかし、この古典的なC-E-Iパターンは、単一コントラクト内のロジックには有効だが、現代のDeFiエコシステムにおける複雑なプロトコル間連携に対しては、その防御能力に限界がある。攻撃者は、単一の関数ではなく、複数のコントラクトを連鎖的に悪用し、状態不整合を作り出す新たな手口を編み出している。
2. マルチコントラクト間再入攻撃:見えない鎖の脆弱性
DeFiの世界は、レンディング、DEX、イールドファーミングなど、多種多様なプロトコルが相互に依存し合う巨大なエコシステムを形成している。あるプロトコルが別のプロトコルのトークンを担保として受け入れたり、オラクルとして利用したりすることは日常茶飯事だ。この相互作用こそが、攻撃者にとっての新たな戦場となる。
2.1. 状態不整合の温床:外部コントラクト呼び出しの危険性
攻撃者が狙うのは、まさにこの「外部コントラクト呼び出し」の瞬間に発生する、システム全体の「状態の瞬間的な不整合」である。EVM(Ethereum Virtual Machine)の低レイヤでは、CALL命令が実行されると、現在のコントラクトの実行は一時停止し、呼び出し先のコントラクトに制御が移る。呼び出し先のコントラクトは、自身のコードを実行し、その中でさらに別のコントラクトを呼び出すことも可能だ。この一連の連鎖的な呼び出しの中で、呼び出し元コントラクトが期待するシステムの状態と、実際に存在する状態との間に乖離が生じる。
例えば、あるレンディングプロトコルが、ユーザーが預けたLPトークンを担保として認識し、そこから別のトークンを貸し出すシナリオを考えてみよう。
1. ユーザーがLPトークンをレンディングプロトコルAに預ける。
2. プロトコルAは、預けられたLPトークンの価値に基づいて、ユーザーに別のトークンXを貸し出す。
3. このトークンXの貸し出し処理の途中で、プロトコルAがLPトークンの元のプロトコルBに対して、何らかのステータス照会(例: balanceOf)を行う。
4. 攻撃者は、この照会中に、プロトコルBのLPトークンコントラクトを介して再入攻撃を仕掛け、LPトークンのバランスを一時的に操作する、あるいは、別のプロトコルCを呼び出してトークンXをフラッシュローンで取得し、プロトコルAへの担保価値認識を誤らせる、といったことが可能になる。
2.2. 低レイヤの挙動から見る攻撃経路
EVMのCALL命令は、以下の挙動を持つ。
- ガス転送:
CALL命令は、指定されたガス量(または残りの全ガス量)を呼び出し先のコントラクトに転送する。これにより、呼び出し先のコードが実行される。 - 戻り値: 呼び出し先のコントラクトが実行を終えると、戻り値をスタックにプッシュする。
CALL命令自体も成功/失敗を示すブール値を返す。 - 状態変更:
CALL命令は、呼び出し先のコントラクトが状態を変更する可能性がある。しかし、呼び出し元コントラクトは、呼び出し先の状態変更を直接制御することはできない。
攻撃者は、この低レイヤの挙動、特に「呼び出し先のコントラクトが自身の状態を変更する能力」と「呼び出し元コントラクトがその状態変更を即座に認識できない(あるいは適切に扱えない)可能性」を悪用する。
例えば、ERC-777トークンは、tokensToSendとtokensReceivedというコールバックフックを持つ。これは、トークン送金時に自動的に特定の関数が呼び出される仕組みであり、非常に強力な機能である一方で、再入攻撃の新たなベクトルとなりうる。攻撃者は、このコールバック内で意図しない操作(例:別のコントラクトへの再入呼び出し)を仕掛けることで、送金元や送金先のコントラクトの状態を不整合に陥れることが可能になる。
// ERC-777のコールバックを悪用した再入攻撃の概念(簡略化)
// 攻撃コントラクト
contract Attacker {
IERC777 public token;
VulnerableReceiver public receiver; // 脆弱なレシーバーコントラクト
constructor(IERC777 _token, VulnerableReceiver _receiver) {
token = _token;
receiver = _receiver;
}
// ERC-777の`tokensReceived`フック。トークンを受け取った際に呼び出される。
function tokensReceived(address operator, address from, address to, uint amount, bytes userData, bytes operatorData) external {
if (msg.sender == address(token)) {
// ここで再入攻撃を仕掛ける
// receiverコントラクトの脆弱な関数を呼び出し、状態不整合を誘発
receiver.withdrawFunds();
}
}
function attack(uint amount) public {
// 攻撃者がトークンをVulnerableReceiverに送金
// この送金でVulnerableReceiverのtokensReceivedが呼び出され、上記の再入ロジックが実行される
token.send(address(receiver), amount, "");
}
}
// 脆弱なレシーバーコントラクト (概念)
contract VulnerableReceiver is IERC777Receiver {
mapping(address => uint) public funds;
IERC777 public token;
constructor(IERC777 _token) {
token = _token;
}
function deposit() public payable {
funds[msg.sender] += msg.value;
}
function withdrawFunds() public {
uint amount = funds[msg.sender];
require(amount > 0, "No funds to withdraw");
// C-E-Iパターンが不適切、またはマルチコントラクト間で破られている
(bool success, ) = msg.sender.call{value: amount}(""); // 外部呼び出し
require(success, "Withdrawal failed");
funds[msg.sender] = 0; // 攻撃者はここが実行される前に再入できる
}
// ERC-777の`tokensReceived`を実装
function tokensReceived(address operator, address from, address to, uint amount, bytes userData, bytes operatorData) external returns (bytes4) {
// 例えば、ここでトークンを受け取ったと同時に、depositされた資金を即座に引き出すロジックがあったとする
// この中で、Attackerコントラクトからの再入が起こりうる
// ... (省略) ...
return IERC777Receiver.tokensReceived.selector;
}
}
この例は非常に簡略化されているが、ERC-777のようなコールバックを悪用することで、トークンの送金という一見無害な操作が、別のコントラクトの状態を不正に操作するトリガーになりうることを示している。
2.3. 典型的な攻撃シナリオの分解
- フラッシュローンと連鎖的再入: 最も高度で頻繁に見られるのが、フラッシュローンを起点とした攻撃だ。攻撃者は、瞬間的に巨額の資金を借り入れ、その資金を使って複数のDeFiプロトコル(DEX、レンディング、オラクルなど)を連鎖的に操作し、特定のコントラクトの状態を不正に更新する。例えば、借り入れたトークンをDEXに流動性提供し、LPトークンを受け取った直後に、そのLPトークンを別のレンディングプロトコルの担保として預け、さらにトークンを借り入れる。この一連の操作の途中で、元のフラッシュローンプロトコルが担保価値を再評価する前に、再入的に担保を引き出す、といった手法が考えられる。
- オラクル操作と再入: 価格オラクルはDeFiの生命線だが、その価格フィードが操作されると致命的だ。攻撃者は、再入メカニズムを利用して、一時的にDEXの流動性プールを歪め、オラクルが不正な価格を読み取るように仕向ける。その後、その不正な価格に基づいてレンディングプロトコルから過剰な担保を引き出す、あるいは清算を回避するといった手口が用いられる。
3. 防衛の最前線:高度なセキュリティパターンとアーキテクチャ
進化する脅威に対し、我々もまた防御戦略を進化させなければならない。単一コントラクトのC-E-Iだけでは不十分だ。プロトコル全体、エコシステム全体を見据えた多層的な防御が求められる。
3.1. 徹底されたChecks-Effects-Interactionsの再定義
C-E-Iパターンは健在だが、その適用範囲を拡大する必要がある。
- プロトコルレベルのC-E-I: 単一関数内だけでなく、プロトコルを構成する全てのコントラクト間での状態遷移を考慮し、外部呼び出しが必要な場合は、プロトコル全体の状態が安全に更新された後にのみ行う。
- 外部呼び出しの最小化と隔離: 必要不可欠な外部呼び出し以外は避け、可能な限り内部ロジックで完結させる。外部呼び出しは常にトランザクションの最後に配置し、その呼び出しによって再入可能となる関数へのアクセスを厳格に制限する。
3.2. 再入防止ガード(Reentrancy Guard)の進化
OpenZeppelinのReentrancyGuardは、nonReentrantモディファイアを通じて、再入を防止する強力なツールだ。
// OpenZeppelin ReentrancyGuard の使用例
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// nonReentrant モディファイアを適用
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount; // Effects
(bool success, ) = msg.sender.call{value: _amount}(""); // Interactions
require(success, "Failed to send Ether");
}
}
このモディファイアは、内部で_status変数を管理し、関数実行中にこの変数をロックすることで、同じコントラクトへの再入をブロックする。
しかし、攻撃者は複数のコントラクトを跨ぐ。nonReentrantは、あくまで同じコントラクトインスタンスへの再入を防ぐものであり、異なるコントラクトからの連鎖的な呼び出しによる状態不整合までは防ぎきれない。
より高度な対策としては、以下のようなカスタムロックメカニズムが考えられる。
- グローバルなロック: プロトコル全体で共有されるロック機構を導入し、特定のクリティカルな操作中は、他の関連するコントラクトの操作も一時的にブロックする。これは実装が複雑で、デッドロックのリスクも伴うため、慎重な設計が必要だ。
- 粒度の高いロック: 特定のリソース(例: 特定のLPトークンプール)に対するロックを導入し、そのリソースに関連する操作のみをロックすることで、システム全体の可用性を損なわずに安全性を高める。
3.3. 外部呼び出しの厳格な管理とアサーション
transferとsendの活用: Etherの送金には、call.value()ではなく、transferやsendを使用する。これらの関数は2300ガスという制限を課すため、呼び出し先のコントラクトで複雑なロジックを実行させ、再入攻撃を仕掛けることを事実上不可能にする。- 技術的背景: 2300ガスは、EVMが持つスタックフレームの基本的な操作やログ記録には十分だが、追加の
CALL命令やストレージアクセスといった複雑な操作には不足する。これにより、攻撃者がコールバック関数内で悪意のあるコードを実行する余地を奪う。 - 戻り値と
msg.senderの厳格なチェック:call.value()を使用する場合でも、その戻り値は必ずチェックし、成功が確認されなければトランザクションをリバートする。さらに、msg.senderが期待されるアドレスであるか、あるいはmsg.valueが期待される値であるかを常にアサートすることで、不正な呼び出しを早期に検知する。
3.4. システム全体での状態管理と不変条件(Invariants)の適用
DeFiプロトコルは、常に特定の不変条件(invariant)を満たしているべきだ。例えば、「全てのトークンの総供給量は、発行されたトークンの総数に等しい」「レンディングプロトコルの担保総額は、貸し出された資金総額を下回らない」など。
- 不変条件の定義と継続的監視: プロトコル全体の不変条件を明確に定義し、オンチェーン/オフチェーンの監視システムでこれらの条件が常に満たされているかを継続的にチェックする。逸脱が見られた場合、即座にアラートを発し、必要であれば緊急停止メカニズムを起動する。
- 形式的検証(Formal Verification): 最も厳格なセキュリティ保証手法の一つ。スマートコントラクトのコードが、数学的に定義されたプロパティ(不変条件など)を常に満たすことを証明する。これは開発コストが高いが、クリティカルなプロトコルでは必須のアプローチとなりつつある。
3.5. 防御層としての耐量子暗号とAIガードレール
直接的な再入攻撃の防御とは異なるが、最先端のセキュリティアーキテクチャを語る上で、未来を見据えたこれらの防御層は不可欠だ。
- 耐量子暗号(Post-Quantum Cryptography: PQC)への移行計画: 量子コンピュータの進化は、現在の公開鍵暗号(楕円曲線暗号など)を破る可能性を秘めている。これは、ブロックチェーンの署名アルゴリズムに直接的な脅威となる。再入攻撃とはレイヤーが異なるが、プロトコル全体のセキュリティ基盤を揺るがしかねないため、将来的なPQCへの移行パスを検討し、研究開発を進める必要がある。特に、量子耐性を持つハッシュベース署名(例: Lamport, XMSS)や格子ベース暗号(例: Dilithium)などの動向を注視し、スマートコントラクトの署名検証ロジックへの影響を評価する。
- 生成AIのプロンプトインジェクションに対する防御層: 生成AIは、スマートコントラクトのコード生成、セキュリティ監査、脆弱性分析において強力なツールとなり得る。しかし、AIに対するプロンプトインジェクションは、AIの行動を操作し、意図しない脆弱なコードを生成させたり、監査結果を誤誘導したりするリスクがある。
- AI監査ツールの防御: AIベースのセキュリティ監査ツールを開発・利用する際は、そのAIモデル自体へのプロンプトインジェクション耐性を高める必要がある。入力プロンプトのサニタイズ、複数の異なるAIモデルやヒューリスティックによるクロスチェック、生成されたコードに対する厳格な事後検証(Post-Verification)メカニズムを組み込む。
- 「AIガードレール」のアーキテクチャ設計: AIが生成するコードや分析結果が、事前に定義されたセキュリティポリシーや不変条件に違反しないように、最終的な出力層で「ガードレール」を設ける。これは、生成AIのアウトプットを直接プロダクションにデプロイするのではなく、必ず人間の専門家によるレビューと、形式的検証ツールなどの厳格なチェックを経由させることを意味する。
4. 監査と実践:ホワイトハッカーの視点
攻撃者の思考を理解し、その手口を先回りするためには、コードレビューとテスト戦略を深く掘り下げる必要がある。
4.1. コードレビューの深化:依存グラフとデータフロー解析
単にコードを読むだけでは不十分だ。
- コントラクト間の依存グラフの構築: プロトコルを構成する全てのコントラクトを洗い出し、それらの間の呼び出し関係、状態依存関係を視覚化する。どのコントラクトがどのコントラクトのデータを参照し、どのコントラクトの関数を呼び出すのかを明確にする。
- データフローとコントロールフローの追跡: 攻撃者が特定のデータを操作し、その結果がどのようにシステム全体に伝播するかをトレースする。特に、外部呼び出しを跨いだ状態変数の変化を追跡し、潜在的な状態不整合のポイントを特定する。
- 静的解析ツールの限界と手動レビューの重要性: Slither, Mythrilなどの静的解析ツールは強力だが、複雑なマルチコントラクト間の状態依存関係や、意図されたビジネスロジックの欠陥までは見抜けないことが多い。最終的には、人間による深い洞察と、プロトコル全体のアーキテクチャを理解した上での手動レビューが不可欠だ。
4.2. テスト戦略の拡張:ファジングとプロパティベースドテスト
- ファジング(Fuzzing): HardhatやFoundryの
fuzzテストは、ランダムな入力値に対してコントラクトの挙動を検証する。これにより、開発者が想定していなかったエッジケースや、特定の入力の組み合わせによって発生する脆弱性を発見できる可能性が高まる。特に、外部コントラクト呼び出しを含む一連の操作に対してファジングを行うことで、マルチコントラクト間の再入攻撃シナリオを再現しやすくなる。
// Foundry の Fuzz テスト例 (概念)
import {Test, console} from "forge-std/Test.sol";
import {SecureBank, Attacker} from "../src/SecureBank.sol"; // 適切なパスに修正
contract SecureBankTest is Test {
SecureBank secureBank;
Attacker attacker; // 攻撃コントラクトのインスタンス (テスト用)
address deployer;
address user1;
address user2;
function setUp() public {
deployer = makeAddr("deployer");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
secureBank = new SecureBank();
attacker = new Attacker(address(secureBank)); // 攻撃コントラクトにSecureBankのアドレスを渡す
}
// Fuzz テストの例: ランダムな量で預金と引き出しを試行
function testFuzz_DepositAndWithdraw(uint256 _depositAmount, uint256 _withdrawAmount) public {
// 不正な金額を排除
vm.assume(_depositAmount > 0 && _depositAmount < 100 ether);
vm.assume(_withdrawAmount > 0 && _withdrawAmount < 100 ether);
// user1が預金
vm.prank(user1);
secureBank.deposit{value: _depositAmount}();
// user1が引き出しを試みる
vm.prank(user1);
// nonReentrantがあるため、通常は再入できないが、テストで確認
// revertWith("ReentrancyGuard: reentrant call") が期待される場合もある
secureBank.withdraw(_withdrawAmount);
// ここで、期待される状態(例: 残高の整合性)をアサート
// assertEq(secureBank.balances(user1), _depositAmount - _withdrawAmount);
}
// マルチコントラクト間の攻撃シナリオを模倣したFuzzテスト (例)
// 攻撃コントラクトがSecureBankのwithdrawを呼び出し、そのコールバックでさらに何かを試みる
function testFuzz_ReentrancyAttempt(uint256 _initialDeposit, uint256 _withdrawAmount) public {
vm.assume(_initialDeposit > 0 && _initialDeposit < 100 ether);
vm.assume(_withdrawAmount > 0 && _withdrawAmount < _initialDeposit); // 引き出しは預金以下
// 攻撃コントラクトがSecureBankに預金
vm.prank(address(attacker));
secureBank.deposit{value: _initialDeposit}();
// 攻撃コントラクトがSecureBankのwithdrawを呼び出す (ReentrancyGuardが防ぐはず)
vm.expectRevert("ReentrancyGuard: reentrant call"); // 期待されるリバート
vm.prank(address(attacker));
// 攻撃コントラクトが`withdraw`を呼び出す際のガスを考慮
// attackerコントラクトが内部で再入を試みる場合、`secureBank.withdraw`はリバートする
attacker.attackWithdraw(_withdrawAmount);
// 攻撃が成功していないことを確認 (例: SecureBankの残高が期待通りであること)
assertEq(secureBank.balances(address(attacker)), _initialDeposit); // 攻撃が失敗し、残高が減っていないことを確認
}
}
// テスト用の攻撃コントラクト (SecureBankのwithdrawを呼び出す)
contract Attacker {
SecureBank public target;
uint public counter = 0;
constructor(address _target) {
target = SecureBank(_target);
}
// 攻撃者のfallback関数 (Etherを受け取った際に呼び出される)
// ここでSecureBankのwithdrawを再入しようとする
receive() external payable {
if (counter < 1) { // 複数回の再入を防ぐため
counter++;
target.withdraw(msg.value); // 再入を試みる
}
}
// AttackerがSecureBankのwithdrawを呼び出す関数
function attackWithdraw(uint _amount) public {
target.withdraw(_amount);
}
}
この例では、SecureBankのwithdraw関数がnonReentrantモディファイアで保護されているため、Attackerコントラクトが再入を試みてもリバートされることをテストで確認している。ファジングは、このようなシナリオで様々な_initialDepositや_withdrawAmountを試行し、予期せぬ挙動がないかを探るのに役立つ。
- プロパティベースドテスト: 特定の入力に対して出力が正しいかを検証する「例ベーステスト」に対し、プロパティベースドテストは、入力によらず常に満たされるべき「プロパティ」を定義し、それを検証する。例えば、「コントラクトの総資産は、預金総額と引き出し総額の差と常に一致する」といったプロパティを定義し、あらゆる操作の後にそれが満たされているかをチェックする。
EchidnaやManticoreのようなツールがこの種のテストをサポートしている。
4.3. インシデントハンドリング:攻撃検知と緊急停止(Pause/Upgrade)
どんなに完璧な防御を敷いても、未知の脆弱性やゼロデイ攻撃のリスクはゼロにはならない。そのため、攻撃を早期に検知し、被害を最小限に抑えるためのインシデントハンドリング戦略が不可欠だ。
- オンチェーン監視システムの構築: ブロックチェーン上のイベントログ、トランザクション、コントラクトの状態変化をリアルタイムで監視するシステムを構築する。特に、通常の振る舞いから逸脱するパターン(例: 特定のコントラクトからの異常な量の引き出し、短時間での複数コントラクト間の複雑なインタラクション)をAI/MLモデルで検知し、即座にアラートを発する。
- 緊急停止(Pausable)機能: 攻撃が検知された場合、プロトコルを一時的に停止させる
pausableパターンを導入する。これにより、攻撃によるさらなる資金流出を防ぎ、根本的な原因究明と修正のための時間稼ぎができる。ただし、緊急停止機能の権限管理は非常に慎重に行う必要があり、単一障害点とならないよう、マルチシグウォレットによる制御やタイムロックの導入が望ましい。 - アップグレード可能なプロキシパターン: 脆弱性が発見された場合、コントラクトのロジック部分をアップグレードできるように、
Proxyパターン(例: UUPS, Transparent Proxy)を採用する。これにより、基盤となるストレージを維持しつつ、ロジックコントラクトを修正・デプロイし直すことが可能になる。このパターン自体もセキュリティ上の注意点(プロキシの初期化忘れ、アップグレード権限の悪用など)があるため、厳格な監査と権限管理が求められる。
5. 結び:進化する脅威への適応
スマートコントラクト、特にDeFiプロトコルにおける再入攻撃は、古典的なものからマルチコントラクトを跨ぐ高度な変種へと進化し続けている。これは、単一の技術的解決策で全てが解決するものではなく、アーキテクチャ全体、プロトコル全体の設計思想に深く根ざしたセキュリティ文化が求められることを意味する。
我々セキュリティリサーチャー、アーキテクト、そして開発者は、EVMの低レイヤ挙動から、複雑なプロトコル間のインタラクション、そして未来の脅威である耐量子暗号やAIの脆弱性まで、多岐にわたる知識と洞察を持ち合わせる必要がある。サイバー攻撃者は常に盲点を狙い、システムの最も弱い鎖を探し出す。その一歩先を行くためには、技術的な深掘り、徹底した監査、そして常に攻撃者の視点に立つという、終わりのない旅を続ける覚悟が必要だ。
このディープな知見が、諸君のプロジェクトを未曾有の脅威から守り、DeFiエコシステムの健全な発展に寄与することを切に願う。
—
コメント