2026-06-07 · 14 min read
Coinswap: Swapping Coin Histories Without Handing Over Custody
A story-shaped technical tour of Coinswap: maker discovery, fidelity bonds, legacy and taproot contracts, and the current demo apps.

Introduction:
Hello to whoever is reading this article. I am Sarthak, or you can call me Egao. It's been more than a year since I am contributing to Coinswap,I started as a Summer of Bitcoin ’25 Fellow, and since then, my work has been supported by Bitshala through their developer fellowship. This is my attempt to provide a technical introduction to what Coinswap is, how the current implementation fits together. The protocol details are serious, but learning Bitcoin engineering can still be fun.
So, grab a coffee, let's dive into Coinswap.
TL;DR :
Coinswap starts with a simple question:
"What if coin ownership could move without making the transfer look like a normal direct spend?"
At first the idea sounds small. Then you try to build it and run into the real parts: UTXOs, signatures, scripts, timelocks, network reachability, fee pressure, counterparties who may disappear, and wallets that still need to recover cleanly.
Coinswap does not ask users to hand funds to an operator. It arranges a swap so each participant has a cooperative path and a recovery path. If everyone behaves, the swap completes. If someone goes offline or acts badly, the transaction logic still has a way to unwind.
Most of the design is careful use of locks, keys, scripts, incentives, and conservative failure handling.

The Short Version
Coinswap is a self-custodial atomic swap protocol for Bitcoin. A taker initiates a swap, two or more makers participate as counter-parties, and the protocol uses contract transactions so coin ownership can move through a route instead of a simple direct-payment shape.
The privacy point is subtle. Bitcoin's public ledger is not a bug; it is how the system stays verifiable. Coinswap works within that model and gives users another ownership-transfer pattern. After a successful swap, the naive story of "input A paid output B" becomes harder to treat as certainty.
Not impossible to analyze. Not a promise of perfect anonymity. But meaningfully less obvious, which is already useful in a world where chain analysis often treats guesses as conclusions.
The Cast
There are two core roles.
The taker starts the swap. It chooses the amount, discovers makers, builds a route, negotiates terms, creates funding transactions, coordinates the protocol messages, and drives finalization.
The maker answers swap requests. It publishes enough information to be discoverable, advertises fees and limits, proves it has locked capital in a fidelity bond, and participates in the contract exchange when selected.
The route can have more than one maker:
Taker input wallet
|
v
Maker A
|
v
Maker B
|
v
Taker receive wallet
The taker sees the route. Each maker only needs to handle its local part of the swap. That separation avoids creating one all-knowing coordinator.

The Moving Parts
A useful mental model is to split Coinswap into five layers.
- Discovery finds candidate makers.
- Offer sync asks reachable makers for terms.
- Route selection chooses makers that can satisfy the amount, protocol version, fees, confirmations, and timelocks.
- Contract exchange creates the on-chain safety structure.
- Finalization and recovery either complete the swap or return funds through the correct fallback path.
The list is cleaner than the implementation feels. In practice, these layers touch Tor, Bitcoin Core, wallet state, Nostr relays, fidelity proofs, maker state, swap tracking, and recovery loops.
Discovery: Nostr Points, Bitcoin Proves
Coinswap needs a marketplace, but a central maker directory would be an awkward place to put trust. The current design keeps discovery useful without making the relay the authority.
The flow looks like this:
Maker
|
| creates fidelity bond tx
| OP_RETURN: endpoint#expiry_height
v
Bitcoin chain
Maker
|
| publishes Nostr event content: txid:vout
v
Nostr relays
Taker watcher
|
| receives txid:vout, fetches tx from Bitcoin Core
| parses OP_RETURN, checks freshness, stores verified candidate
v
Local maker registry
Offer sync
|
| connects to maker over Tor, requests offer, verifies fidelity proof
v
Offerbook
The OP_RETURN announcement currently encodes the maker endpoint and expiry height as:
<maker-endpoint>#<expiry-height>
On normal network builds, the endpoint is normalized as an onion address.
The Nostr event does not carry the full offer. It carries a pointer: txid:vout. The event kind is network-specific, so Bitcoin mainnet, signet, regtest, testnet, and testnet4 can be separated. The current implementation uses 37778 for mainnet, 37779 for signet, 37780 for regtest, 37781 for testnet, and 37782 for testnet4. Events also carry expiration, and the taker ignores stale or expired announcements. In the current code, the event expiration window is 24 hours.
Nostr helps with reachability, but the blockchain remains the source of truth. A relay can say, "Here is a bond outpoint." The taker still asks Bitcoin, "Does this transaction exist, does it contain the expected announcement, and does it describe a maker candidate that can be verified?"
The trust boundary is useful: relays can relay; they do not become the authority.
What A Maker Offer Contains
Once the taker has candidate maker addresses, it connects over Tor and asks for offers. A maker offer is more than a reachable address and a fee quote.
The offer includes practical swap terms:
- fixed base fee
- amount-relative fee
- time-relative fee
- required confirmations
- minimum contract locktime
- minimum and maximum swap size
- supported protocol path
- tweakable public key and chain code for swap address derivation
- fidelity proof
The taker can use those terms to filter the route before committing funds. A maker that is unreachable, outside the amount range, too expensive, unsupported for the chosen protocol, or unable to pass fidelity verification does not become part of the final path.
Maker selection is not just "pick the cheapest row." Fees matter, but so do bond strength, expiry, reliability, confirmation requirements, and how much slack the route has if a candidate fails before funding is broadcast.
Coin Selection And Fee Math
Before the taker broadcasts funding transactions, the wallet has to choose which UTXOs will fund the swap. Coinswap uses the rust-coinselect library, but there is Coinswap-specific filtering around it.
The wallet does not treat every UTXO as spendable. Coin selection only considers regular wallet coins and swept incoming swap coins. It filters out locked or explicitly excluded outpoints, and it does not include fidelity bonds, outgoing swapcoins, hashlock contracts, or timelock contracts. Manual selection is also constrained: if a user manually selects coins, the selection cannot mix regular wallet UTXOs and swap UTXOs.
The current code tries UTXO types separately. If regular wallet coins can cover the target plus estimated fees, it tries those first. Swept incoming swap coins are tried as a separate pool. It does not silently combine both pools into one selection.
There is also an address grouping step before the library coin selection runs. UTXOs from reused addresses are grouped together and selected as a group. Single-UTXO addresses are then passed into rust-coinselect as output groups. That keeps the wallet from pretending that two UTXOs from the same reused address are unrelated.
For early fee estimation, the wallet uses P2TR-oriented weight constants:
P2TR input weight = 231 WU
P2TR output weight = 172 WU
base tx weight = 43 WU
vbytes = ceil(weight / 4)
If the user manually selected inputs, the estimated input count is the manual input count. Otherwise the wallet starts with a two-input estimate. The rough estimate is:
estimated_weight =
input_count * 231
+ 43
+ 172 // target output
+ 172 // change output
estimated_vbytes = ceil(estimated_weight / 4)
estimated_fee = estimated_vbytes * feerate
Coinswap currently defines MIN_FEE_RATE as 2.0 sats/vbyte. Code paths that use calculate_fee_sats(vbytes) are using that minimum fee rate:
miner_fee_sats = vbytes * 2
Inside coin selection, the target passed to the selection algorithm is the amount plus the estimated fee, minus anything already covered by grouped address selection:
remaining_target = max(amount + estimated_fee - grouped_total, 0)
The selection options also account for change. Change is not free: the wallet pays to create a change output now and assumes a future cost to spend that change later. In the current code:
change_cost =
fee(change_output_vbytes, current_feerate)
+ fee(p2tr_input_vbytes, long_term_feerate)
long_term_feerate = 10 sats/vbyte
min_change_value = 294 sats
Maker fees are separate from miner fees. A maker advertises three fee components: fixed base fee, amount-relative fee, and time-relative fee. For a hop, the current formula is:
maker_fee =
ceil(
base_fee
+ (amount_sats * amount_relative_fee_pct) / 100
+ (amount_sats * refund_locktime * time_relative_fee_pct) / 100
)
From the maker side, those three values come from maker configuration and are copied into the offer response:
base_fee
amount_relative_fee_pct
time_relative_fee_pct
The default maker config currently uses:
base_fee = 500 sats
amount_relative_fee = 0.0025%
time_relative_fee = 0.0001%
These are defaults, not protocol constants. A maker operator can configure them. When the taker asks for an offer, the maker also includes min_size, max_size, required_confirms, supported protocol versions, tweakable key material, and the fidelity proof. So the taker is not only comparing fees; it is comparing whether this maker can serve the requested swap at all.
The taker rejects obviously bad fee offers before using them in a route. The amount-relative and time-relative percentages must be finite, non-negative, and below 100. The base fee cannot be larger than the send amount. The maker's minimum and maximum swap sizes must also contain the taker's send amount.
When the maker receives concrete swap details, it checks its own side too. It verifies that the requested amount is within its configured bounds, checks that it has enough liquidity, checks the timelock rules for the selected protocol, and then calculates the swap fee with the same formula:
maker_receives_fee_sats =
ceil(base_fee + amount_fee + time_fee)
amount_fee =
(swap_amount_sats * amount_relative_fee_pct) / 100
time_fee =
(swap_amount_sats * timelock_blocks * time_relative_fee_pct) / 100
The refund locktime is staggered by hop. On normal builds, the innermost hop starts at 20 blocks and each outer hop adds 20 blocks:
refund_locktime_i = 20 + 20 * (maker_count - i - 1)
So the taker estimates the amount that reaches each maker hop like this:
amount_0 = send_amount
amount_{i+1} = max(amount_i - maker_fee_i, 0)
For verification, the taker also applies a fee margin when checking expected hop amounts. The current margin is 1.5, so the taker allows the actual deduction to be up to 50% higher than the advertised maker fee before treating it as suspicious. That margin exists because the route also has mining fees and rounding effects.
Fidelity Bonds: Costly Identity, Not Custody
A fidelity bond is a time-locked Bitcoin UTXO used for Sybil resistance. It does not make a maker honest, but it makes fake maker identities cost real locked capital.
The current fidelity bond redeem script is compact:
<pubkey> OP_CHECKSIGVERIFY <locktime> OP_CLTV
The taker does not accept a bond only because it appears in an offer response. The verification checks several things:
- the bond timelock is within the accepted range, currently 12,960 to 25,920 blocks on normal builds
- the current chain height has not passed the bond locktime
- the advertised bond outpoint exists in the fetched Bitcoin transaction
- the transaction output matches the expected fidelity redeem script
- the certificate hash binds the bond outpoint, bond pubkey, locktime, amount, maker address, and maker tweakable point
- the certificate signature verifies under the bond public key
- the bond public key corresponds to the maker's advertised tweakable point and chain code
That final point is easy to miss. The maker is not merely showing a random timelocked UTXO to the taker. The proof ties the bond to the identity material used for swap address derivation. The route is built from candidates that can connect their advertisement, their bond, and their cryptographic identity.
The certificate message is also deliberately specific. In the current code, the hash is built from a Bitcoin Signed Message payload shaped like:
fidelity-bond-cert|<outpoint>|<bond_pubkey>|<locktime>|<amount>|<maker_addr>|<tweakable_point>
That binding prevents the bond from being treated as a generic badge that can be casually copied between maker identities.
The Swap State Machine
The user-facing story is "click swap and wait." Underneath, the swap moves through several phases.
A simplified state progression looks like this:
MakersDiscovered
|
v
Negotiated
|
v
FundingCreated
|
v
FundsBroadcast <- after this, recovery matters
|
v
ContractsExchanged
|
v
Finalizing
|
v
PrivkeysForwarded
|
v
Completed
Before funds are broadcast, a failed maker can often be replaced and the route can be rebuilt. After funds are on-chain, the wallet has to survive interruption, failed peers, and recovery timing.
What Actually Happens During A Swap
A simplified successful swap goes through this shape:
- The taker syncs its wallet and offerbook.
- The taker selects makers and negotiates swap details.
- Funding transactions are created for the outgoing side.
- Makers and taker exchange contract data and signatures.
- Funding reaches the required confirmation depth.
- Contract transactions become the safety net around each hop.
- Private key handover finalizes the route.
- Incoming swapcoins are swept to the taker's wallet.
- A report records the result.
The word contract is doing real work here. Coinswap is not based on "please be online and kind." It uses scripts that define who can spend, under what condition, and after which timeout.
There are two essential recovery ideas:
- hashlock path: a party can spend by revealing the right preimage
- timelock path: a party can recover after waiting long enough
These paths are what let a swap fail without turning into a custody promise.
Legacy Contract Shape
Coinswap has a legacy script path, often described as the P2SH-style contract family in older material. In the current code, the funding and contract outputs are witness-script based, so it is more precise to think "legacy ECDSA/P2WSH contract flow" rather than old bare P2SH.
The funding side uses a 2-of-2 multisig redeem script:
2 <pubkey_a> <pubkey_b> 2 OP_CHECKMULTISIG
The contract redeem script then gives the receiver a hashlock spend and the sender a timelock recovery spend. Stripped to the useful shape, it looks like this:
OP_SIZE
OP_SWAP
OP_HASH160 <HASH160(preimage)> OP_EQUAL
OP_IF
<hashlock_pubkey> 32 0
OP_ELSE
<timelock_pubkey> 0 <relative_locktime>
OP_ENDIF
OP_CSV
OP_DROP
OP_ROT
OP_EQUALVERIFY
OP_CHECKSIG
The script also protects against some classic footguns. It checks preimage size, uses OP_EQUAL before OP_IF, and validates the recovery branch through OP_CHECKSEQUENCEVERIFY. The details are unglamorous, but each check keeps a known failure mode out of the happy path.
Taproot Contract Shape
The Taproot path uses P2TR outputs and separates the cooperative path from the recovery leaves. Conceptually:
P2TR output
internal key: aggregate/cooperative key material
script tree:
hashlock leaf
timelock leaf
The hashlock leaf is compact:
OP_SHA256 <SHA256(preimage)> OP_EQUALVERIFY <receiver_xonly_pubkey> OP_CHECKSIG
The timelock leaf is also compact:
<absolute_locktime> OP_CLTV OP_DROP <sender_xonly_pubkey> OP_CHECKSIG
In the cooperative case, Taproot keeps the chain footprint cleaner. In the recovery case, only the script path that is actually used needs to be revealed. Cooperation reveals less; recovery still stays explicit.
There is also a timelock difference worth remembering: the legacy flow uses relative CSV-style recovery, while the current Taproot flow uses absolute CLTV-style recovery. That sounds small until you are debugging a swap and one side thinks "later" means "after N blocks" while the other thinks "later" means "at block height H."
Recovery Is Part Of The Protocol
Coinswap recovery is not an afterthought bolted onto the README. The wallet tracks outgoing contracts, incoming swapcoins, watch-only contracts, and persisted swap state so recovery can resume across process restarts.
If a swap fails after funding hits the chain, the taker & maker can run recovery in two directions:
- incoming swapcoins can be swept through the hashlock path when the needed preimage/key material is available
- outgoing contract funds can be recovered through the timelock path after the required delay
The implementation also distinguishes contracts that were spent cooperatively, contracts that need hashlock or timelock action, and contracts that can be discarded because they were never broadcast or are already spent.
That recovery state is what makes the demo more than a one-shot happy-path run.
Demo Stack: Maker Dashboard First, Taker App Second
For the current demo flow, start with the Maker Dashboard and then use the Taker App:
That order matters because the Maker Dashboard setup brings up its own Bitcoin Core and Tor environment. The Taker App can then connect to that environment for the swap demo.
The Maker Dashboard makes the maker side visible: configuration, wallet state, fidelity funding, maker runtime status, reports, and operational feedback. The Taker App shows the initiator side: wallet setup, offer discovery, maker selection, swap configuration, progress, and final reporting.
The Taker App is especially useful because it uses the Coinswap FFI layer, with Rust-backed taker functionality exposed through citadel-tech/coinswap-ffi. In the JavaScript path, the desktop app can call the Rust taker client through the NAPI binding instead of reimplementing protocol logic in the UI. FFI bindings for other languages are available as well.
The common taker flow exposed by the FFI is close to:
setup logging
init/load taker wallet
sync and save wallet
sync offerbook and wait
fetch offers
prepare_coinswap -> returns swap_id
start_coinswap(swap_id)
sync and save again
Maker Dashboard Demo
Watch for fidelity funding, maker startup, runtime state, and the dashboard's view of swap reports.
The dashboard is helpful because the maker role is otherwise easy to understate. A maker is not only "someone with liquidity." It is a running service with wallet state, a fidelity bond, network reachability, configured fees, and enough operational surface area to deserve a dashboard.
Taker App Demo
Watch for offerbook sync, maker selection, swap preparation, contract setup, private key handover, and the completion report.
The taker demo turns "multi-hop atomic swap with recovery paths" into a visible sequence. You see the offerbook, the route, the swap state, and the final accounting. The protocol starts looking like software instead of a pile of terms.

Summary
Coinswap is a composition of small, checkable claims.
Nostr can announce where to look. Bitcoin proves whether the bond exists. The maker offer states the terms. The fidelity proof binds identity to locked capital. The taker builds and verifies the route. Scripts define the spend paths. The wallet tracks enough state to recover when the happy path stops being happy.
No single component does all the privacy work. The design is a sequence of limited responsibilities, with concrete checks behind the important claims.
How Can You Contribute to Coinswap
After reading this, the most useful next step is to play with the demo apps. Try to understand the process visually and get a feel for how everything works.
A good first step is to build apps using our FFI bindings. We already have scaffolds available for React Native and Swift apps.
You can plug the FFI agent docs into your coding agent, start experimenting, and build applications that support Coinswap.
Further Reading and Questions
- Coinswap Website: https://citadelfoss.xyz
- DeepWiki Coinswap overview: https://deepwiki.com/citadel-tech/coinswap
- Chris Belcher's Maxwell-Belcher Coinswap protocol gist: https://gist.github.com/chris-belcher/9144bd57a91c194e332fb5ca371d0964
- Coinswap protocol specification: https://github.com/citadel-tech/Coinswap-Protocol-Specification
- Coinswap repo: https://github.com/citadel-tech/coinswap
- Coinswap FFI: https://github.com/citadel-tech/coinswap-ffi