arkade
(Asset Swaps).
Any change to these bytes changes every address on both sides of a swap.
A version mismatch surfaces as a refused quote at the address comparison, not
as lost funds.
Taproot Envelope
Both contracts are taproot outputs with no key-path spend: the internal key is the BIP-341 NUMS point, so the only way to move the money is a leaf.
The tree is assembled with btcd’s algorithm
(
txscript.AssembleTaprootScriptTree), not a Huffman builder: leaves are paired
left to right, an odd trailing leaf merges into the last branch built, and the
resulting branches are merged FIFO. For leaf counts that are not a power of two
a Huffman tree produces a different merkle root, a different output key, and a
spend the Arkade server rejects.
Leaf order is therefore part of the address. The orders below are normative.
An Arkade address wraps the output key together with the Arkade server key; the
same tree also has an ordinary on-chain taproot address, and both are derived
from the identical output key.
Leaf Grammar
Every leaf of both contracts is one of five closures. Signature checks areCHECKSIGVERIFY for each signer except the last, which is CHECKSIG.
Numbers are pushed as minimal script-nums, little-endian:
1 through 16
use OP_1–OP_16, everything else is a data push. A seconds-type relative
timelock encodes as (1 << 22) | (seconds / 512), so 4096 seconds is
0x400008 and pushes as 03 08 00 40. An absolute locktime pushes the same
way: 1800277200 is 04 d0 0c 4e 6b.
The preimage condition
All three preimage-gated VHTLC leaves (claim, unilateralClaim,
nonInteractiveClaim) share one condition fragment, and the current
construction length-checks the witness item before hashing it:
<h160> is ripemd160(sha256(P)), 20 bytes — OP_HASH160 of the witness item
reproduces it from P itself. The wire carries sha256(P) as payment_hash;
the second hash happens locally on both sides.
Without the OP_SIZE 32 OP_EQUALVERIFY prefix the claim leaves accept any
witness value whose HASH160 matches, whatever its length. These bytes are
VHTLC.ScriptV2; the SDK also exports VHTLC.Script, which omits the prefix
and so derives a different address from the same participant keys.
Covenant leaves and the Emulator tweak
A covenant leaf carries no covenant bytes. Its enforcement lives in an ArkadeScript — a separate program the Emulator, the covenant’s co-signing service, executes before it signs — and the leaf commits to that program through the Emulator’s key:OP_SUCCESS range on-chain, so they are only ever
valid inside an ArkadeScript, never in a tapscript leaf.
Worked Example
Every byte below is generated from these inputs.VHTLC
Eight leaves on every corridor swap:VHTLC.ScriptV2 makes the two
non-interactive leaves optional, but corridor quotes always request both, in
both directions. sender funds the lockup and gets it back if the swap fails;
receiver claims it with P — contract positions, not roles, since which
party fills each flips with direction (the assignment table is in
Corridor Swaps).
Leaf order, which is the order the tree is built from:
The CSV tiers above are this example’s values. They come from the trader’s own
Arkade server’s reported exit delay, rounded up to a multiple of 512 s — never
from a quote. The derivation, and why
unilateralRefund sits level with
unilateralClaim, are in Corridor Swaps.
Leaf 0 — claim (96 bytes)
Leaf 0 — claim (96 bytes)
Witness:
[serverSig, receiverSig, P, script, controlBlock].Leaf 1 — refund (102 bytes)
Leaf 1 — refund (102 bytes)
No timelock and no condition: an immediate, cooperative hand-back.Witness:
[serverSig, receiverSig, senderSig, script, controlBlock].Leaf 2 — refundWithoutReceiver (75 bytes)
Leaf 2 — refundWithoutReceiver (75 bytes)
Witness:
[serverSig, senderSig, script, controlBlock].Leaf 3 — unilateralClaim (68 bytes)
Leaf 3 — unilateralClaim (68 bytes)
The condition fragment comes first, so its arguments sit on top of the
initial stack — last in the witness array.Witness:
[receiverSig, P, script, controlBlock].Leaf 4 — unilateralRefund (74 bytes)
Leaf 4 — unilateralRefund (74 bytes)
Witness:
[receiverSig, senderSig, script, controlBlock].Leaf 5 — unilateralRefundWithoutReceiver (40 bytes)
Leaf 5 — unilateralRefundWithoutReceiver (40 bytes)
The only leaf whose timing can steal — a funder able to refund before a
claimant holding
P can claim takes that claimant’s money — so it opens last.Witness: [senderSig, script, controlBlock].Leaf 6 — nonInteractiveClaim (96 bytes)
Leaf 6 — nonInteractiveClaim (96 bytes)
The receiver’s key does not appear at all. What pins the payout to the
receiver is the covenant below, not a key — which is what lets this leaf be
pushed while the receiver is offline.The tweaked key derives from the Emulator key and the covenant program:
ArkScriptHash = d711889930d948d05d5091337c4f96081c01fec158bcf9e125ea3b5041471c16.Witness: [emulatorSig, serverSig, P, script, controlBlock].Leaf 7 — nonInteractiveRefund (102 bytes)
Leaf 7 — nonInteractiveRefund (102 bytes)
No timelock, and no signature from the sender — the only refund path that
survives a permanently lost sender key, though it still needs both of the
signatures in the table above.Witness:
[emulatorSig, receiverSig, serverSig, script, controlBlock].The VHTLC covenants
Both covenant leaves commit to the same 43-byte program shape, differing only in the destination. It reads the output at the same index as the input being spent and requires that output to pay the pre-committed script at least the input’s value.nonInteractiveClaim, pinned to the receiver’s script:
nonInteractiveRefund is the same program with the sender’s program bytes at
offset 5:
OP_1 OP_EQUALVERIFY pair already proves the version, so the
prefix is dropped before it is committed. A spend with no output at the
matching index simply cannot satisfy the leaf.
Tapleaf hashes and the tree
For the worked example:
Eight leaves give a balanced tree of depth 3. The
claim leaf’s merkle path is
d6155f7e…29bf, 1c5b1597…4e9d, 46f0af6c…ac53, and the resulting output key
is da2542078eae0e401d0b5a26a497960b335bf11f52d08ae4f3b3429ff8c5c838 —
scriptPubKey 5120da2542078eae0e401d0b5a26a497960b335bf11f52d08ae4f3b3429ff8c5c838.
Non-Interactive Swap Contract
Two leaves, no timelock, no preimage. The deposit is either delivered against or handed back; nothing expires.fulfill is signed by the server and
the tweaked Emulator key alone and is constrained by its covenant; cancel is a
plain 2-of-2 of the user and the server, with no solver signature anywhere in
the contract.
There are two programs, chosen by what the fill must deliver. The deposit side
is never inspected, which is why one contract carries BTC, an asset, or an
asset-for-asset swap.
makerWP is the funding side’s 32-byte taproot program and user its x-only
key. These are contract positions, not roles — the names come from the
program artifact.
Leaf order is fulfill, then cancel.
want-BTC — fulfill leaf (68 bytes)
want-BTC — fulfill leaf (68 bytes)
Witness:
[emulatorSig, serverSig, script, controlBlock].want-BTC — fulfill covenant (46 bytes)
want-BTC — fulfill covenant (46 bytes)
want-asset — fulfill leaf (68 bytes)
want-asset — fulfill leaf (68 bytes)
[emulatorSig, serverSig, script, controlBlock].want-asset — fulfill covenant (81 bytes)
want-asset — fulfill covenant (81 bytes)
For asset id
0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0,
group index 0, wantAmount 250000:The txid is pushed reversed. Asset ids are displayed in RPC byte order and
committed in internal order, so
0f1e2d3c…e1f0 becomes f0e1d2c3…2d1e0f in
the covenant. Reversing at the wrong step derives a different address that
nothing will ever fill.Both programs — cancel leaf (68 bytes)
Both programs — cancel leaf (68 bytes)
No covenant and no timelock: the deposit goes wherever the spend says, and the
user needs nobody but the Arkade server. A funded deposit is therefore always
cryptographically spendable by the wallet that funded it, which is why the
SDK marks the contract as escrow to keep it out of ordinary coin selection —
nothing in the contract itself says “escrow”.
The
cancel leaf is byte-identical across both programs — it binds only user
and server — so both trees share the tapleaf hash
bb96303c8b14e35fad880d1d0fc7169aa2187447f0b9bbcc26473113e48ab32d. Only
fulfill differs, and only through the tweaked key its covenant produces.
Opcode Reference
Standard Bitcoin opcodes carry their usual values. The Arkade opcodes appearing above are extensions, valid only inside an ArkadeScript:
The co-signing service is authoritative for their exact semantics; the stack
effects above are what the programs on this page depend on.
Reproducing These Bytes
The lockup compiles fromVHTLC.ScriptV2 in @arkade-os/sdk; the
non-interactive swap from offerVtxoScript in @arkade-os/swap, whose two
program artifacts — swap-want-btc.program.json and
swap-want-asset.program.json, exported parsed as swapPrograms — are pure
data, shared verbatim with any other implementation.
The leaf bytes come off different accessors. VHTLC.ScriptV2 names each leaf
(claimScript, refundScript, … nonInteractiveRefundScript), plus
nonInteractiveClaimArkadeScript and nonInteractiveRefundArkadeScript for
the two covenant programs; the swap contract’s leaves come off
functionByName("fulfill" | "cancel"). Both inherit .scripts and .leaves
from VtxoScript. Golden tests pin these exact values, so an implementation in
another language can compare against them before it ever derives a live
address; changing either contract requires coordinated deployment with the
solver.
Sources
- Corridor Swaps — what each VHTLC leaf is for
- Asset Swaps — the non-interactive contract in use
- Hash Time Locked Contract — building a minimal VHTLC by hand
- RFQ Protocol — the quote fields these contracts bind