Skip to content

Closures - #28

Closed
sputn1ck wants to merge 3 commits into
mainfrom
closures
Closed

Closures#28
sputn1ck wants to merge 3 commits into
mainfrom
closures

Conversation

@sputn1ck

Copy link
Copy Markdown
Member

Summary

This PR introduces a flexible closure-based system for VTXO (Virtual Transaction Output) scripts, replacing the hardcoded script
construction with a modular architecture that supports custom spend conditions.

  • Add closure package (lib/closure/) - Core closure types (CSVSigClosure, CSVMultisigClosure, MultisigClosure, CLTVMultisigClosure,
    ConditionMultisigClosure, ConditionCSVMultisigClosure) with Script/Decode/Witness methods
  • Replace existing vtxo scripts with closures - Migrate lib/scripts/, lib/tree/, and lib/tx/ to use the new closure system
  • Add DefaultVTXOTapScript for compatibility - Bridge function for code requiring waddrmgr.Tapscript type

Motivation

The previous VTXO implementation had hardcoded 2-of-2 multisig + CSV timeout structure. This PR enables:

  • Custom VTXO scripts with arbitrary closure combinations
  • Additional spend conditions (CLTV timelocks, conditional scripts)
  • Flexible validation rules that can be extended per-closure type

Key Changes

New lib/closure/ Package

File Purpose
closure.go 6 closure types with Script/Decode/Witness methods
vtxo_script.go TapscriptsVtxoScript container with Validate/TapTree/Encode/Decode
locktime.go BIP68 relative and absolute locktime encoding
script.go Script evaluation helpers
*_test.go Comprehensive test coverage (~2100 lines)

Closure Types

  • CSVSigClosure - Single-sig exit with CSV timelock
  • CSVMultisigClosure - Multi-sig exit with CSV timelock
  • MultisigClosure - Collaborative path (CHECKSIG or CHECKSIGADD variants)
  • CLTVMultisigClosure - Collaborative with absolute timelock
  • ConditionMultisigClosure - Collaborative with custom condition script
  • ConditionCSVMultisigClosure - Exit with condition + CSV timelock

Validation Rules

  • Server/signer key must be present in all forfeit (collaborative) closures
  • Exit closures must meet minimum locktime requirement
  • Block-type locktimes can be restricted via parameter

sputn1ck and others added 3 commits November 27, 2025 19:28
Add DefaultVTXOTapScript function that constructs a waddrmgr.Tapscript
from the closure-based VTXO system. This bridges the new closure
architecture with code that requires the waddrmgr.Tapscript type.

The function is explicitly named "Default" to indicate it creates the
standard 2-of-2 VTXO structure (exit + collab paths). Custom VTXO
scripts with additional closures should use the closure package
directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @sputn1ck, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly refactors the VTXO scripting mechanism by introducing a modular, closure-based architecture. This change moves away from rigid, hardcoded script constructions towards a more adaptable system that can support a wider array of custom spend conditions, including various timelocks and conditional scripts. The new design enhances the flexibility and extensibility of VTXO functionality, allowing for more complex and tailored transaction logic while maintaining essential validation checks.

Highlights

  • Flexible VTXO Scripting: Introduced a new lib/closure/ package that provides a flexible, closure-based system for constructing Virtual Transaction Output (VTXO) scripts, replacing previous hardcoded script generation.
  • New Closure Types: Added six distinct closure types: CSVSigClosure, CSVMultisigClosure, MultisigClosure, CLTVMultisigClosure, ConditionMultisigClosure, and ConditionCSVMultisigClosure, each supporting different spend conditions and timelocks.
  • Migration to Closure System: Migrated existing VTXO script generation and related components in lib/scripts/, lib/tree/, and lib/tx/ to utilize the new closure system, including the introduction of DefaultVTXOTapScript for compatibility.
  • Enhanced VTXO Validation: Implemented validation rules for VTXO scripts, ensuring that all collaborative closures include the signer key, exit closures meet minimum locktime requirements, and block-type locktimes can be restricted.
  • Comprehensive Testing: Added extensive test coverage for the new closure types, locktime utilities, and the TapscriptsVtxoScript to ensure correctness and robustness of the flexible scripting system.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@claude

claude Bot commented Nov 27, 2025

Copy link
Copy Markdown

Pull Request Review: Closures

This is an excellent architectural improvement that replaces hardcoded VTXO script construction with a flexible, modular closure-based system. The implementation is well-designed, thoroughly tested, and follows the project's development guidelines. Below is my detailed review.


✅ Strengths

1. Excellent Architecture & Design

  • Clean abstraction: The Closure interface with Script(), Decode(), and Witness() methods provides a clean, composable API
  • Extensibility: The design enables custom VTXO scripts with arbitrary closure combinations without modifying core code
  • Separation of concerns: Each closure type handles its own script generation, parsing, and witness construction
  • Backward compatibility: DefaultVTXOTapScript() maintains compatibility with existing code while exposing the new system

2. Comprehensive Test Coverage

The PR includes ~2,100 lines of test code across three test files:

  • closure_test.go: Roundtrip encoding/decoding tests for all closure types
  • vtxo_script_test.go: Validation rules, encoding/decoding, taproot tree construction
  • locktime_test.go: BIP68 locktime encoding/decoding edge cases

Test quality highlights:

  • Property-based scenarios (various locktime values, key combinations)
  • Negative test cases (invalid scripts, malformed data)
  • Roundtrip verification (encode → decode → re-encode produces identical output)
  • Edge cases (empty scripts, minimum/maximum values, boundary conditions)

3. Strong Code Quality

  • Error handling: Proper error propagation with context-aware messages
  • Validation: Validate() method enforces critical security invariants (signer key presence, minimum locktimes)
  • Documentation: Clear package-level comments explaining the closure system and VTXO structure
  • Type safety: Strong typing throughout with no unsafe casts or reflections

4. Security Considerations

  • Mandatory exit closures: Validation ensures every VTXO has at least one exit path for unilateral recovery
  • Signer key enforcement: All forfeit closures must contain the signer's public key
  • Minimum locktime validation: Prevents malicious short timeouts
  • Script verification: Decoded closures are re-encoded and compared to ensure correctness
  • Forbidden opcodes: EvaluateScriptToBool() prohibits signature/locktime opcodes in condition scripts

🔍 Issues & Recommendations

1. Critical: Missing Function Comments ⚠️

Many exported functions lack comments starting with the function name, violating the project's style guide (see CLAUDE.md):

// lib/closure/locktime.go:38
func (l AbsoluteLocktime) IsSeconds() bool {  // ❌ Missing comment

// lib/closure/locktime.go:48
func (l RelativeLocktime) Seconds() int64 {  // ❌ Missing comment

// lib/closure/locktime.go:55
func (l RelativeLocktime) Compare(other RelativeLocktime) int {  // ❌ Missing comment

// lib/closure/locktime.go:69
func (l RelativeLocktime) LessThan(other RelativeLocktime) bool {  // ❌ Missing comment

Required format (per development guidelines):

// IsSeconds returns true if the locktime represents a Unix timestamp
// rather than a block height (values >= 500000000).
func (l AbsoluteLocktime) IsSeconds() bool {

Action: Add proper function comments for all exported functions and methods.

2. Potential Bug: Locktime Encoding Issue 🐛

// lib/closure/locktime.go:104-105
seconds := asNumber & SEQUENCE_LOCKTIME_MASK << SEQUENCE_LOCKTIME_GRANULARITY

This line has operator precedence ambiguity. Due to Go's precedence rules, this parses as:

seconds := asNumber & (SEQUENCE_LOCKTIME_MASK << SEQUENCE_LOCKTIME_GRANULARITY)

But the intended logic is likely:

seconds := (asNumber & SEQUENCE_LOCKTIME_MASK) << SEQUENCE_LOCKTIME_GRANULARITY

Impact: This could result in incorrect locktime decoding for seconds-based relative locktimes.

Action: Add explicit parentheses and verify with unit tests:

seconds := (asNumber & SEQUENCE_LOCKTIME_MASK) << SEQUENCE_LOCKTIME_GRANULARITY

3. Code Style: Switch Case Spacing 📐

The development guidelines require blank lines between switch/select cases. Several switch statements need formatting:

// lib/closure/closure.go:78-90 (and others)
switch f.Type {
case MultisigTypeChecksig:
	// ... code ...
	
case MultisigTypeChecksigAdd:  // ✅ Needs blank line before this
	// ... code ...
}

Action: Run make ast-grep-fix to auto-format, or manually add blank lines between cases.

4. Minor: Potential Performance Improvement 💡

// lib/closure/closure.go:51-53
scriptCopy := make([]byte, len(script))
copy(scriptCopy, script)
valid, err := t.closure.Decode(scriptCopy)

The script copy is made for safety, but the Decode() methods don't modify the input. Consider documenting this contract in the Closure interface:

type Closure interface {
	// Decode attempts to parse the given script into this closure type.
	// The script parameter must not be modified.
	Decode(script []byte) (bool, error)
	// ...
}

Then eliminate the copy in DecodeClosure() for better performance.

5. Documentation Enhancement 📚

The ConditionWitnessKey constant could use more context:

// lib/closure/script.go:28
var ConditionWitnessKey = "condition"

Suggestion:

// ConditionWitnessKey is the map key used to pass condition witness data
// to ConditionMultisigClosure.Witness() and ConditionCSVMultisigClosure.Witness().
// The value should be a serialized wire.TxWitness.
var ConditionWitnessKey = "condition"

6. Testing: Missing Edge Case 🧪

Consider adding tests for:

  • Malicious scripts: Verify that scripts with embedded control codes or unusual opcodes are rejected
  • Maximum closure limits: Test behavior with large numbers of closures (e.g., 100+ closures in a VTXO)
  • Memory safety: Verify no panics on very large script inputs

📊 Security Analysis

✅ Security Strengths

  1. Unspendable key path: Uses proper NUMS key generation (arkNUMSHex) with documented seed phrase
  2. Exit path enforcement: Validate() ensures users can always recover funds
  3. Script validation: Roundtrip encoding verification prevents injection attacks
  4. Condition script restrictions: Forbidden opcodes list prevents signature/locktime manipulation

⚠️ Security Considerations

  1. Script execution in EvaluateScriptToBool: Uses a fake transaction for condition evaluation. While this is safe for boolean conditions, ensure:

    • The forbidden opcodes list is comprehensive
    • Consider adding opcodes: OP_CODESEPARATOR, introspection opcodes (OP_INSPECTINPUTVALUE, etc.)
  2. Locktime validation: The blockTypeAllowed parameter controls block-based locktimes. Ensure this is set conservatively in production to avoid clock-skew attacks.


🎯 Action Items Summary

Before merge:

  1. ✅ Add function comments for all exported functions/methods
  2. ✅ Fix operator precedence in locktime.go:104
  3. ✅ Run make ast-grep-fix or manually add switch case spacing
  4. ✅ Run make lint and ensure it passes

Nice to have:

  • Consider documenting the Decode() non-mutation contract
  • Enhance ConditionWitnessKey documentation
  • Add edge case tests for malicious scripts and large closure counts

📈 Overall Assessment

Rating: 9/10 - This is a high-quality PR with excellent design, comprehensive tests, and strong security practices. The issues identified are minor and easily addressable. The closure system provides the flexibility needed for advanced VTXO scripts while maintaining safety and simplicity for the default case.

Recommendation: Approve after addressing the critical items (function comments and locktime bug).


Great work on this PR! The architectural improvement will significantly benefit the codebase's extensibility. 🚀

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant and well-executed refactoring to a flexible, closure-based system for VTXO scripts. The new lib/closure package is a great addition, providing a modular and extensible way to construct and validate complex spending conditions. The migration of existing code to use this new system is thorough and consistent. The comprehensive test suite for the new functionality is also commendable and inspires confidence in the changes. I've identified a few correctness and robustness issues in the new library code, which are detailed in the specific comments. Once these are addressed, this will be an excellent contribution to the codebase.

Comment thread lib/closure/locktime.go
case sequenceNum&wire.SequenceLockTimeDisabled == wire.SequenceLockTimeDisabled:
return nil, true
case sequenceNum&wire.SequenceLockTimeIsSeconds == wire.SequenceLockTimeIsSeconds:
timeLockSeconds := (relativeLock << wire.SequenceLockTimeGranularity) - 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The subtraction of 1 here seems incorrect according to BIP-68. BIP-68 specifies that the time-based lock-time is the 16-bit value multiplied by 512 (2^9). There is no subtraction of 1 mentioned. This could lead to off-by-one errors in locktime calculations. The other decoding function BIP68DecodeSequenceFromBytes in this file correctly implements this without subtracting 1. The test TestBIP68DecodeSequence in locktime_test.go should also be updated to reflect this correction.

Suggested change
timeLockSeconds := (relativeLock << wire.SequenceLockTimeGranularity) - 1
timeLockSeconds := (relativeLock << wire.SequenceLockTimeGranularity)

Comment thread lib/closure/script.go
Comment on lines +93 to +102
finalStack := vm.GetStack()

if len(finalStack) != 0 {
return false, fmt.Errorf(
"script must return zero value on the stack, got %d",
len(finalStack),
)
}

return true, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for EvaluateScriptToBool seems incorrect. A script that evaluates to 'true' for opcodes like OP_VERIFY should leave a single non-zero value on the stack. This implementation checks for an empty stack and returns true, which is the opposite of the desired behavior. It should check for a single stack item that evaluates to true.

    finalStack := vm.GetStack()
    if len(finalStack) == 0 {
        // An empty stack is considered false.
        return false, nil
    }
    if len(finalStack) > 1 {
        return false, fmt.Errorf(
            "script must return a single value on the stack, got %d",
            len(finalStack),
        )
    }

    return txscript.AsBool(finalStack[0]), nil

Comment thread lib/closure/closure.go
Comment on lines +511 to +518
if !valid {
return false, nil
}

d.Locktime = *locktime
d.MultisigClosure = *multisigClosure

return valid, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Decode method for CSVMultisigClosure is missing a final verification step. Other Decode methods in this file (e.g., CSVSigClosure.Decode, ConditionMultisigClosure.Decode) rebuild the script from the parsed components and compare it to the original script to ensure canonical encoding and prevent extraneous data. This check should be added here for consistency and correctness.

    if !valid {
        return false, nil
    }

    d.Locktime = *locktime
    d.MultisigClosure = *multisigClosure

    // Verify the script matches what we would generate to ensure canonical encoding.
    rebuilt, err := d.Script()
    if err != nil {
        return false, err
    }

    return bytes.Equal(rebuilt, script), nil

Comment thread lib/closure/closure.go
Comment on lines +614 to +621
if !valid {
return false, nil
}

d.Locktime = AbsoluteLocktime(locktime)
d.MultisigClosure = *multisigClosure

return valid, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to CSVMultisigClosure.Decode, the Decode method for CLTVMultisigClosure is missing the final script rebuild and comparison step. This is important for ensuring the script is canonically encoded and doesn't contain extra data. This should be added for consistency with other Decode methods in this file.

    if !valid {
        return false, nil
    }

    d.Locktime = AbsoluteLocktime(locktime)
    d.MultisigClosure = *multisigClosure

    // Verify the script matches what we would generate to ensure canonical encoding.
    rebuilt, err := d.Script()
    if err != nil {
        return false, err
    }

    return bytes.Equal(rebuilt, script), nil

Comment thread lib/closure/script.go
Comment on lines +112 to +116
func UnspendableKey() *btcec.PublicKey {
pubBytes, _ := hex.DecodeString(arkNUMSHex)
pub, _ := btcec.ParsePubKey(pubBytes)
return pub
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Ignoring errors from hex.DecodeString and btcec.ParsePubKey is not robust. While arkNUMSHex is a constant, it's better practice to handle potential parsing errors. A good pattern for this is to parse the key in an init() function and panic if it fails. This ensures the key is valid when the package is initialized and avoids ignoring errors in the function itself. For example:

var unspendableKey *btcec.PublicKey

func init() {
	pubBytes, err := hex.DecodeString(arkNUMSHex)
	if err != nil {
		panic(fmt.Sprintf("invalid arkNUMSHex: %v", err))
	}
	pub, err := btcec.ParsePubKey(pubBytes)
	if err != nil {
		panic(fmt.Sprintf("invalid arkNUMSHex: %v", err))
	}
	unspendableKey = pub
}

// UnspendableKey returns the NUMS (nothing up my sleeves) key used as the
// internal key for taproot outputs where the key path should be unspendable.
func UnspendableKey() *btcec.PublicKey {
	return unspendableKey
}

Comment thread lib/closure/closure.go
type Closure interface {
Script() ([]byte, error)
Decode(script []byte) (bool, error)
Witness(controlBlock []byte, opts map[string][]byte) (wire.TxWitness, error)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's opts here? Any reason to not use functional options? Unclear what the string key value is here, etc.

Comment thread lib/closure/closure.go
// Closure represents a single tapscript leaf that can be spent.
type Closure interface {
Script() ([]byte, error)
Decode(script []byte) (bool, error)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the first return value indicate?

Is this Decode actually useful on the interface level?

Comment thread lib/closure/closure.go
for _, t := range types {
scriptCopy := make([]byte, len(script))
copy(scriptCopy, script)
valid, err := t.closure.Decode(scriptCopy)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not have some sort of framing layer here instead? So like a type prefix, so then you know exactly what you're attempting to decode?

Alternatively, you can add a type param here, than do like:

var closure T

and then decode into that.

Comment thread lib/closure/closure.go
}

valid, err = f.decodeChecksigAdd(script)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd line spacing here.

Comment thread lib/closure/closure.go

}

func (f *MultisigClosure) decodeChecksigAdd(script []byte) (bool, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re just declaring the type upfront. Haven't seen how it's used yet in the wild though, so perhaps I'm missing something.

Comment thread lib/closure/script.go
}

// Create a new script engine with the fake tx
vm, err := txscript.NewEngine(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can this run w/o all the other required inputs like the prev output fetcher, etc?

Comment thread lib/closure/script.go
return false, fmt.Errorf("failed to create script engine: %w", err)
}

vm.SetStack(witness)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is odd....so it's a higher level VM on top of existing VTXOs?

Comment thread lib/closure/witness.go
)

// ReadTxWitness deserializes a witness from a byte slice.
func ReadTxWitness(witnessSerialized []byte) (wire.TxWitness, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export from the wire package?

Comment thread lib/scripts/vtxo.go
package scripts

// VTXO Taproot Tree Structure:
// VTXO Closure System:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be possible to leave most of the existing scripts in place, but then add on custom scripts implemented as an && then an arbitrary script after that?

This PR as is breaks everything built on top of lib as is.

Comment thread lib/tree/batch.go
// These can be decoded into Closure objects using closure.ParseVtxoScript().
// The scripts define the spending conditions (exit paths, collab paths,
// etc.) for this VTXO.
Scripts []string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why hex encode it at all?

ellemouton pushed a commit that referenced this pull request Mar 17, 2026
agents: add initial draft of agent files, and CI work flows to sync them
@litbot-9000

Copy link
Copy Markdown
Collaborator

@bhandras: review reminder
@ellemouton: review reminder
@sputn1ck, remember to re-request review from reviewers when ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants