Appearance
L2-20: Atomic Swaps (HTLC / Hash Time-Locked Contracts)
1. Problem
How can two mutually distrusting parties exchange assets across two different blockchains without requiring a third-party escrow? -- This is the "Atomic Swap."
Specifically: Alice wants to exchange her 10 ETH for Bob's equivalent value in BTC, but neither trusts the other to send first. How can this cross-chain exchange be completed safely for both sides?
2. Why
Atomic swaps are the foundational primitive of cross-chain DeFi, enabling cross-chain asset exchanges without trusting any bridge or intermediary. The HTLC (Hash Time-Locked Contract) is the core technology behind Bitcoin's Lightning Network and Ethereum cross-chain swaps.
Its security rests on two cryptographic principles:
- Hash Lock: Only someone who knows the secret preimage
scan unlock the funds (hash(s) == hashLock) - Time Lock: After a timeout, the sender can reclaim their funds
Combined, this means: the receiver must reveal the secret within a certain time window, or the sender can refund.
3. Solution
Architecture Design
Deploy AtomicSwaps contract
|
├── lock(receiver, hashLock, timelock) -> Alice locks ETH
| └── generates swapId = keccak256(sender, receiver, amount, hashLock, timelock, timestamp)
|
├── withdraw(swapId, secret) -> Bob reveals secret to claim ETH
| └── verifies: keccak256(secret) == hashLock
|
└── refund(swapId) -> Alice reclaims ETH after timeout
└── verifies: block.timestamp > timelock && msg.sender == sender
Swap Flow (Alice ETH <-> Bob BTC)
- Alice calls
lock(bobAddress, hashLock, timelock)on Ethereum to lock ETH - Bob sees the lock and creates a corresponding HTLC on the Bitcoin chain (same hashLock, tighter timelock)
- Alice reveals
secreton the Bitcoin chain to claim the BTC - Bob sees the secret revealed on the BTC chain, uses it to call
withdraw(swapId, secret)on Ethereum to claim the ETH
Key point: Alice must reveal the secret first (on the BTC chain), and only after Bob sees it can he reveal it on the ETH chain -- Bob's BTC->ETH is a one-way information flow (Bob won't voluntarily give up his BTC).
State Machine
None -> Locked -> Withdrawn (Bob reveals secret)
-> Refunded (Alice refunds after timelock)
Core Code
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. Pitfalls Encountered
4.1 swapId Uniqueness
swapId = keccak256(sender, receiver, amount, hashLock, timelock, block.timestamp) includes timestamp to ensure uniqueness. However, two transactions with identical parameters within the same block would collide -- theoretically possible but extremely rare in practice.
4.2 Cross-Chain Timing Attack
Improper timelock settings between Alice and Bob can lead to: Alice's ETH refunds after timeout, but the secret has already been revealed on the BTC side -- Alice gets both the BTC and the refunded ETH.
4.3 Replay Attack Protection
Both withdraw and refund check SwapState.Locked and immediately update the state to Withdrawn/Refunded after execution. This is the classic Checks-Effects-Interactions pattern -- preventing reentrancy and ensuring safe state transitions.
4.4 Secret Information Leak
If the secret is exposed in the calldata of the withdraw transaction, MEV searchers can front-run and copy your secret to unlock the HTLC on the other chain. In real cross-chain swaps, the timing of secret disclosure is critical.
5. Why the Pitfalls Exist
5.1
Although block.timestamp is constant within a single block, swapId contains all 6 parameters. If sender/receiver/amount/hashLock/timelock are all identical and occur in the same block, swapId will collide. Typical solution: add a nonce or use an incrementing counter directly.
5.2
Correct timelock configuration rules:
- The initiator's (Alice's) timelock should be longer than the receiver's (Bob's)
- Bob's timelock on BTC should be shorter than Alice's on ETH (e.g., 24h vs 48h)
- This way, Alice either reveals the secret within 24h (and Bob reveals within 48h), or abandons the swap (both parties refund independently)
5.3
solidity
// Correct CEI: update state first
swap.state = SwapState.Withdrawn;
// Then make the external call
(bool ok, ) = swap.receiver.call{value: swap.amount}("");
If call happens before the state update, an attacker can re-enter the withdraw function and withdraw again.
6. How to Resolve the Pitfalls
- swapId uniqueness: In production, use
swapCount++as the swapId rather than hash computation - Timelock gradient: The initiator's timelock > the receiver's timelock, with at least a 2x gap
- CEI pattern: Always update state before external calls
- Secret protection: Use Flashbots bundles or private transaction pools to relay withdraw transactions
7. Key Technical Points
| Point | Description |
|---|---|
| HTLC | Hash Time-Locked Contract -- the cross-chain atomic swap standard |
| hashLock | keccak256(abi.encodePacked(secret)) -- verifies the preimage |
| timelock | Unix timestamp; after it expires, the sender can refund |
| State machine | None -> Locked -> Withdrawn/Refunded |
| CEI | Checks-Effects-Interactions -- prevents reentrancy |
| swapId design | Includes block.timestamp to prevent collisions |
| Use cases | ETH<->BTC, ETH<->ERC20, Lightning Network routing |