Skip to content
On this page

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:

  1. Hash Lock: Only someone who knows the secret preimage s can unlock the funds (hash(s) == hashLock)
  2. 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)

  1. Alice calls lock(bobAddress, hashLock, timelock) on Ethereum to lock ETH
  2. Bob sees the lock and creates a corresponding HTLC on the Bitcoin chain (same hashLock, tighter timelock)
  3. Alice reveals secret on the Bitcoin chain to claim the BTC
  4. 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

PointDescription
HTLCHash Time-Locked Contract -- the cross-chain atomic swap standard
hashLockkeccak256(abi.encodePacked(secret)) -- verifies the preimage
timelockUnix timestamp; after it expires, the sender can refund
State machineNone -> Locked -> Withdrawn/Refunded
CEIChecks-Effects-Interactions -- prevents reentrancy
swapId designIncludes block.timestamp to prevent collisions
Use casesETH<->BTC, ETH<->ERC20, Lightning Network routing

Built with AiAda