Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ docker-tools:

lint-source: docker-tools
@$(call print, "Linting source.")
$(DOCKER_TOOLS) custom-gcl run -v $(LINT_WORKERS)
$(DOCKER_TOOLS) custom-gcl run -v --timeout=15m $(LINT_WORKERS)

# Globs to exclude generated files from ast-grep.
AST_GREP_EXCLUDE := --globs '!**/*.pb.go' --globs '!**/*.pb.gw.go' --globs '!**/*.pb.json.go' --globs '!**/db/sqlc/*.go'
Expand Down
3 changes: 1 addition & 2 deletions db/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ func (s *Store) Close() error {
type Config struct {
// Backend specifies which database backend to use: "sqlite" or
// "postgres".
//nolint:ll
Backend string `long:"backend" description:"Database backend to use (sqlite or postgres)" choice:"sqlite" choice:"postgres"`
Backend string `long:"backend" choice:"sqlite" choice:"postgres"`

// Sqlite contains SQLite-specific configuration
Sqlite *SqliteConfig `group:"sqlite" namespace:"sqlite"`
Expand Down
38 changes: 29 additions & 9 deletions harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,11 @@ func (h *Harness) Faucet(address string, amount btcutil.Amount) string {
txID := h.bitcoindSendToAddress(address, amount.ToBTC())
h.Logf("Faucet sent %v to %s (txid %s)", amount, address, txID)

// Ensure the transaction actually lands in mempool before returning.
// Some systests intentionally restart client-side actors before mining,
// and we want to avoid racing "mine" against "broadcast".
h.WaitMempoolTx(txID)

return txID
}

Expand Down Expand Up @@ -1460,6 +1465,26 @@ func (h *Harness) WaitMempoolTxCount(minTxCount int) []string {
return txIDs
}

// WaitMempoolTx waits until the given transaction ID appears in bitcoind's
// mempool.
func (h *Harness) WaitMempoolTx(txID string) {
h.T.Helper()

require.Eventually(
h.T, func() bool {
txIDs := h.MempoolTxIDs()
for i := range txIDs {
if txIDs[i] == txID {
return true
}
}

return false
}, defaultTimeout, pollInterval,
"txid %s not found in mempool", txID,
)
}

// rpcRequest is a JSON-RPC request.
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
Expand Down Expand Up @@ -1785,9 +1810,8 @@ func (h *Harness) initAndWaitLNDInstance(
)
defer cancel()

conn, err := grpc.DialContext(
ctxt, addr, grpc.WithTransportCredentials(tlsCert),
grpc.WithBlock(),
conn, err := grpc.NewClient(
addr, grpc.WithTransportCredentials(tlsCert),
)
if err != nil {
return false
Expand Down Expand Up @@ -2049,12 +2073,9 @@ func getLNDClientConn(ctx context.Context, addr, tlsPath,
opts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(macaroonCred),
grpc.WithBlock(),
}

conn, err := grpc.DialContext(
ctx, addr, opts...,
)
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
return nil, fmt.Errorf("failed to dial LND: %w", err)
}
Expand Down Expand Up @@ -2093,10 +2114,9 @@ func getTapdClientConn(ctx context.Context, addr, tlsPath,
opts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(macaroonCred),
grpc.WithBlock(),
}

conn, err := grpc.DialContext(ctx, addr, opts...)
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
return nil, fmt.Errorf("failed to dial tapd: %w", err)
}
Expand Down
5 changes: 2 additions & 3 deletions harness/tapd_harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,8 @@ func (th *TapdHarness) initAndWaitLND() {
)
defer cancel()

conn, err := grpc.DialContext(
ctx, addr, grpc.WithTransportCredentials(tlsCert),
grpc.WithBlock(),
conn, err := grpc.NewClient(
addr, grpc.WithTransportCredentials(tlsCert),
)
if err != nil {
return false
Expand Down
103 changes: 103 additions & 0 deletions lib/scripts/checkpoint_oor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package scripts

import (
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightningnetwork/lnd/input"
)

// CheckpointPolicy defines the parameters for constructing an OOR checkpoint
// taproot tree.
//
// This is intentionally interface-first and minimal: it provides enough
// information to deterministically derive a checkpoint output pkScript, while
// allowing the underlying closure system to evolve later.
type CheckpointPolicy struct {
// OperatorKey is the public key required by the operator-controlled CSV
// unroll leaf.
OperatorKey *btcec.PublicKey

// CSVDelay is the relative timelock enforced by the
// operator-controlled leaf.
//
// This is a raw BIP-68 sequence value interpreted by
// OP_CHECKSEQUENCEVERIFY.
CSVDelay uint32
Comment thread
ellemouton marked this conversation as resolved.
}

// CheckpointTapScript constructs the tapscript for an OOR checkpoint output.
//
// The checkpoint tree for v0 is a simple two-leaf tree:
//
// - an operator-controlled CSV unroll leaf (operator key + relative
// timelock),
// - a collaborative leaf between operator and VTXO owner (provided by the
// caller as raw script bytes).
//
// "Owner" here means the owner of the VTXO being refreshed. The checkpoint
// output itself is still operator-controlled on the CSV timeout path.
//
Comment on lines +33 to +42

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.

hmm im a bit confused here. in my mind, the checkpoint is "owned" by the operator since the operator is the party who can sweep after the CSV (so that is the operator-controlled CSV leaf path), then : the other leave is a collab multisig between operator and client. ie, it is important that that is the exact script

@bhandras bhandras Feb 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Clarified the docs: checkpoint has operator CSV timeout leaf + collaborative operator/owner leaf, and this helper only commits the provided script bytes into the tree. Higher layers are responsible for enforcing that those bytes are the exact expected closure script.

// For v0, the checkpoint output always uses the ARK NUMS internal key so there
// is no key-path spend and all spends go through one of the script leaves.
//
// This function does not validate that ownerLeafScript is "a correct Ark
// closure". That validation belongs in higher layers once the canonical closure
// system is in place (see the closures PRs). For now, this gives OOR primitives
// a deterministic way to bind checkpoint scripts.
func CheckpointTapScript(policy CheckpointPolicy,
ownerLeafScript []byte) (*waddrmgr.Tapscript, error) {

switch {
case policy.OperatorKey == nil:
return nil, fmt.Errorf("operator key must be provided")

case len(ownerLeafScript) == 0:
return nil, fmt.Errorf("owner leaf script must be provided")
}

unrollLeaf, err := UnilateralCSVTimeoutTapLeaf(
policy.OperatorKey, policy.CSVDelay,
)
if err != nil {
return nil, fmt.Errorf("unable to construct unroll leaf: %w",
err)
}

ownerLeaf := txscript.NewBaseTapLeaf(ownerLeafScript)

tapscript := input.TapscriptFullTree(&ARKNUMSKey, unrollLeaf, ownerLeaf)

// Compute and set the root hash since TapscriptFullTree doesn't
// populate it.
tree := txscript.AssembleTaprootScriptTree(tapscript.Leaves...)
rootHash := tree.RootNode.TapHash()
tapscript.RootHash = rootHash[:]

return tapscript, nil
}

// CheckpointPkScript returns the pkScript for a checkpoint output produced by
// CheckpointTapScript.
//
// The caller should treat this as the canonical way to derive checkpoint output
// scripts for v0 OOR transfers so both client and server can validate and
// serialize checkpoint transactions deterministically.
func CheckpointPkScript(policy CheckpointPolicy,
ownerLeafScript []byte) ([]byte, error) {

tapscript, err := CheckpointTapScript(policy, ownerLeafScript)
if err != nil {
return nil, err
}

tapKey, err := tapscript.TaprootKey()
if err != nil {
return nil, fmt.Errorf("unable to compute taproot key: %w",
err)
}

return txscript.PayToTaprootScript(tapKey)
}
52 changes: 52 additions & 0 deletions lib/scripts/checkpoint_oor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package scripts

import (
"testing"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/txscript"
"github.com/stretchr/testify/require"
)

// TestCheckpointPkScriptIsTaproot asserts that the checkpoint helper returns a
// valid P2TR script and that it binds the expected internal key and tap tree.
func TestCheckpointPkScriptIsTaproot(t *testing.T) {
t.Parallel()

operatorKey, err := btcec.NewPrivateKey()
require.NoError(t, err)

policy := CheckpointPolicy{
OperatorKey: operatorKey.PubKey(),
CSVDelay: 10,
}

ownerLeafScript := []byte{
txscript.OP_1,
txscript.OP_1,
txscript.OP_ADD,
txscript.OP_2,
txscript.OP_EQUAL,
}

tapscript, err := CheckpointTapScript(policy, ownerLeafScript)
require.NoError(t, err)
require.NotNil(t, tapscript)

pkScript, err := CheckpointPkScript(policy, ownerLeafScript)
require.NoError(t, err)
require.True(t, txscript.IsPayToTaproot(pkScript))

tree := txscript.AssembleTaprootScriptTree(tapscript.Leaves...)
expectedRoot := tree.RootNode.TapHash()
require.Equal(t, expectedRoot[:], tapscript.RootHash)

expectedKey := txscript.ComputeTaprootOutputKey(
&ARKNUMSKey, tapscript.RootHash,
)

actualKey, err := tapscript.TaprootKey()
require.NoError(t, err)
require.Equal(t, expectedKey.SerializeCompressed(),
actualKey.SerializeCompressed())
}
Loading
Loading