Class VHTLCv2Contract
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
serverOutputDescriptorThe Arkade server's key, co-signing every collaborative leaf.
senderOutputDescriptorThe party that funds, and that the refund paths pay back.
receiverOutputDescriptorThe party that claims by revealing the preimage.
hashuint160HASH160 of the preimage.
refundLocktimeLockTimeWhen the sender's timelocked refund path opens.
unilateralClaimDelaySequenceHow long after funding the receiver may claim alone.
unilateralRefundDelaySequenceHow long after funding sender and receiver may refund without the server.
unilateralRefundWithoutReceiverDelaySequenceHow long after funding the sender alone may refund.
nonInteractiveClaimVHTLCv2NonInteractiveClaimThe optional covenant claim leaf.
nonInteractiveRefundVHTLCv2NonInteractiveRefundThe optional covenant refund leaf, and its optional timelocked twin.
assetVHTLCv2AssetThe Arkade asset the covenant leaves bind; requires at least one of them.
preimagebyte[]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
MaxAssetGroupIndex
The largest asset group index the covenant's introspection opcodes accept.
public const int MaxAssetGroupIndex = 65535
Field Value
PreimageSize
Every claim-family leaf gates the preimage to this length before hashing it.
public const int PreimageSize = 32
Field Value
Properties
Asset
The Arkade asset the covenant leaves bind, or null for a sat-only contract.
public VHTLCv2Asset? Asset { get; }
Property Value
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
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
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
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
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
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
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
emulatorPubKeyECXOnlyPubKeyThe emulator's untweaked key.
arkadeScriptbyte[]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
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
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
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
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
CreateRefundWithoutReceiverScript()
Sender + server after RefundLocktime, no receiver.
public ScriptBuilder CreateRefundWithoutReceiverScript()
Returns
CreateUnilateralClaimScript()
Preimage + receiver alone, after a delay.
public ScriptBuilder CreateUnilateralClaimScript()
Returns
CreateUnilateralRefundScript()
Sender + receiver after a delay, no server.
public ScriptBuilder CreateUnilateralRefundScript()
Returns
CreateUnilateralRefundWithoutReceiverScript()
The sender alone after the longest delay — the recourse that depends on nobody.
public ScriptBuilder CreateUnilateralRefundWithoutReceiverScript()
Returns
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
destinationPkScriptbyte[]The pinned payout — a P2TR scriptPubKey, 34 bytes.
assetVHTLCv2AssetThe asset to bind, or
nullfor the sat-only covenant.strictVHTLCv2StrictClaimThe 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
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
Parse(Dictionary<string, string>, Network)
Rebuild a contract from the data GetContractData() wrote.
public static ArkContract Parse(Dictionary<string, string> contractData, Network network)
Parameters
contractDataDictionary<string, string>The parsed
arkcontract=fields.networkNetworkThe 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
walletIdentifierstringThe wallet holding the receiver's key.
vtxoArkVtxoThe 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
walletIdentifierstringThe wallet holding the receiver's key.
vtxoArkVtxoThe unspent lockup output.
preimagebyte[]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
walletIdentifierstringThe wallet tracking this lockup; no signer is required.
vtxoArkVtxoAn unspent, unswept output of this contract.
preimagebyte[]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
walletIdentifierstringThe wallet tracking this lockup; no signer is required.
vtxoArkVtxoAn 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
walletIdentifierstringThe wallet holding the sender's key.
vtxoArkVtxoThe 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.