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
- The receiver picks a random 32-byte secret and shares
digest = sha3_256(secret). - The sender pays to the HTLC policy lock (
policyLock(htlc(...))). - 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.
- Refund: after
timeout_height, the sender spends it with their signature and a transactionlock_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);"""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=... python examples/htlc.py
"""
import os
import secrets
from arrekta_sdk import (
Client,
OutPoint,
Transaction,
TxIn,
TxOut,
create_pst,
encode_address,
finalize_pst,
generate_seed,
htlc,
keychain_from_seed,
policy_lock,
sha3_256,
sign_pst,
)
URL = os.environ.get("ARREKTA_URL", "http://127.0.0.1:28332")
ADMIN = os.environ.get("ARREKTA_ADMIN_TOKEN")
SEED = bytes.fromhex(os.environ.get("ARREKTA_SEED", "a1" * 32))
def main() -> None:
# 1. Connect; Alice (funder, refund key) and Bob (receiver).
client = Client(URL, admin_token=ADMIN)
chain = client.get_chain()
alice = keychain_from_seed(SEED, network=chain["network"])
bob = keychain_from_seed(generate_seed(), network=chain["network"])
if not client.get_coins(alice):
client.mine(alice.address(0), 101)
# 2. Bob's secret and the HTLC policy: Bob + preimage, or Alice after 50 blocks.
secret = secrets.token_bytes(32)
timeout = client.get_chain()["height"] + 50
contract = htlc(bob.public_key(0), alice.public_key(0), sha3_256(secret), timeout)
lock = policy_lock(contract)
print(f"htlc address {encode_address(lock, chain['network'])}, refund after height {timeout}")
# 3. Alice locks 1 ARRK in the contract.
fund = client.send_payment(alice, encode_address(lock, chain["network"]), "1")
client.mine(alice.address(0), 1)
vout = next(i for i, o in enumerate(fund.tx.outputs) if o.lock == lock)
coin = fund.tx.outputs[vout]
# 4. Bob builds the claim and signs it as a PST.
tx = Transaction(inputs=(TxIn(OutPoint(fund.tx.txid, vout)),),
outputs=(TxOut(coin.asset, coin.amount - 20_000, bob.lock(1)),))
signed = sign_pst(create_pst(tx, [coin], chain["network"], redeem=[contract]), bob.account_at(0))
# 5. Finalize with the preimage (it never goes into the PST) and broadcast.
claim = finalize_pst(signed, preimages=[secret])
print("claimed", client.send_transaction(claim)["txid"])
client.mine(alice.address(0), 1)
if __name__ == "__main__":
main()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.