Table of Contents

Class VHTLCv2Contract

Namespace
NArk.Arkade.Contracts
Assembly
NArk.Arkade.dll

The VHTLC the Lightning and off-board swap corridors settle into: the six leaves of the reference VHTLC construction, plus up to three whose co-signer is an emulator key tweaked by a covenant that pins where the spend may pay — a claim leaf, a refund leaf, and that refund's timelocked twin, the last two sharing one covenant key because they pin one destination.

public class VHTLCv2Contract : ArkContract
Inheritance
VHTLCv2Contract
Inherited Members

Remarks

This must derive byte-for-byte what the counterparty derives, because the address is the agreement — nothing is exchanged to confirm it. Leaf order decides the taproot merkle root and therefore the address, so the order below is not a stylistic choice: it is the order VHTLC.ScriptV2 builds in, and changing it silently produces an address whose funds nobody can spend. NArk.Tests/ArkadeIntents/Fixtures/covenant_swap.json holds vectors generated from that implementation; if this class and those vectors disagree, this class is what is wrong.

The ladder is six leaves through nine, depending on which optional covenant leaves are asked for: NonInteractiveClaim and NonInteractiveRefund are independently optional and append in that order, and the refund leaf can carry its timelocked twin as a ninth. The RFQ corridors ask for both without the twin, which is the eight-leaf shape the fixtures pin — but a contract built with a different set is a different address, not a variant of the same one, so the set is part of what the two sides must agree on.

Roles are positional, not fixed to a party. On arkade:BTC->lightning:BTC the trader is Sender and the solver is Receiver; on lightning:BTC->arkade:BTC they swap, because there it is the solver that funds and the trader that claims. Both corridors build this same class.

Constructors

VHTLCv2Contract(OutputDescriptor, OutputDescriptor, OutputDescriptor, uint160, LockTime, Sequence, Sequence, Sequence, VHTLCv2NonInteractiveClaim?, VHTLCv2NonInteractiveRefund?, VHTLCv2Asset?, byte[]?)

Builds the contract and, with it, the address both sides must agree on.

public VHTLCv2Contract(OutputDescriptor server, OutputDescriptor sender, OutputDescriptor receiver, uint160 hash, LockTime refundLocktime, Sequence unilateralClaimDelay, Sequence unilateralRefundDelay, Sequence unilateralRefundWithoutReceiverDelay, VHTLCv2NonInteractiveClaim? nonInteractiveClaim = null, VHTLCv2NonInteractiveRefund? nonInteractiveRefund = null, VHTLCv2Asset? asset = null, byte[]? preimage = null)

Parameters

server OutputDescriptor

The Arkade server's key, co-signing every collaborative leaf.

sender OutputDescriptor

The party that funds, and that the refund paths pay back.

receiver OutputDescriptor

The party that claims by revealing the preimage.

hash uint160

HASH160 of the preimage.

refundLocktime LockTime

When the sender's timelocked refund path opens.

unilateralClaimDelay Sequence

How long after funding the receiver may claim alone.

unilateralRefundDelay Sequence

How long after funding sender and receiver may refund without the server.

unilateralRefundWithoutReceiverDelay Sequence

How long after funding the sender alone may refund.

nonInteractiveClaim VHTLCv2NonInteractiveClaim

The optional covenant claim leaf.

nonInteractiveRefund VHTLCv2NonInteractiveRefund

The optional covenant refund leaf, and its optional timelocked twin.

asset VHTLCv2Asset

The Arkade asset the covenant leaves bind; requires at least one of them.

preimage byte[]

The secret behind hash, when the caller holds it. Changes nothing about the address; see Preimage for what carrying it costs.

Exceptions

ArgumentException

A delay is not a relative timelock, a pinned destination is not a P2TR scriptPubKey, an asset is named that no leaf would bind, a strict claim bound is incomplete or non-positive, or the preimage is the wrong length or does not hash to hash.

Fields

ContractType

The discriminator this contract serializes under.

public const string ContractType = "HTLCv2"

Field Value

string

MaxAssetGroupIndex

The largest asset group index the covenant's introspection opcodes accept.

public const int MaxAssetGroupIndex = 65535

Field Value

int

PreimageSize

Every claim-family leaf gates the preimage to this length before hashing it.

public const int PreimageSize = 32

Field Value

int

Properties

Asset

The Arkade asset the covenant leaves bind, or null for a sat-only contract.

public VHTLCv2Asset? Asset { get; }

Property Value

VHTLCv2Asset

Remarks

Only the non-interactive leaves change. Every other leaf is a signature path that asserts nothing about value, which is why this reaches exactly the leaves whose covenant the emulator enforces — and why naming an asset without either of them is refused rather than silently dropped.

The sat clause is RETAINED, not replaced: an asset-carrying VTXO carries sats too, so dropping it would let a spend satisfy the asset covenant while stripping the sats. And ONE asset is bound: fund this contract with the asset it names and nothing else, because any additional asset on the VTXO is the spender's to direct.

DefaultScope

The layer this contract type's funds live on by default (on-chain vs off-chain). Abstract so every contract type makes an explicit, compile-time scope decision. Used as the fallback when ToEntity(string, OutputDescriptor?, DateTimeOffset?, ContractActivityState, ContractScope?) is called without a scope override.

public override ContractScope DefaultScope { get; }

Property Value

ContractScope

Hash

HASH160 of the preimage.

public uint160 Hash { get; }

Property Value

uint160

NonInteractiveClaim

The non-interactive claim leaf, or null if this contract carries none.

public VHTLCv2NonInteractiveClaim? NonInteractiveClaim { get; }

Property Value

VHTLCv2NonInteractiveClaim

NonInteractiveClaimArkadeScript

The ArkadeScript the non-interactive claim leaf's key commits to, which the emulator runs before it will co-sign.

public byte[] NonInteractiveClaimArkadeScript { get; }

Property Value

byte[]

Exceptions

InvalidOperationException

This contract carries no non-interactive claim leaf.

NonInteractiveRefund

The non-interactive refund leaf, or null if this contract carries none.

public VHTLCv2NonInteractiveRefund? NonInteractiveRefund { get; }

Property Value

VHTLCv2NonInteractiveRefund

NonInteractiveRefundArkadeScript

The ArkadeScript the non-interactive refund leaf's key commits to.

public byte[] NonInteractiveRefundArkadeScript { get; }

Property Value

byte[]

Exceptions

InvalidOperationException

This contract carries no non-interactive refund leaf.

Preimage

The secret this contract's claim leaves open, when we hold it — otherwise null.

public byte[]? Preimage { get; }

Property Value

byte[]

Remarks

Not part of the script: every leaf commits to Hash, so a contract with the preimage and one without derive the same address. It rides along because the claim is the contract's own operation — anything holding the rebuilt contract can spend it, without a second lookup into whatever negotiated the swap. VHTLCContract carries it the same way and for the same reason.

It is a SECRET travelling in a structure that is otherwise a public identifier: it is written into the arkcontract= descriptor and the persisted contract row, so it reaches wherever those are copied. Leave it null on a contract built only to derive or verify an address.

Receiver

The party that claims by revealing the preimage.

public OutputDescriptor Receiver { get; }

Property Value

OutputDescriptor

ReceiverKey

The receiver's x-only key, as the leaves commit to it.

public ECXOnlyPubKey ReceiverKey { get; }

Property Value

ECXOnlyPubKey

RefundLocktime

When the sender's timelocked refund path opens.

public LockTime RefundLocktime { get; }

Property Value

LockTime

Sender

The party that funds the contract, and that the refund paths pay back.

public OutputDescriptor Sender { get; }

Property Value

OutputDescriptor

SenderKey

The sender's x-only key, as the leaves commit to it.

public ECXOnlyPubKey SenderKey { get; }

Property Value

ECXOnlyPubKey

Type

Contract type discriminator string (e.g. "vtxo", "boarding").

public override string Type { get; }

Property Value

string

UnilateralClaimDelay

How long after funding the receiver may claim without the server.

public Sequence UnilateralClaimDelay { get; }

Property Value

Sequence

UnilateralRefundDelay

How long after funding sender and receiver may refund without the server.

public Sequence UnilateralRefundDelay { get; }

Property Value

Sequence

UnilateralRefundWithoutReceiverDelay

How long after funding the sender alone may refund, needing nobody.

public Sequence UnilateralRefundWithoutReceiverDelay { get; }

Property Value

Sequence

Methods

CovenantKey(ECXOnlyPubKey, byte[])

The emulator key tweaked by a covenant, which is what makes the emulator's signature conditional on the spend actually honouring that covenant.

public static ECXOnlyPubKey CovenantKey(ECXOnlyPubKey emulatorPubKey, byte[] arkadeScript)

Parameters

emulatorPubKey ECXOnlyPubKey

The emulator's untweaked key.

arkadeScript byte[]

The covenant the key commits to.

Returns

ECXOnlyPubKey

The tweaked co-signer key a leaf commits to.

CreateClaimScript()

Preimage + receiver + server.

public ScriptBuilder CreateClaimScript()

Returns

ScriptBuilder

CreateNonInteractiveClaimScript()

Preimage + server + the covenant key, pinned to the receiver's own payout. Lets the receiver's claim be pushed while the receiver is offline.

public ScriptBuilder CreateNonInteractiveClaimScript()

Returns

ScriptBuilder

Exceptions

InvalidOperationException

This contract carries no non-interactive claim leaf.

CreateNonInteractiveRefundScript()

Server + receiver + the covenant key, pinned to the sender's own payout. Releases the refund the moment those two agree the swap failed, without waiting out RefundLocktime and without a sender signature.

public ScriptBuilder CreateNonInteractiveRefundScript()

Returns

ScriptBuilder

Exceptions

InvalidOperationException

This contract carries no non-interactive refund leaf.

CreateNonInteractiveRefundWithoutReceiverScript()

Server + the covenant key after RefundLocktime, pinned to the sender's own payout — the one refund tier needing no participant signature at all.

public ScriptBuilder CreateNonInteractiveRefundWithoutReceiverScript()

Returns

ScriptBuilder

Remarks

The same covenant key as CreateNonInteractiveRefundScript(), because both leaves pin the same destination. That is a guarantee rather than a coincidence: the key is derived once in the constructor and read by both, so the two cannot drift.

Exceptions

InvalidOperationException

This contract carries no non-interactive refund leaf, or carries one without its timelocked twin. Returning a builder for a leaf that is not in the committed merkle root would produce a witness rejected on-chain, with nothing at the SDK level saying why.

CreateRefundScript()

Sender + receiver + server, immediate — everyone agreeing to cancel now.

public ScriptBuilder CreateRefundScript()

Returns

ScriptBuilder

CreateRefundWithoutReceiverScript()

Sender + server after RefundLocktime, no receiver.

public ScriptBuilder CreateRefundWithoutReceiverScript()

Returns

ScriptBuilder

CreateUnilateralClaimScript()

Preimage + receiver alone, after a delay.

public ScriptBuilder CreateUnilateralClaimScript()

Returns

ScriptBuilder

CreateUnilateralRefundScript()

Sender + receiver after a delay, no server.

public ScriptBuilder CreateUnilateralRefundScript()

Returns

ScriptBuilder

CreateUnilateralRefundWithoutReceiverScript()

The sender alone after the longest delay — the recourse that depends on nobody.

public ScriptBuilder CreateUnilateralRefundWithoutReceiverScript()

Returns

ScriptBuilder

EnforcePayTo(byte[], VHTLCv2Asset?, VHTLCv2StrictClaim?)

The covenant a leaf's key commits to: "the output at this input's index pays destinationPkScript, for at least what the input was worth" — plus, when asset is named, the same conservation bound on that asset, and plus the quoted floors when strict asks for them.

public static byte[] EnforcePayTo(byte[] destinationPkScript, VHTLCv2Asset? asset = null, VHTLCv2StrictClaim? strict = null)

Parameters

destinationPkScript byte[]

The pinned payout — a P2TR scriptPubKey, 34 bytes.

asset VHTLCv2Asset

The asset to bind, or null for the sat-only covenant.

strict VHTLCv2StrictClaim

The opt-in quoted bound. Only the claim leaf ever carries one.

Returns

byte[]

The ArkadeScript bytes.

Remarks

PUSHCURRENTINPUTINDEX as the output index is not an assumption about how a batch pairs inputs with outputs — the covenant imposes the pairing on the spender. Whatever index a spending transaction places this input at, the output at that same index must pay the destination, or the script fails and the emulator never signs. Index alignment is therefore a liveness obligation on whoever assembles the spend, never a safety assumption.

GetContractData()

Returns the key-value map of contract parameters used for serialization.

protected override Dictionary<string, string> GetContractData()

Returns

Dictionary<string, string>

GetScriptBuilders()

The leaves, in the order that fixes the merkle root. Do not reorder, and do not move an optional leaf ahead of a mandatory one: every earlier leaf must keep its position.

protected override IEnumerable<ScriptBuilder> GetScriptBuilders()

Returns

IEnumerable<ScriptBuilder>

Parse(Dictionary<string, string>, Network)

Rebuild a contract from the data GetContractData() wrote.

public static ArkContract Parse(Dictionary<string, string> contractData, Network network)

Parameters

contractData Dictionary<string, string>

The parsed arkcontract= fields.

network Network

The network the descriptors belong to.

Returns

ArkContract

The reconstructed contract.

Exceptions

FormatException

A covenant leaf, a strict bound or an asset is only half-written, or a flag names a leaf the row does not carry. Reading any of those as "not set" would rebuild a different script, and the address it derives would not be the one the row was written for.

ToClaimCoin(string, ArkVtxo)

The receiver's payout, using the preimage this contract already carries.

public ArkCoin ToClaimCoin(string walletIdentifier, ArkVtxo vtxo)

Parameters

walletIdentifier string

The wallet holding the receiver's key.

vtxo ArkVtxo

The unspent lockup output.

Returns

ArkCoin

A coin spendable through the claim leaf.

Exceptions

InvalidOperationException

This contract carries no Preimage, so it cannot be claimed from alone — the secret has to come from whoever holds it.

ToClaimCoin(string, ArkVtxo, byte[])

The receiver's payout: the claim leaf, opened by revealing the preimage.

public ArkCoin ToClaimCoin(string walletIdentifier, ArkVtxo vtxo, byte[] preimage)

Parameters

walletIdentifier string

The wallet holding the receiver's key.

vtxo ArkVtxo

The unspent lockup output.

preimage byte[]

The 32-byte secret whose hash this contract commits to.

Returns

ArkCoin

A coin spendable through the claim leaf.

Remarks

Spending this publishes the preimage in the witness, which is what settles the other side of the swap — so claiming is not merely taking delivery, it is also how the counterparty gets paid. The preimage is checked against Hash before a coin is built: a spend carrying the wrong one fails in the mempool, having already broadcast the attempt.

Exceptions

ArgumentException

The preimage is the wrong length, or hashes to something else.

ToNonInteractiveClaimCoin(string, ArkVtxo, byte[])

Opens a BTC-only covenant claim without a wallet signature; each input requires its own pinned payout output.

public ArkCoin ToNonInteractiveClaimCoin(string walletIdentifier, ArkVtxo vtxo, byte[] preimage)

Parameters

walletIdentifier string

The wallet tracking this lockup; no signer is required.

vtxo ArkVtxo

An unspent, unswept output of this contract.

preimage byte[]

The 32-byte secret committed by the contract. Submission reveals it.

Returns

ArkCoin

A signerless coin carrying the committed claim leaf and emulator covenant.

Remarks

Submitting this coin discloses the secret even if submission fails. It does not verify other swaps sharing its hash.

ToNonInteractiveRefundWithoutReceiverCoin(string, ArkVtxo)

Opens the BTC-only ninth, timelocked refund leaf without participant signatures or a preimage.

public ArkCoin ToNonInteractiveRefundWithoutReceiverCoin(string walletIdentifier, ArkVtxo vtxo)

Parameters

walletIdentifier string

The wallet tracking this lockup; no signer is required.

vtxo ArkVtxo

An unspent, unswept output of this contract.

Returns

ArkCoin

A signerless coin; submit only after chain maturity, with one pinned refund output per input.

Exceptions

InvalidOperationException

The lockup is unusable or has no refund-without-receiver covenant leaf.

ToRefundWithoutReceiverCoin(string, ArkVtxo)

The sender's own timelocked way out: refundWithoutReceiver, co-signed by the Arkade server once RefundLocktime has passed.

public ArkCoin ToRefundWithoutReceiverCoin(string walletIdentifier, ArkVtxo vtxo)

Parameters

walletIdentifier string

The wallet holding the sender's key.

vtxo ArkVtxo

The unspent lockup output.

Returns

ArkCoin

A coin spendable through that leaf.

Remarks

This is the sender's recourse that needs no counterparty, and the only leaf a sender-side wallet can build alone before the CSV ladder opens. The neighbouring nonInteractiveRefund leaf resolves without waiting out the locktime, but it is not an alternative here: it is co-signed by the emulator against a covenant, so it is pushed on the sender's behalf rather than built by this wallet, which holds neither key. Unlike it this leaf carries no covenant, so the payout is not pinned — the caller chooses where it goes.