Appearance
L2-20: Atomic Swaps(アトミックスワップ / HTLC)
1. 問題
相互に信頼していない二者が、第三者エスクローなしで異なるブロックチェーン間で資産を交換するにはどうすればよいか? — これが「アトミックスワップ」(Atomic Swap)です。
具体的には:Aliceは自分の10 ETHをBobの同等価値のBTCと交換したいが、両者とも相手が先に送金することを信用していません。どうすれば双方にとって安全にこのクロスチェーン交換を完了できるでしょうか?
2. 理由
アトミックスワップはクロスチェーンDeFiの基礎的プリミティブであり、ブリッジや仲介者を信頼することなくクロスチェーン資産交換を実現します。HTLC(Hash Time-Locked Contract)は、BitcoinのライトニングネットワークとEthereum間の交換の中核技術です。
その安全性は2つの暗号学的原理に基づいています:
- Hash Lock(ハッシュロック):秘密の原像
sを知っている者だけが資金を解除できる(hash(s) == hashLock) - Time Lock(タイムロック):タイムアウト後に送信者が資金を回収できる
組み合わせると:受信者は一定時間内に秘密を開示しなければならず、さもなければ送信者は返金できます。
3. 解決策
アーキテクチャ設計
AtomicSwapsコントラクトをデプロイ
│
├── lock(receiver, hashLock, timelock) → AliceがETHをロック
│ └── swapId = keccak256(sender, receiver, amount, hashLock, timelock, timestamp) を生成
│
├── withdraw(swapId, secret) → Bobが秘密を開示してETHを取得
│ └── 検証: keccak256(secret) == hashLock
│
└── refund(swapId) → Aliceがタイムアウト後にETHを回収
└── 検証: block.timestamp > timelock && msg.sender == sender
交換フロー(Alice ETH ↔ Bob BTC)
- AliceがEthereum上で
lock(bobAddress, hashLock, timelock)を呼び出しETHをロック - Bobはロックを確認後、Bitcoinチェーン上に対応するHTLCを作成(同じhashLock、より短いtimelock)
- AliceがBitcoinチェーン上で
secretを開示してBTCを取得 - BobはBTCチェーン上で開示された
secretを確認し、Ethereum上でwithdraw(swapId, secret)を呼び出してETHを取得
ポイント: Aliceが先にsecretを開示し(BTCチェーン上で)、Bobがそれを見てからETHチェーン上で開示する — BobのBTC→ETHは一方向の情報フローです(Bobは自らBTCを放棄しない)。
状態マシン
None → Locked → Withdrawn(Bobが秘密を開示)
→ Refunded (Aliceがタイムロック後に返金)
コアコード
solidity
function withdraw(bytes32 swapId, bytes32 secret) external {
Swap storage swap = swaps[swapId];
require(swap.state == SwapState.Locked, "Not locked");
require(swap.receiver == msg.sender, "Not receiver");
require(keccak256(abi.encodePacked(secret)) == swap.hashLock, "Wrong secret");
swap.state = SwapState.Withdrawn;
(bool ok, ) = swap.receiver.call{value: swap.amount}("");
require(ok, "Transfer failed");
}
function refund(bytes32 swapId) external {
Swap storage swap = swaps[swapId];
require(swap.state == SwapState.Locked, "Not locked");
require(swap.sender == msg.sender, "Not sender");
require(block.timestamp > swap.timelock, "Timelock not expired");
swap.state = SwapState.Refunded;
(bool ok, ) = swap.sender.call{value: swap.amount}("");
require(ok, "Transfer failed");
}
4. 遭遇した落とし穴
4.1 swapIdの一意性
swapId = keccak256(sender, receiver, amount, hashLock, timelock, block.timestamp) はタイムスタンプを含めて一意性を確保しています。しかし、同じブロック内でパラメータが完全に同一の2つのトランザクションは衝突します — 理論的には可能ですが、実際には極めて稀です。
4.2 クロスチェーンタイミング攻撃
AliceとBobのtimelock設定が不適切な場合、以下のような事態が発生し得ます:AliceのETHがタイムアウト返金されたが、BTC側ではsecretがすでに開示されている — AliceはBTCと返金されたETHの両方を手に入れる。
4.3 リプレイ攻撃対策
withdraw と refund は両方とも SwapState.Locked をチェックし、実行後すぐに状態をWithdrawn/Refundedに更新します。これは古典的なChecks-Effects-Interactionsパターンです — リエントランシを防ぎ、安全な状態遷移を保証します。
4.4 secretの情報漏洩
withdraw トランザクションのcalldataでsecretが公開された場合、MEVサーチャーが先回りしてあなたのsecretをコピーし、もう一方のチェーンのHTLCを解除する可能性があります。実際のクロスチェーン交換では、secret公開のタイミングが極めて重要です。
5. 落とし穴の原因
5.1
block.timestamp は同じブロック内で定数ですが、swapIdは6つのパラメータをすべて含みます。sender/receiver/amount/hashLock/timelockがすべて同一で同じブロック内で発生した場合、swapIdは衝突します。典型的な解決策:nonce を追加するか、直接インクリメントカウンターを使用する。
5.2
正しいtimelock設定ルール:
- 開始者(Alice)のtimelockは受信者(Bob)よりも長くする
- BobのBTC上のtimelockはAliceのETH上のtimelockよりも短くする(例:24時間 vs 48時間)
- これにより、Aliceは24時間以内にsecretを開示する(そしてBobは48時間以内に開示する)か、交換を放棄する(両者がそれぞれ返金)
5.3
solidity
// ✅ CEI: 先に状態を更新
swap.state = SwapState.Withdrawn;
// その後で外部呼び出しを実行
(bool ok, ) = swap.receiver.call{value: swap.amount}("");
状態更新の前に call があると、攻撃者がwithdraw関数に再入してもう一度引き出すことができます。
6. 落とし穴の解決方法
- swapIdの一意性:本番環境ではハッシュ計算ではなく
swapCount++をswapIdとして使用 - Timelock勾配:開始者のtimelock > 受信者のtimelock、少なくとも2倍の差をつける
- CEIパターン:状態更新は常に外部呼び出しの前に行う
- Secret保護:Flashbotsバンドルまたはプライベートトランザクションプールを使用してwithdrawトランザクションを中継
7. 技術的要点
| 要点 | 説明 |
|---|---|
| HTLC | Hash Time-Locked Contract — クロスチェーンアトミックスワップ標準 |
| hashLock | keccak256(abi.encodePacked(secret)) — 原像を検証 |
| timelock | Unixタイムスタンプ、超過後に送信者が返金可能 |
| 状態マシン | None → Locked → Withdrawn/Refunded |
| CEI | Checks-Effects-Interactions — リエントランシ防止 |
| swapId設計 | block.timestampを含めて衝突防止 |
| 適用シナリオ | ETH↔BTC、ETH↔ERC20、ライトニングネットワークルーティング |