BIP-199 by hand: a code walk through vanta-swap
A line-by-line tour of the Rust HTLC state machine that drives BTC ↔ VANTA atomic swaps. Redeem script bytes, the 2x/1x timelock dance, BIP143 sighash binding, and the witness layout that makes refund and claim routes provably distinct.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/vanta_swap_htlc_walkthrough/
- FILED
- 2026-04-13 22:22 UTC
- REVISED
- 2026-04-17 05:52 UTC
- TIME
- 14 min read
- SERIES
- Vanta In Practice
- TAGS
The companion to Private atomic swaps and the price-discovery problem is a piece of code, not a planning document. The chain-policy decisions about how prices form are upstream of planning/price-discovery-for-private-swaps.md. The actual swap mechanics — the bytes that go on the wire, the script that locks the funds, the witness that unlocks them — live in vanta/vanta-swap, which landed in commit 149c1a41 on 2026-04-13.
This post is a code walk. If you want the policy framing, read the other post first. If you want to understand what an HTLC actually is in 350 lines of Rust, you’re in the right place.
What BIP-199 is, in one paragraph
A hash time-locked contract is a Bitcoin output that pays whoever can produce one of two things:
- The preimage of a public hash (the claim path), or
- The original funder’s signature, but only after a block-height locktime has passed (the refund path).
That’s a four-line redeem script. The protocol around it — generating the secret, picking timelocks, broadcasting in the right order, watching the chain for the preimage reveal — is the BIP-199 atomic-swap state machine. Two parties construct two HTLCs, one on each chain, with the same hash and opposite-asymmetric timelocks. Either both legs settle or both legs refund. There is no third outcome.
The timelock math
The whole thing rests on a piece of arithmetic that is one inequality:
where is the initiator’s locktime (longer) and is the participant’s locktime (shorter, conventionally ). The initiator commits first, with the longer timelock. The participant matches with a shorter timelock. When the initiator claims the participant’s HTLC (revealing the preimage), the participant has at least left to use that preimage on the initiator’s HTLC. If the participant disappears, the initiator waits blocks and refunds. If the initiator disappears, the participant waits and refunds.
The asymmetry matters. If the timelocks were equal, a malicious initiator could refund their own HTLC seconds before the participant claims, racing the participant for one of the funds. The 2x/1x ratio gives the participant a -block buffer to react to the preimage reveal.
In Vanta’s CLI this shows up as a --timelock flag on initiate and a derived value the participant uses, printed as a hint by main.rs:
The participant should use timelock = {timelock / 2} (half of yours).
Half. Not “your locktime minus epsilon.” Half. Because the participant has to pick a value that gives the initiator enough time to claim and leaves the participant a meaningful refund window if the initiator vanishes.
The state machine
Four parties, four states.
stateDiagram-v2 [*] --> Created: initiator generates secret + hash Created --> Funded_I: initiator broadcasts HTLC on chain A (locktime t2) Funded_I --> Funded_P: participant broadcasts HTLC on chain B (locktime t1) Funded_P --> Claimed_P: initiator reveals preimage, claims chain B Claimed_P --> Claimed_I: participant uses revealed preimage on chain A Claimed_I --> [*]: swap complete Funded_I --> Refunded_I: t2 expired, no participant Funded_P --> Refunded_P: t1 expired, initiator never claimed Refunded_I --> [*]: aborted before participant Refunded_P --> [*]: aborted after participant
The two refund paths are the only way the swap fails partially. Either both sides claim — atomic — or both sides refund — atomic. The mid-swap state where exactly one side has settled is unreachable, because the act of claiming chain B publishes the preimage on chain B, and chain A’s HTLC reads the same preimage. (We’ll come back to this.)
The Rust enum that mirrors this is in swap.rs:48:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SwapStatus {
Created,
Funded,
Claimed,
Refunded,
}
Note: there’s no Aborted or Failed. A swap that goes wrong refunds. There is no sad-path state.
The redeem script, byte by byte
Quoting htlc.rs:
OP_IF
OP_SHA256 <hash> OP_EQUALVERIFY <receiver_pubkey> OP_CHECKSIG
OP_ELSE
<locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP <sender_pubkey> OP_CHECKSIG
OP_ENDIF
The IF branch is the claim path. To take it, the spender pushes:
- A signature over the spending transaction
- A 32-byte preimage
0x01(OP_TRUE — selects the IF branch)- The redeem script itself (this is the P2WSH witness convention)
The script then runs: pop 0x01 (truthy → enter IF), OP_SHA256 the preimage, compare against the embedded <hash>, OP_EQUALVERIFY (fail if not equal), then <receiver_pubkey> OP_CHECKSIG against the signature.
The ELSE branch is the refund path:
- A signature
- The empty byte string (OP_FALSE — selects the ELSE branch)
- The redeem script
<locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP is the BIP-65 incantation: pull nLockTime from the spending tx, compare against <locktime>, fail if too early. Then <sender_pubkey> OP_CHECKSIG.
The Rust that builds this lives in redeem_script(&self) -> Vec<u8>. It hand-emits opcodes. Worth quoting — there’s no “script library” here, just a Vec<u8> that gets pushed on:
The output is a fixed-shape ~110-byte script depending on locktime encoding. The P2WSH wrapper is the OP_0 <32-byte sha256(redeem)> two-byte-then-pushdata encoding that makes the witness program — the thing the network sees — a 34-byte commitment to the script’s hash.
p2wsh_script() in htlc.rs does the wrap:
pub fn p2wsh_script(&self) -> Vec<u8> {
let redeem = self.redeem_script();
let hash = sha256(&redeem);
let mut script = Vec::with_capacity(34);
script.push(op::OP_0);
script.push(0x20); // push 32 bytes
script.extend_from_slice(&hash);
script
}
The assert_eq!(p2wsh.len(), 34) test in htlc.rs:198 is the safety net for that: anyone reading the test sees the constant the wire format depends on.
Why P2WSH and not Taproot
Worth a brief note. Taproot is the cool kid in 2026, and a Schnorr-key-aggregation atomic swap could in principle use a single-key-path-spend that looks indistinguishable from a normal transfer. But:
| Option | Cost | Latency | Blast radius | Notes |
|---|---|---|---|---|
| P2WSH (current) | ~110 byte redeem script + 34 byte scriptPubKey | Standard segwit verification path | Any segwit node, any wallet, BIP-199 standard | Boring. Audited. Works on every Bitcoin Core fork. |
| Taproot key-aggregation (MuSig2) | Single 32-byte x-only key on-chain — privacy win | Slightly cheaper to verify | Requires MuSig2 on both ends; smaller wallet surface in 2026 | On the roadmap for v2 once both ends ship Taproot wallets. |
| Taproot script-path | Two leaf scripts (claim + refund) | Still BIP-65 path on refund | Slightly better privacy than P2WSH; not key-path so still distinguishable | Not a meaningful upgrade over P2WSH for this use case. |
The simplest thing that could work is P2WSH. v1 ships P2WSH. The Taproot-key-path version is the v2 conversation, which I expect to come up the same time the shielded-VANTA-leg work lands.
The sighash dance
This is the part of HTLC code that’s easy to get wrong and impossible to debug when you do. The witness script is sighashed differently in segwit than in legacy, and the spending side has to compute the exact same sighash the verifier will check.
The relevant code is in swap.rs:215:
// Sign: BIP143 sighash over the witness program (the redeem script)
let redeem_script = state.contract.redeem_script();
let witness_script = ScriptBuf::from_bytes(redeem_script.clone());
let privkey = PrivateKey::from_wif(privkey_wif).context("invalid WIF private key")?;
let secp = Secp256k1::new();
let mut sighash_cache = SighashCache::new(&spending_tx);
let sighash = sighash_cache
.p2wsh_signature_hash(0, &witness_script, Amount::from_sat(htlc_value), EcdsaSighashType::All)
.context("sighash computation failed")?;
let msg = secp256k1::Message::from_digest(sighash.to_byte_array());
let sig = secp.sign_ecdsa(&msg, &privkey.inner);
// DER-encode signature + sighash type byte
let mut sig_bytes = sig.serialize_der().to_vec();
sig_bytes.push(EcdsaSighashType::All as u8);
Three things to notice:
p2wsh_signature_hash, not legacy_signature_hash. This is BIP143 — the segwit sighash. It hashes the input value as part of the sighash so a signature that’s valid for “spend X satoshis” can never be replayed for “spend Y satoshis.” A legacy sighash doesn’t include the value, which is why pre-segwit malleable signatures were a thing.
Amount::from_sat(htlc_value). The funding amount has to be exact. Off by one satoshi and the sighash mismatches, the signature is rejected, and the broadcast fails with a generic mandatory-script-verify-flag-failed from bitcoind. Welcome to the worst error message in cryptocurrency.
EcdsaSighashType::All — the standard “sign every input and every output.” The only time you’d want a different sighash type for an HTLC is if you wanted partial-input flexibility, which atomic swaps don’t.
The Rust bitcoin crate ships SighashCache, which precomputes the parts of the sighash that don’t change per-input (the input/output digests) so you can sign multiple inputs without redoing the hash. We have one input, so the cache is trivial — but the API is the same and the per-input computation is correct.
Witness layout: claim vs. refund
The two witnesses look almost identical and they have to be carefully different. Both are stacks; the bottom of the stack is the redeem script, and what’s above it controls which branch runs.
Claim witness, from claim_witness in htlc.rs:97:
pub fn claim_witness(&self, signature: &[u8], preimage: &[u8; 32]) -> Vec<Vec<u8>> {
vec![
signature.to_vec(),
preimage.to_vec(),
vec![0x01], // OP_TRUE — take the IF branch
self.redeem_script(),
]
}
Four items. Bottom-to-top of stack: redeem script, OP_TRUE, preimage, signature. After the OP_PUSHDATA consumes the script reveal, execution begins at OP_IF. The next pop is 0x01 → truthy → take the IF branch. The IF branch consumes the preimage with OP_SHA256, compares against the embedded hash via OP_EQUALVERIFY, and then the receiver pubkey + CHECKSIG consumes the signature.
Refund witness, from refund_witness in htlc.rs:107:
pub fn refund_witness(&self, signature: &[u8]) -> Vec<Vec<u8>> {
vec![
signature.to_vec(),
vec![], // empty — take the ELSE branch
self.redeem_script(),
]
}
Three items: redeem script, empty bytes (which Bitcoin script interprets as OP_FALSE), signature. OP_IF pops the empty bytes → falsy → take ELSE. The ELSE branch checks <locktime> OP_CHECKLOCKTIMEVERIFY against the spending transaction’s nLockTime, drops the locktime, then verifies the sender’s signature.
Two failure modes are interesting:
Claim with the wrong preimage. OP_SHA256 hashes whatever you push. OP_EQUALVERIFY fails. The script aborts with a verification error. The transaction is rejected. The HTLC is still spendable.
Refund before locktime expires. OP_CHECKLOCKTIMEVERIFY pulls nLockTime from the spending transaction. If it’s less than the embedded locktime, the script aborts. The Rust code in swap.rs:266 preflights this check before broadcast:
let current_height = rpc::get_block_height(&client)?;
if current_height < state.contract.locktime as u64 {
anyhow::bail!(
"Locktime not reached: current height {} < locktime {}. Wait {} more blocks.",
current_height, state.contract.locktime,
state.contract.locktime as u64 - current_height,
);
}
You could just let the broadcast fail. Better UX is to refuse to construct the transaction in the first place.
Why the preimage reveal is atomic
Worth dwelling on this because it’s the part of HTLC theory that students always blink at. If Alice claims Bob’s HTLC by revealing s, why does that make Bob’s claim of Alice’s HTLC inevitable?
Because the preimage s is now on Chain B’s mempool/blockchain in plaintext. Any node, any explorer, any indexer running on Chain B can extract s from the witness stack of Alice’s claim transaction. Bob’s wallet polls Chain B for the spending of his HTLC, finds s, and now has the secret needed to spend Alice’s HTLC on Chain A.
The “atomic” property is that revealing s to Chain B is necessarily publishing it. There is no way to construct a P2WSH spend that hides the witness data — the witness stack is part of the transaction, the transaction is part of the block, the block is gossiped to the network. By the time Alice’s claim is mined, Bob already knows.
If Alice never claims (refund path), s is never revealed. After blocks, Bob refunds his HTLC. After blocks, Alice refunds hers. Both got their original funds back. No third outcome.
The math: the only way the swap settles partially is if Alice claims chain B and then somehow prevents Bob from claiming chain A within the window . The 2x/1x ratio is what makes that window large enough that Bob’s ordinary chain-watching software can detect, parse, and broadcast inside it.
What the wallet doesn’t do (yet)
A fully shielded VANTA leg — where the HTLC’s amount is hidden — is the missing piece. Today, the value of the P2WSH output on the VANTA chain is plaintext, exactly as it is on Bitcoin’s. From the price-discovery post:
the current swap implementation is fully transparent on both sides
The plan, gestured at in planning/price-discovery-for-private-swaps.md, is to replace the P2WSH output on the VANTA leg with a witness v2 commitment whose amount is hidden behind a Pedersen blinding. The HTLC pubkey path becomes a shielded-pool note; the claim/refund logic becomes a ZK proof of pubkey ownership + preimage knowledge, instead of a script-level CHECKSIG. Same atomic property; different cryptographic primitive.
That work is real, and it’s not in the current vanta-swap. The vanta-swap we have today is the simplest thing that could possibly work, in 350 lines of Rust, with the same script semantics on both chains. The shielded version is a different post.
What I changed my mind about
The first version of htlc.rs used the bitcoin::ScriptBuf::builder() API — the abstraction-layer way of constructing a Bitcoin script. It was 30% shorter and 100% less debuggable. When the OP_CHECKLOCKTIMEVERIFY encoding was wrong (script-number encoding for negative numbers and 128 has a sign-bit edge case the builder API didn’t trigger), I had to rewrite half of it as raw byte pushes anyway to instrument the failure.
The version that ships is the boring Vec<u8> with explicit opcode pushes. Every byte is visible. When something doesn’t verify, I read the script in a hex dumper and spot the wrong byte. That’s a lower abstraction level than I’d ordinarily reach for, but BIP-199 is a wire format, and wire formats want to be visible.
The script-number encoding bug was specifically encode_script_number(128) returning [0x80] (which Bitcoin script interprets as -0) instead of [0x80, 0x00] (which encodes positive 128). The test in htlc.rs:236 is the regression catch:
assert_eq!(encode_script_number(128), vec![0x80u8, 0x00]);
I’d estimate I’d have caught that bug six hours faster if I’d been building the script as bytes from the start.
Further reading
vanta/vanta-swap/src/htlc.rs— script construction + witness builder + testsvanta/vanta-swap/src/swap.rs— initiate / participate / claim / refund state machinevanta/vanta-swap/src/main.rs— CLI surface and the timelock-halving hint- BIP-199 — the upstream HTLC pattern
- BIP-143 — segwit sighash, the BIP this signing path implements
- Private atomic swaps and the price-discovery problem — the policy framing the implementation rides on
- Vanta: a Bitcoin fork with ZK at consensus — the chain doing the verification