Skip to content
LogoLogo

HTLC swap

A hash time-locked contract pays the receiver if they reveal a secret before a deadline, and refunds the sender afterwards. Two HTLCs on the same hash make an atomic cross-chain or cross-asset swap. Arrekta builds it from the policy language, not a script VM.

htlc(receiver, refund, digest, timeout_height) =
  OR( AND(KEY(receiver), SHA3(digest)),      claim: receiver's signature + 32-byte preimage
      AND(KEY(refund),   AFTER(timeout_height)) )   refund: sender's signature, lock_time ≥ timeout

Steps

  1. The receiver picks a random 32-byte secret and shares digest = sha3_256(secret).
  2. The sender pays to the HTLC policy lock (policyLock(htlc(...))).
  3. Claim: the receiver spends it with their signature and the preimage. The preimage becomes public on chain, which lets the counterparty claim the other leg of the swap.
  4. Refund: after timeout_height, the sender spends it with their signature and a transaction lock_time ≥ timeout_height.

When finalizing, the preimage is passed to the finalizer locally; it is never stored in the PST. In the unused branch, items are left empty. A wrong preimage fails with bad-preimage even in an unused branch.

/**
 * Hash time-locked contract: Bob claims with the secret before the timeout, else Alice refunds.
 *
 *   ARREKTA_URL=http://127.0.0.1:28332 ARREKTA_ADMIN_TOKEN=... npx tsx examples/htlc.ts
 */
import {
  addPstRedeem,
  createClient,
  createPst,
  createTransaction,
  encodeAddress,
  equalLocks,
  finalizePst,
  fromHex,
  generateSeed,
  getChain,
  getCoins,
  htlc,
  keychainFromSeed,
  mine,
  policyLock,
  randomBytes,
  sendPayment,
  sendTransaction,
  sha3_256,
  signPst,
  txid,
} from 'arrekta-sdk';
 
const URL = process.env.ARREKTA_URL ?? 'http://127.0.0.1:28332';
const ADMIN = process.env.ARREKTA_ADMIN_TOKEN;
const SEED = fromHex(process.env.ARREKTA_SEED ?? 'a1'.repeat(32));
 
// 1. Connect; Alice (funder, refund key) and Bob (receiver).
const client = createClient({ url: URL, adminToken: ADMIN });
const chain = await getChain(client);
const alice = keychainFromSeed(SEED, { network: chain.network });
const bob = keychainFromSeed(generateSeed(), { network: chain.network });
if ((await getCoins(client, { wallet: alice })).length === 0) await mine(client, alice.address(0), 101);
 
// 2. Bob's secret and the HTLC policy: Bob + preimage, or Alice after 50 blocks.
const secret = randomBytes(32);
const timeout = (await getChain(client)).height + 50;
const contract = htlc({ receiver: bob.publicKey(0), refund: alice.publicKey(0), digest: sha3_256(secret), timeoutHeight: timeout });
const lock = policyLock(contract);
console.log(`htlc address ${encodeAddress(lock, chain.network)}, refund after height ${timeout}`);
 
// 3. Alice locks 1 ARRK in the contract.
const fund = await sendPayment(client, { wallet: alice, to: encodeAddress(lock, chain.network), amount: '1' });
await mine(client, alice.address(0), 1);
const vout = fund.tx.outputs.findIndex((o) => equalLocks(o.lock, lock));
const coin = fund.tx.outputs[vout]!;
 
// 4. Bob builds the claim and signs it as a PST.
const tx = createTransaction({
  inputs: [{ prevout: { txid: txid(fund.tx), vout }, sequence: 0xffffffff }],
  outputs: [{ asset: coin.asset, amount: coin.amount - 20_000n, lock: bob.lock(1) }],
});
const signed = signPst(addPstRedeem(createPst(tx, [coin], chain.network), [contract]), [bob.key(0)]);
 
// 5. Finalize with the preimage (it never goes into the PST) and broadcast.
const claim = finalizePst(signed, { preimages: [secret] });
console.log('claimed', (await sendTransaction(client, { tx: claim })).txid);
await mine(client, alice.address(0), 1);

Source: sdk/typescript/examples/htlc.ts (updated 2026-11-02) · sdk/python/examples/htlc.py (updated 2026-11-02). Runnable against a regtest node; CI runs every example.

Set the second leg's timeout well before the first leg's, so the party who reveals the secret always has time to claim.