Skip to content

Add ConditionCLTVMultisigClosure (condition + absolute CLTV + multisig) #1085

Description

@Kukks

TL;DR

arkd's tapscript closure surface partitions the 5 closure types into two buckets in pkg/ark-lib/script/vtxo_script.go:

ForfeitClosures()  ⊇  { MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure }   // execution / collaborative
ExitClosures()     ⊇  { CSVMultisigClosure, ConditionCSVMultisigClosure }                  // unilateral exit to L1

There is no condition-bearing closure on the execution side. Any execution path that needs a condition + absolute timelock has no protocol-supported leaf — the closest substitute (ConditionCSVMultisigClosure) is an exit-side leaf, meaning the user must unroll to L1 and wait the relative timelock from L1 confirmation rather than claim cooperatively offchain.

The convention, made explicit by arkd's own taxonomy

Path type Without condition With condition
Execution / collaborative (off-chain, arkd co-signs via /v1/tx/submit; absolute deadlines via CLTV) CLTVMultisigClosure (e.g. refund: funder + arkd, absolute finalExpiration) — GAP —
Unilateral exit (push VTXO chain to L1, then spend on-chain after the relative CSV timelock matures) CSVMultisigClosure (e.g. server-unroll, exit closures) ConditionCSVMultisigClosure

The collaborative-spend validator at internal/infrastructure/tx-builder/covenantless/builder.go:96-138 confirms this by construction: its switch over input closure types accepts Multisig / CSV / CLTV / ConditionMultisig but has no case for ConditionCSVMultisig — exit closures aren't validated on the collaborative offchain path; they're spent on L1 after unroll.

The gap and its consequence

The matrix above leaves one cell empty: a condition-bearing execution leaf. Any protocol that wants "cooperatively spend offchain, gated on a hash-preimage reveal and an absolute deadline" currently has no first-class closure to express that — only the exit-side substitute, which silently changes the user-facing semantics from "wait T seconds then call /penalty to claim instantly" into "publish your VTXO chain to L1, wait for confirmations + the CSV relative timelock, then claim on-chain with full L1 fees."

For protocols where the leaf is meant as a deterrent (forfeit / penalty leaves in fair-exchange protocols), this is functionally important — the deterrent strength depends on the claim being cheap and fast. Forcing a unilateral exit weakens the deterrent for small stakes.

Use case — forfeit / penalty leaves in fair-exchange protocols

Concrete pattern surfaced while implementing an R1-style forfeit leaf on a trustless coin-flip escrow:

  • Two parties co-stake into per-party escrows.
  • One reveals its secret to the other (via the server).
  • If the other party withholds its secret, the revealer should be able to sweep the whole pot after an absolute deadline — cooperatively, via /tx/submit, with arkd co-signing — using only its own revealed secret.

That decomposes naturally to:

  • hash-preimage check ("I revealed") → condition
  • absolute deadline → CLTV (execution path: arkd co-signs, parties are presumed online)
  • 2-of-2 multisig (revealer + arkd) → outer multisig

The protocol cleanly supports each individual component but not the combination. ConditionMultisigClosure won't admit OP_CHECKLOCKTIMEVERIFY inside its conditionScript (correctly — forbiddenOpcodes enforces stateless conditions). ConditionCSVMultisigClosure is exit-bucket. CLTVMultisigClosure lacks a condition slot. No combination works for cooperative-claim-after-absolute-deadline.

Proposed

Add ConditionCLTVMultisigClosure to the closure set, mirroring ConditionCSVMultisigClosure structurally, and add it to ForfeitClosures() (execution-bucket) rather than ExitClosures():

type ConditionCLTVMultisigClosure struct {
    CLTVMultisigClosure
    Condition []byte
}

Encoded script form:

<conditionScript> VERIFY <locktime> CHECKLOCKTIMEVERIFY DROP <pubkey> CHECKSIGVERIFY ... CHECKSIG

Implementation analogous to existing ConditionCSVMultisigClosure: same condition-tokenization split (break at first OP_VERIFY), same forbiddenOpcodes enforcement on the condition, but the outer-closure timelock uses absolute CLTV. The collaborative-spend validator at builder.go would gain a case for this closure mirroring ConditionMultisigClosure's logic (evaluate condition + register pubkeys), with the CLTV propagated to the spending tx's nLockTime (as CLTVMultisigClosure already does at offchain/tx.go:121-150).

SDK-side counterpart in @arkade-os/sdk's script/tapscript.ts would add ConditionCLTVMultisigTapscript to the decodeTapscript enumeration so client builders recognize it.

This completes the 2×2 grid (exec/exit × bare/conditioned) and makes the CSV-for-exit / CLTV-for-execution distinction expressible for all leaf shapes — including condition-bearing execution leaves which are currently unrepresentable.

Alternative considered

Relaxing forbiddenOpcodes to allow OP_CHECKLOCKTIMEVERIFY inside the conditionScript (and threading a real tx context into EvaluateScriptToBool instead of the fake stub) would technically work, but mixes two distinct concerns — stateless boolean predicate vs. tx-introspecting timelock — at the condition layer, which I read the current ban as deliberately codifying. A dedicated closure type preserves the factoring and matches the existing ConditionCSVMultisig precedent.

Happy to PR

If the maintainers think this fits, I'm happy to send a PR with the closure addition + round-trip tests matching the existing ConditionCSVMultisig patterns + the SDK counterpart.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions