Skip to main content

Install the SDK

If you haven’t already, install the SDK and set up your environment
A simplified Hash Time Locked Contract with two spending paths using Bitcoin Tapscript opcodes.
PathConditionWhen to use
Collaborativepreimage + receiver signature + server signatureInstant offchain claim
Unilateralpreimage + receiver signature (after exit delay)Fallback if server is unresponsive

Build the Tapscript

import { Script } from '@scure/btc-signer';
import { hex } from '@scure/base';
import {
  RestArkProvider,
  RestIndexerProvider,
  SingleKey,
  VtxoScript,
  networks
} from '@arkade-os/sdk';

// Setup
const arkProvider = new RestArkProvider('https://mutinynet.arkade.sh');
const info = await arkProvider.getInfo();
const serverPubkey = hex.decode(info.signerPubkey).slice(1); // x-only
const exitDelay = BigInt(info.exitDelay);

// Identities
const receiver = SingleKey.fromRandomBytes();
const receiverPubkey = await receiver.xOnlyPublicKey();

// The secret (in production, receiver generates this)
const preimage = crypto.getRandomValues(new Uint8Array(32));
const preimageHash = await crypto.subtle.digest('SHA-256', preimage)
  .then(buf => new Uint8Array(buf).slice(0, 20)); // HASH160 expects 20 bytes

Collaborative path (offchain, instant)

The receiver reveals the preimage and signs with the server:
OP_HASH160 <preimageHash> OP_EQUALVERIFY <receiverPubkey> OP_CHECKSIGVERIFY <serverPubkey> OP_CHECKSIG
// Collaborative: preimage + receiverSig + serverSig
const collaborativePath = Script.encode([
  'HASH160',
  preimageHash,
  'EQUALVERIFY',
  receiverPubkey,
  'CHECKSIGVERIFY',
  serverPubkey,
  'CHECKSIG'
]);

Unilateral path (onchain, timelocked)

After the exit delay, the receiver can claim without server cooperation:
<exitDelay> OP_CHECKSEQUENCEVERIFY OP_DROP OP_HASH160 <preimageHash> OP_EQUALVERIFY <receiverPubkey> OP_CHECKSIG
// Unilateral: after exitDelay, preimage + receiverSig
const unilateralPath = Script.encode([
  exitDelay,
  'CHECKSEQUENCEVERIFY',
  'DROP',
  'HASH160',
  preimageHash,
  'EQUALVERIFY',
  receiverPubkey,
  'CHECKSIG'
]);

Assemble the VtxoScript

// Build the VTXO with both paths
const vhtlcScript = new VtxoScript([collaborativePath, unilateralPath]);
const vhtlcAddress = vhtlcScript.address(networks.mutinynet.hrp, serverPubkey).encode();

console.log('VHTLC address:', vhtlcAddress);
console.log('Preimage (save this!):', hex.encode(preimage));

Spending the VHTLC

Collaborative claim (receiver + server)

import {
  buildOffchainTx,
  CSVMultisigTapscript,
  Transaction
} from '@arkade-os/sdk';
import { base64 } from '@scure/base';

// Query the VTXO
const indexerProvider = new RestIndexerProvider('https://mutinynet.arkade.sh');
const result = await indexerProvider.getVtxos({
  scripts: [hex.encode(vhtlcScript.pkScript)],
  spendableOnly: true,
});

const vtxo = result.vtxos[0];

// Recipient script (where funds go after claim)
const recipientScript = /* your destination VtxoScript */;

// Build the transaction
const serverUnrollScript = CSVMultisigTapscript.decode(
  hex.decode(info.checkpointTapscript)
);

const input = {
  txid: vtxo.txid,
  vout: vtxo.vout,
  value: vtxo.value,
  tapLeafScript: vhtlcScript.findLeaf(hex.encode(collaborativePath)),
  tapTree: vhtlcScript.encode(),
};

const outputs = [{ amount: vtxo.value, script: recipientScript.pkScript }];

const { arkTx, checkpoints } = buildOffchainTx(
  [input],
  outputs,
  serverUnrollScript
);

// Sign with receiver
const psbt = arkTx.toPSBT();
const tx = Transaction.fromPSBT(psbt);
const signedTx = await receiver.sign(tx);

// Submit to server (server co-signs)
const { arkTxid, signedCheckpointTxs } = await arkProvider.submitTx(
  base64.encode(signedTx.toPSBT()),
  checkpoints.map(c => base64.encode(c.toPSBT()))
);

// Finalize checkpoints
const finalCheckpoints = await Promise.all(
  signedCheckpointTxs.map(async (cpB64) => {
    const cpTx = Transaction.fromPSBT(base64.decode(cpB64));
    const signed = await receiver.sign(cpTx, [0]);
    return base64.encode(signed.toPSBT());
  })
);

await arkProvider.finalizeTx(arkTxid, finalCheckpoints);
console.log('VHTLC claimed!', arkTxid);
The witness for the collaborative path includes: <preimage> <receiverSig> <serverSig>. The server validates the preimage hash before co-signing.

Script breakdown

OpcodeEffect
HASH160Hash the top stack element (preimage)
<preimageHash>Push the expected hash
EQUALVERIFYFail if hashes don’t match
CHECKSIGVERIFYVerify signature against pubkey, continue
CHECKSIGFinal signature check
CHECKSEQUENCEVERIFYEnforce relative timelock
DROPRemove timelock value from stack
For production code, consider using the TypeScript SDK’s VHTLC.Script class which handles validation and provides a cleaner API.

Next steps