diff --git a/Makefile b/Makefile index cc70b077a..1e221a17f 100644 --- a/Makefile +++ b/Makefile @@ -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' diff --git a/db/store.go b/db/store.go index af672e1ba..d20637d30 100644 --- a/db/store.go +++ b/db/store.go @@ -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"` diff --git a/harness/harness.go b/harness/harness.go index 89663922d..764c0b0cd 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -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 } @@ -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"` @@ -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 @@ -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) } @@ -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) } diff --git a/harness/tapd_harness.go b/harness/tapd_harness.go index a4739c67c..995c084db 100644 --- a/harness/tapd_harness.go +++ b/harness/tapd_harness.go @@ -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 diff --git a/lib/scripts/checkpoint_oor.go b/lib/scripts/checkpoint_oor.go new file mode 100644 index 000000000..ef7f6e70b --- /dev/null +++ b/lib/scripts/checkpoint_oor.go @@ -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 +} + +// 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. +// +// 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) +} diff --git a/lib/scripts/checkpoint_oor_test.go b/lib/scripts/checkpoint_oor_test.go new file mode 100644 index 000000000..23ba28737 --- /dev/null +++ b/lib/scripts/checkpoint_oor_test.go @@ -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()) +} diff --git a/lib/tx/oor/canonical.go b/lib/tx/oor/canonical.go new file mode 100644 index 000000000..0086fbb04 --- /dev/null +++ b/lib/tx/oor/canonical.go @@ -0,0 +1,221 @@ +package oor + +import ( + "bytes" + "fmt" + "sort" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" +) + +// IsAnchorOutput returns true if the output is the v0 Ark anchor output (P2A, +// value 0). +func IsAnchorOutput(out *wire.TxOut) bool { + if out == nil { + return false + } + + if out.Value != 0 { + return false + } + + return bytes.Equal(out.PkScript, scripts.AnchorPkScript) +} + +// ValidateCanonicalArkTx validates the canonical ordering rules for an Ark tx +// (as a raw transaction). +// +// The v0 rule set is based on BIP-0069-style sorting: +// - inputs are ordered by previous outpoint (txid, then vout); and +// - non-anchor outputs are ordered lexicographically by raw pkScript bytes, +// with output value used only as a tie-breaker. +// +// Ark txs additionally include exactly one anchor output (P2A, value 0), and +// the anchor output must be the final output. +// +// This is a structural validator only. It does not validate signatures, script +// satisfaction, or VTXO ownership. Those checks belong in higher-level +// validators that have access to policy and VTXO set state. +func ValidateCanonicalArkTx(tx *wire.MsgTx) error { + if tx == nil { + return fmt.Errorf("ark tx must be provided") + } + + if len(tx.TxOut) == 0 { + return fmt.Errorf("ark tx has no outputs") + } + + anchorCount := 0 + for _, out := range tx.TxOut { + if IsAnchorOutput(out) { + anchorCount++ + } + } + + if anchorCount != 1 { + return fmt.Errorf("ark tx must have exactly one anchor "+ + "output, got %d", anchorCount) + } + + last := tx.TxOut[len(tx.TxOut)-1] + if !IsAnchorOutput(last) { + return fmt.Errorf("ark tx must have anchor as last " + + "output") + } + + err := validateCanonicalArkOutputs(tx) + if err != nil { + return err + } + + return validateCanonicalArkInputs(tx) +} + +// ValidateCanonicalArkPSBT validates canonical ordering for an Ark tx PSBT. +func ValidateCanonicalArkPSBT(pkt *psbt.Packet) error { + if pkt == nil || pkt.UnsignedTx == nil { + return fmt.Errorf("ark psbt must include unsigned tx") + } + + return ValidateCanonicalArkTx(pkt.UnsignedTx) +} + +// validateCanonicalArkInputs validates BIP-0069-style ordering of Ark tx +// inputs. +func validateCanonicalArkInputs(tx *wire.MsgTx) error { + for i := 1; i < len(tx.TxIn); i++ { + prev := tx.TxIn[i-1].PreviousOutPoint + cur := tx.TxIn[i].PreviousOutPoint + + cmp := bytes.Compare(prev.Hash[:], cur.Hash[:]) + switch { + case cmp < 0: + continue + + case cmp > 0: + return fmt.Errorf("ark tx inputs are not canonical " + + "(outpoint hash order)") + } + + if prev.Index > cur.Index { + return fmt.Errorf("ark tx inputs are not canonical " + + "(outpoint index order)") + } + } + + return nil +} + +// validateCanonicalArkOutputs validates recipient output ordering rules and +// anchor placement for v0 OOR transfers. +// +// Recipient outputs exclude the anchor output. Their ordering is compatible +// with BIP-0069 output ordering, using raw pkScript bytes as the primary sort +// key. +func validateCanonicalArkOutputs(tx *wire.MsgTx) error { + if len(tx.TxOut) == 0 { + return fmt.Errorf("ark tx has no outputs") + } + + recipientOuts := tx.TxOut[:len(tx.TxOut)-1] + + for i := 1; i < len(recipientOuts); i++ { + prev := recipientOuts[i-1] + cur := recipientOuts[i] + + if IsAnchorOutput(prev) || IsAnchorOutput(cur) { + return fmt.Errorf("anchor output must be last") + } + + cmp := bytes.Compare(prev.PkScript, cur.PkScript) + switch { + case cmp < 0: + continue + + case cmp > 0: + return fmt.Errorf("ark tx outputs are not canonical " + + "(pkScript order)") + } + + if prev.Value > cur.Value { + return fmt.Errorf("ark tx outputs are not canonical " + + "(value tie-break)") + } + } + + return nil +} + +// CanonicalizeArkTxOrdering sorts the transaction inputs and outputs in-place +// according to the v0 rules. +// +// CanonicalizeArkTxOrdering does not insert or remove outputs. It assumes the +// caller has already constructed an Ark tx that includes exactly one anchor +// output of value 0. If the anchor output is missing or invalid, this returns +// an error rather than guessing what to do. +func CanonicalizeArkTxOrdering(tx *wire.MsgTx) error { + if tx == nil { + return fmt.Errorf("ark tx must be provided") + } + + if len(tx.TxOut) == 0 { + return fmt.Errorf("ark tx has no outputs") + } + + anchorIndex := -1 + for idx, out := range tx.TxOut { + if IsAnchorOutput(out) { + if anchorIndex != -1 { + return fmt.Errorf("multiple anchor outputs") + } + + anchorIndex = idx + } + } + + if anchorIndex == -1 { + return fmt.Errorf("missing anchor output") + } + + anchorOut := tx.TxOut[anchorIndex] + + recipientOuts := make([]*wire.TxOut, 0, len(tx.TxOut)-1) + for idx, out := range tx.TxOut { + if idx == anchorIndex { + continue + } + + recipientOuts = append(recipientOuts, out) + } + + sort.SliceStable(recipientOuts, func(i, j int) bool { + a := recipientOuts[i] + b := recipientOuts[j] + + cmp := bytes.Compare(a.PkScript, b.PkScript) + if cmp != 0 { + return cmp < 0 + } + + return a.Value < b.Value + }) + + recipientOuts = append(recipientOuts, anchorOut) + tx.TxOut = recipientOuts + + sort.SliceStable(tx.TxIn, func(i, j int) bool { + a := tx.TxIn[i].PreviousOutPoint + b := tx.TxIn[j].PreviousOutPoint + + cmp := bytes.Compare(a.Hash[:], b.Hash[:]) + if cmp != 0 { + return cmp < 0 + } + + return a.Index < b.Index + }) + + return nil +} diff --git a/lib/tx/oor/finalize.go b/lib/tx/oor/finalize.go new file mode 100644 index 000000000..bc901dd2e --- /dev/null +++ b/lib/tx/oor/finalize.go @@ -0,0 +1,111 @@ +package oor + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" +) + +// ApplyFinalizeData attaches per-input tap tree metadata from the Ark tx PSBT +// to the corresponding checkpoint PSBT output. +// +// In OOR transfers, finalization should not mutate the unsigned transactions; +// it should only attach output metadata needed for later auditing/tracing. This +// function therefore: +// +// - maps Ark inputs to checkpoint txs by matching Ark input prevouts to +// (checkpoint_txid, vout=0), and +// - sets `checkpoint.Outputs[0].TaprootTapTree` to the encoded tap tree blob +// extracted from the corresponding Ark PSBT input unknown field. +// +// The caller should treat the resulting checkpoint PSBTs as the canonical +// checkpoint representation to persist. +func ApplyFinalizeData(ark *psbt.Packet, + checkpoints []*psbt.Packet) error { + + switch { + case ark == nil || ark.UnsignedTx == nil: + return fmt.Errorf("ark psbt must include unsigned tx") + + case len(ark.Inputs) != len(ark.UnsignedTx.TxIn): + return fmt.Errorf("ark psbt input count mismatch") + + case len(checkpoints) == 0: + return fmt.Errorf("checkpoint psbts must be provided") + } + + checkpointByOutpoint := make( + map[wire.OutPoint]*psbt.Packet, len(checkpoints), + ) + + for _, checkpoint := range checkpoints { + if checkpoint == nil || checkpoint.UnsignedTx == nil { + return fmt.Errorf("checkpoint psbt must include " + + "unsigned tx") + } + + if len(checkpoint.UnsignedTx.TxOut) == 0 { + return fmt.Errorf("checkpoint tx has no outputs") + } + + if len(checkpoint.Outputs) != len(checkpoint.UnsignedTx.TxOut) { + return fmt.Errorf("checkpoint psbt output count " + + "mismatch") + } + + if len(checkpoint.Outputs) == 0 { + return fmt.Errorf("checkpoint psbt has no outputs") + } + + checkpointTxid := checkpoint.UnsignedTx.TxHash() + outpoint := wire.OutPoint{ + Hash: checkpointTxid, + Index: 0, + } + + _, exists := checkpointByOutpoint[outpoint] + if exists { + return fmt.Errorf("duplicate checkpoint txid: %s", + checkpointTxid) + } + + checkpointByOutpoint[outpoint] = checkpoint + } + + for i, txIn := range ark.UnsignedTx.TxIn { + prevOut := txIn.PreviousOutPoint + + checkpoint, ok := checkpointByOutpoint[prevOut] + if !ok { + return fmt.Errorf("ark input %d references "+ + "unknown checkpoint outpoint %s", i, + prevOut.String()) + } + + encodedTapTree, err := GetTapTreePSBTInput(ark.Inputs[i]) + if err != nil { + return fmt.Errorf("ark input %d missing tap tree "+ + "metadata: %w", i, err) + } + + if len(checkpoint.Outputs) == 0 { + return fmt.Errorf("checkpoint psbt has no outputs") + } + + if len(checkpoint.Outputs[0].TaprootTapTree) != 0 && + !bytes.Equal( + checkpoint.Outputs[0].TaprootTapTree, + encodedTapTree, + ) { + + return fmt.Errorf("checkpoint already has a " + + "different tap tree") + } + + checkpoint.Outputs[0].TaprootTapTree = encodedTapTree + } + + return nil +} diff --git a/lib/tx/oor/finalize_test.go b/lib/tx/oor/finalize_test.go new file mode 100644 index 000000000..63f405576 --- /dev/null +++ b/lib/tx/oor/finalize_test.go @@ -0,0 +1,154 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestApplyFinalizeDataAttachesTapTree asserts that finalization attaches the +// per-input tap tree metadata from the Ark tx PSBT input onto the corresponding +// checkpoint PSBT output. +func TestApplyFinalizeDataAttachesTapTree(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + checkpointOutpoint := wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + } + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: checkpointOutpoint, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + encodedTapTree, err := EncodeTapTree([][]byte{ + {0x51, 0x52, 0x53}, + {0x6a}, + }) + require.NoError(t, err) + + err = PutTapTreePSBTInput(arkPsbt, 0, encodedTapTree) + require.NoError(t, err) + + err = ApplyFinalizeData(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.NoError(t, err) + + checkpointTapTree := checkpointPsbt.Outputs[0].TaprootTapTree + require.Equal(t, encodedTapTree, checkpointTapTree) +} + +// TestApplyFinalizeDataMissingTapTree asserts we fail if the Ark PSBT input +// does not include the tap tree metadata required for finalization. +func TestApplyFinalizeDataMissingTapTree(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + checkpointOutpoint := wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + } + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: checkpointOutpoint, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + err = ApplyFinalizeData(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.Error(t, err) +} + +// TestApplyFinalizeDataMappingMismatch asserts we fail if the Ark tx input does +// not correspond to any checkpoint tx output outpoint. +func TestApplyFinalizeDataMappingMismatch(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + encodedTapTree, err := EncodeTapTree([][]byte{{0x51}}) + require.NoError(t, err) + + err = PutTapTreePSBTInput(arkPsbt, 0, encodedTapTree) + require.NoError(t, err) + + err = ApplyFinalizeData(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.Error(t, err) +} diff --git a/lib/tx/oor/finalize_validate.go b/lib/tx/oor/finalize_validate.go new file mode 100644 index 000000000..eee8aa899 --- /dev/null +++ b/lib/tx/oor/finalize_validate.go @@ -0,0 +1,119 @@ +package oor + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +// ValidateFinalizePackage validates a v0 OOR finalize package. +// +// The finalize package is the set of checkpoint PSBTs returned by the client +// with the client's signatures applied. This function validates that: +// +// - the Ark tx is present and canonical (needed for deterministic mapping), +// - each Ark input spends checkpoint output (txid:vout=0), +// - the provided checkpoint PSBTs correspond exactly to the set of checkpoint +// txids referenced by the Ark inputs, and +// - each checkpoint PSBT includes some finalized signature material. +// +// ValidateFinalizePackage is intentionally structural: it does not verify +// cryptographic signature correctness (that depends on VTXO scripts/policy). +// The caller is expected to perform full cryptographic validation with access +// to the correct tapscripts and key material. +func ValidateFinalizePackage(ark *psbt.Packet, + finalCheckpoints []*psbt.Packet) error { + + switch { + case ark == nil || ark.UnsignedTx == nil: + return fmt.Errorf("ark psbt must include unsigned tx") + + case len(finalCheckpoints) == 0: + return fmt.Errorf("final checkpoint psbts must be provided") + } + + err := ValidateCanonicalArkPSBT(ark) + if err != nil { + return err + } + + if len(ark.Inputs) != len(ark.UnsignedTx.TxIn) { + return fmt.Errorf("ark psbt input count mismatch") + } + + // Index the provided checkpoint PSBTs by txid so we can validate: + // - the set is unique (no duplicates); and + // - the set matches the Ark inputs exactly (no missing/extra txs). + checkpointByTxid := make(map[chainhash.Hash]*psbt.Packet, + len(finalCheckpoints), + ) + + for _, checkpoint := range finalCheckpoints { + if checkpoint == nil || checkpoint.UnsignedTx == nil { + return fmt.Errorf("checkpoint psbt must include " + + "unsigned tx") + } + + txid := checkpoint.UnsignedTx.TxHash() + if _, exists := checkpointByTxid[txid]; exists { + return fmt.Errorf("duplicate checkpoint txid: %s", + txid) + } + + if len(checkpoint.Inputs) != len(checkpoint.UnsignedTx.TxIn) { + return fmt.Errorf("checkpoint psbt input count " + + "mismatch") + } + + if len(checkpoint.Inputs) == 0 { + return fmt.Errorf("checkpoint psbt has no inputs") + } + + input := checkpoint.Inputs[0] + hasFinalWitness := len(input.FinalScriptWitness) > 0 || + len(input.FinalScriptSig) > 0 + + hasTaprootSigs := len(input.TaprootKeySpendSig) > 0 || + len(input.TaprootScriptSpendSig) > 0 + + if !hasFinalWitness && !hasTaprootSigs { + return fmt.Errorf("checkpoint %s missing finalize "+ + "signature material", txid) + } + + checkpointByTxid[txid] = checkpoint + } + + seen := make(map[wire.OutPoint]struct{}, len(ark.UnsignedTx.TxIn)) + for i, txIn := range ark.UnsignedTx.TxIn { + prevOut := txIn.PreviousOutPoint + + if prevOut.Index != 0 { + return fmt.Errorf("ark input %d spends "+ + "checkpoint output index %d, want 0", i, + prevOut.Index) + } + + _, ok := checkpointByTxid[prevOut.Hash] + if !ok { + return fmt.Errorf("ark input %d references "+ + "unknown checkpoint txid %s", i, prevOut.Hash) + } + + if _, exists := seen[prevOut]; exists { + return fmt.Errorf("duplicate checkpoint outpoint "+ + "in ark inputs: %s", prevOut) + } + + seen[prevOut] = struct{}{} + } + + if len(seen) != len(checkpointByTxid) { + return fmt.Errorf("final checkpoint set does not match " + + "ark inputs") + } + + return nil +} diff --git a/lib/tx/oor/finalize_validate_test.go b/lib/tx/oor/finalize_validate_test.go new file mode 100644 index 000000000..a159bac2b --- /dev/null +++ b/lib/tx/oor/finalize_validate_test.go @@ -0,0 +1,160 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestValidateFinalizePackageHappyPath asserts a structurally valid finalize +// package is accepted. +func TestValidateFinalizePackageHappyPath(t *testing.T) { + t.Parallel() + + // Finalize validation is intentionally shallow: + // - We only require that every checkpoint has some final witness. + // - Signature correctness is out of scope for v0 structural checks. + // + // This keeps the server-side validation lightweight and allows tests + // to use synthetic signatures. + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + // For structural validation, a non-empty final witness is sufficient. + checkpointPsbt.Inputs[0].FinalScriptWitness = []byte{0x01} + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + err = ValidateFinalizePackage(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.NoError(t, err) +} + +// TestValidateFinalizePackageMissingSig asserts we reject finalize checkpoints +// that do not include any signature material. +func TestValidateFinalizePackageMissingSig(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + err = ValidateFinalizePackage(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.Error(t, err) +} + +// TestValidateFinalizePackageExtraCheckpoint asserts the checkpoint set must +// match the Ark input set exactly. +func TestValidateFinalizePackageExtraCheckpoint(t *testing.T) { + t.Parallel() + + checkpointTxA := wire.NewMsgTx(3) + checkpointTxA.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTxA.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbtA, err := psbt.NewFromUnsignedTx(checkpointTxA) + require.NoError(t, err) + checkpointPsbtA.Inputs[0].FinalScriptWitness = []byte{0x01} + + checkpointTxB := wire.NewMsgTx(3) + checkpointTxB.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 9, + }, + }) + checkpointTxB.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbtB, err := psbt.NewFromUnsignedTx(checkpointTxB) + require.NoError(t, err) + checkpointPsbtB.Inputs[0].FinalScriptWitness = []byte{0x01} + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTxA.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + err = ValidateFinalizePackage(arkPsbt, []*psbt.Packet{ + checkpointPsbtA, + checkpointPsbtB, + }) + require.Error(t, err) +} diff --git a/lib/tx/oor/submit.go b/lib/tx/oor/submit.go new file mode 100644 index 000000000..2b01a17cf --- /dev/null +++ b/lib/tx/oor/submit.go @@ -0,0 +1,168 @@ +package oor + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +// ValidatedSubmitPackage contains derived facts from a submit package that are +// useful to higher layers (server coordinator, client FSM, tests). +type ValidatedSubmitPackage struct { + // ArkTxid is the txid of the unsigned Ark tx. + // + // This is also the v0 session identifier for submit/finalize. + ArkTxid chainhash.Hash + + // CheckpointOutpoints are the checkpoint outputs (txid:vout=0) that the + // Ark tx spends, in the same order as Ark tx inputs. + CheckpointOutpoints []wire.OutPoint +} + +// ValidateSubmitPackage validates a v0 OOR submit package. +// +// This is a structural validator. It validates that: +// +// - the Ark PSBT is present and canonical (anchor last, one anchor, canonical +// input/output ordering), +// - each Ark tx input spends a checkpoint tx output (txid:vout=0), +// - the provided checkpoint txs cover all Ark inputs (no missing or extra), +// - each Ark PSBT input has a witness UTXO that matches the referenced +// checkpoint tx output 0 (script + value), and +// - each Ark PSBT input includes the `taptree` metadata needed for later +// finalization. +// +// ValidateSubmitPackage does not validate: +// +// - client or operator signatures, +// - whether the checkpoint txs correctly spend live VTXOs, +// - whether checkpoint scripts match operator policy, or +// - VTXO set state / locking. +// +// Those checks belong to higher layers that have access to policy and state. +func ValidateSubmitPackage(ark *psbt.Packet, + checkpoints []*psbt.Packet) (*ValidatedSubmitPackage, error) { + + switch { + case ark == nil || ark.UnsignedTx == nil: + return nil, fmt.Errorf("ark psbt must include unsigned tx") + + case len(checkpoints) == 0: + return nil, fmt.Errorf("checkpoint psbts must be provided") + } + + err := ValidateCanonicalArkPSBT(ark) + if err != nil { + return nil, err + } + + if len(ark.Inputs) != len(ark.UnsignedTx.TxIn) { + return nil, fmt.Errorf("ark psbt input count mismatch") + } + + // Index checkpoint PSBTs by txid so we can: + // - check for duplicates early; and + // - validate that the checkpoint set is exactly the set referenced + // by the Ark tx inputs (no missing or extra checkpoints). + checkpointByTxid := make(map[chainhash.Hash]*psbt.Packet, + len(checkpoints), + ) + for _, checkpoint := range checkpoints { + if checkpoint == nil || checkpoint.UnsignedTx == nil { + return nil, fmt.Errorf("checkpoint psbt must include " + + "unsigned tx") + } + + checkpointTxid := checkpoint.UnsignedTx.TxHash() + if _, exists := checkpointByTxid[checkpointTxid]; exists { + return nil, fmt.Errorf("duplicate checkpoint txid: %s", + checkpointTxid) + } + + if len(checkpoint.UnsignedTx.TxOut) == 0 { + return nil, fmt.Errorf("checkpoint tx has no outputs") + } + + checkpointByTxid[checkpointTxid] = checkpoint + } + + seenCheckpoint := make(map[wire.OutPoint]struct{}, + len(ark.UnsignedTx.TxIn), + ) + outpoints := make([]wire.OutPoint, 0, len(ark.UnsignedTx.TxIn)) + + for i, txIn := range ark.UnsignedTx.TxIn { + prevOut := txIn.PreviousOutPoint + + // v0 assumes each Ark input spends vout=0 of the checkpoint tx. + // + // This gives a canonical mapping between checkpoint txs and Ark + // inputs, without needing per-input metadata. + if prevOut.Index != 0 { + return nil, fmt.Errorf("ark input %d spends "+ + "checkpoint output index %d, want 0", i, + prevOut.Index) + } + + checkpointPkt, ok := checkpointByTxid[prevOut.Hash] + if !ok { + return nil, fmt.Errorf("ark input %d references "+ + "unknown checkpoint txid %s", i, prevOut.Hash) + } + + _, exists := seenCheckpoint[prevOut] + if exists { + return nil, fmt.Errorf("duplicate checkpoint outpoint "+ + "in ark inputs: %s", prevOut) + } + + seenCheckpoint[prevOut] = struct{}{} + outpoints = append(outpoints, prevOut) + + // Require witness UTXOs so the package is self-contained. + // The receiver should not need to fetch prevouts from chain to + // validate the PSBT structure. + witnessUtxo := ark.Inputs[i].WitnessUtxo + if witnessUtxo == nil { + return nil, fmt.Errorf("ark input %d missing witness "+ + "utxo", i) + } + + checkpointOut := checkpointPkt.UnsignedTx.TxOut[0] + if witnessUtxo.Value != checkpointOut.Value { + return nil, fmt.Errorf("ark input %d witness utxo "+ + "value mismatch", i) + } + + if !bytes.Equal(witnessUtxo.PkScript, checkpointOut.PkScript) { + return nil, fmt.Errorf("ark input %d witness utxo "+ + "script mismatch", i) + } + + _, err := GetTapTreePSBTInput(ark.Inputs[i]) + if err != nil { + return nil, fmt.Errorf("ark input %d missing tap "+ + "tree metadata: %w", i, err) + } + } + + // Ensure the checkpoint set is exactly the set referenced by Ark + // inputs. + // + // We allow extra checkpoint PSBTs to be rejected here so callers can + // rely on "checkpoint list is session-complete" semantics. + if len(seenCheckpoint) != len(checkpointByTxid) { + return nil, fmt.Errorf("checkpoint set does not match ark " + + "inputs") + } + + arkTxid := ark.UnsignedTx.TxHash() + + return &ValidatedSubmitPackage{ + ArkTxid: arkTxid, + CheckpointOutpoints: outpoints, + }, nil +} diff --git a/lib/tx/oor/submit_test.go b/lib/tx/oor/submit_test.go new file mode 100644 index 000000000..d6f01dfac --- /dev/null +++ b/lib/tx/oor/submit_test.go @@ -0,0 +1,181 @@ +package oor + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/stretchr/testify/require" +) + +// TestValidateSubmitPackageHappyPath asserts a well-formed submit package +// validates successfully and produces derived mapping info. +func TestValidateSubmitPackageHappyPath(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + + checkpointOut := &wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + } + checkpointTx.AddTxOut(checkpointOut) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + arkPsbt.Inputs[0].WitnessUtxo = checkpointOut + + encodedTapTree, err := EncodeTapTree([][]byte{{0x51}}) + require.NoError(t, err) + + err = PutTapTreePSBTInput(arkPsbt, 0, encodedTapTree) + require.NoError(t, err) + + validated, err := ValidateSubmitPackage( + arkPsbt, []*psbt.Packet{checkpointPsbt}, + ) + require.NoError(t, err) + require.NotNil(t, validated) + require.Equal(t, arkTx.TxHash(), validated.ArkTxid) + require.Len(t, validated.CheckpointOutpoints, 1) + require.Equal(t, arkTx.TxIn[0].PreviousOutPoint, + validated.CheckpointOutpoints[0]) +} + +// TestValidateSubmitPackageMissingWitness asserts we reject if Ark PSBT inputs +// don't carry witness UTXOs. +func TestValidateSubmitPackageMissingWitness(t *testing.T) { + t.Parallel() + + checkpointTx := wire.NewMsgTx(3) + checkpointTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbt, err := psbt.NewFromUnsignedTx(checkpointTx) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTx.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + encodedTapTree, err := EncodeTapTree([][]byte{{0x51}}) + require.NoError(t, err) + + err = PutTapTreePSBTInput(arkPsbt, 0, encodedTapTree) + require.NoError(t, err) + + _, err = ValidateSubmitPackage(arkPsbt, []*psbt.Packet{checkpointPsbt}) + require.Error(t, err) +} + +// TestValidateSubmitPackageExtraCheckpoint asserts we reject if the caller +// supplies checkpoint PSBTs that don't match the Ark inputs. +func TestValidateSubmitPackageExtraCheckpoint(t *testing.T) { + t.Parallel() + + checkpointTxA := wire.NewMsgTx(3) + checkpointTxA.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 7, + }, + }) + checkpointOutA := &wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + } + checkpointTxA.AddTxOut(checkpointOutA) + + checkpointPsbtA, err := psbt.NewFromUnsignedTx(checkpointTxA) + require.NoError(t, err) + + checkpointTxB := wire.NewMsgTx(3) + checkpointTxB.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 9, + }, + }) + checkpointTxB.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x51}, + }) + + checkpointPsbtB, err := psbt.NewFromUnsignedTx(checkpointTxB) + require.NoError(t, err) + + arkTx := wire.NewMsgTx(3) + arkTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: checkpointTxA.TxHash(), + Index: 0, + }, + }) + arkTx.AddTxOut(&wire.TxOut{ + Value: 1234, + PkScript: []byte{0x6a, 0x01, 0x01}, + }) + arkTx.AddTxOut(scripts.AnchorOutput()) + + arkPsbt, err := psbt.NewFromUnsignedTx(arkTx) + require.NoError(t, err) + + arkPsbt.Inputs[0].WitnessUtxo = checkpointOutA + + encodedTapTree, err := EncodeTapTree([][]byte{{0x51}}) + require.NoError(t, err) + + err = PutTapTreePSBTInput(arkPsbt, 0, encodedTapTree) + require.NoError(t, err) + + _, err = ValidateSubmitPackage(arkPsbt, []*psbt.Packet{ + checkpointPsbtA, + checkpointPsbtB, + }) + require.Error(t, err) +} diff --git a/lib/tx/oor/taptree.go b/lib/tx/oor/taptree.go new file mode 100644 index 000000000..3995cea30 --- /dev/null +++ b/lib/tx/oor/taptree.go @@ -0,0 +1,191 @@ +package oor + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +var ( + // TapTreePSBTKey is the v0 convention for storing a taproot tree + // encoding in a PSBT input unknown field. + // + // We treat this as part of the OOR PSBT profile so client and server + // implementations can deterministically attach, validate, and later use + // the same metadata during finalization. + TapTreePSBTKey = []byte("taptree") +) + +// EncodeTapTree encodes a set of tapscript leaves into a single byte blob. +// +// EncodeTapTree intentionally uses a simple leaf list representation that is +// sufficient for v0 OOR transfers. Each leaf is encoded at depth 1 with the +// base tapscript leaf version. The encoding uses Bitcoin varint (compact size) +// lengths and is compatible with how many BIP-371 encodings represent tap +// trees. +// +// This encoding is part of the PSBT profile for OOR transfers. If we ever need +// to support richer trees (multiple depths), this function must become +// versioned rather than changing behavior silently. +func EncodeTapTree(leaves [][]byte) ([]byte, error) { + var buf bytes.Buffer + + err := wire.WriteVarInt(&buf, 0, uint64(len(leaves))) + if err != nil { + return nil, fmt.Errorf("unable to write leaf count: %w", + err) + } + + for _, leaf := range leaves { + err := buf.WriteByte(1) + if err != nil { + return nil, fmt.Errorf("unable to write depth: %w", + err) + } + + err = buf.WriteByte(byte(txscript.BaseLeafVersion)) + if err != nil { + return nil, fmt.Errorf("unable to write leaf "+ + "version: %w", err) + } + + err = wire.WriteVarInt(&buf, 0, uint64(len(leaf))) + if err != nil { + return nil, fmt.Errorf("unable to write leaf "+ + "length: %w", err) + } + + _, err = buf.Write(leaf) + if err != nil { + return nil, fmt.Errorf("unable to write leaf "+ + "script: %w", err) + } + } + + return buf.Bytes(), nil +} + +// DecodeTapTree decodes a tap tree encoding produced by EncodeTapTree. +// +// DecodeTapTree is intentionally lenient about leaf depth and version in v0: +// it reads and ignores them. The returned value is the list of raw script +// bytes for each leaf. +func DecodeTapTree(data []byte) ([][]byte, error) { + buf := bytes.NewReader(data) + + leafCount, err := wire.ReadVarInt(buf, 0) + if err != nil { + return nil, fmt.Errorf("unable to read leaf count: %w", + err) + } + + leaves := make([][]byte, 0, leafCount) + for i := uint64(0); i < leafCount; i++ { + _, err := buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("unable to read depth: %w", + err) + } + + _, err = buf.ReadByte() + if err != nil { + return nil, fmt.Errorf("unable to read leaf "+ + "version: %w", err) + } + + scriptLen, err := wire.ReadVarInt(buf, 0) + if err != nil { + return nil, fmt.Errorf("unable to read script "+ + "length: %w", err) + } + + scriptBytes := make([]byte, scriptLen) + _, err = buf.Read(scriptBytes) + if err != nil { + return nil, fmt.Errorf( + "unable to read script bytes: %w", err, + ) + } + + leaves = append(leaves, scriptBytes) + } + + if buf.Len() != 0 { + return nil, fmt.Errorf("trailing bytes in tap tree "+ + "encoding (%d bytes)", buf.Len()) + } + + return leaves, nil +} + +// PutTapTreePSBTInput stores an encoded tap tree blob into the given PSBT input +// unknown fields, using TapTreePSBTKey. +func PutTapTreePSBTInput(pkt *psbt.Packet, inputIndex int, + encodedTapTree []byte) error { + + switch { + case pkt == nil: + return fmt.Errorf("psbt packet must be provided") + + case inputIndex < 0 || inputIndex >= len(pkt.Inputs): + return fmt.Errorf("input index out of range: %d", + inputIndex) + + case len(encodedTapTree) == 0: + return fmt.Errorf("encoded tap tree cannot be empty") + } + + // Replace any existing entry to keep this idempotent and avoid + // ambiguous multiple values. + unknowns := pkt.Inputs[inputIndex].Unknowns + for _, u := range unknowns { + if bytes.Equal(u.Key, TapTreePSBTKey) { + u.Value = encodedTapTree + return nil + } + } + + unknowns = append(unknowns, &psbt.Unknown{ + Key: TapTreePSBTKey, + Value: encodedTapTree, + }) + pkt.Inputs[inputIndex].Unknowns = unknowns + + return nil +} + +// GetTapTreePSBTInput retrieves an encoded tap tree blob from a PSBT input's +// unknown fields. +func GetTapTreePSBTInput(input psbt.PInput) ([]byte, error) { + var ( + tapTreeValue []byte + found bool + ) + + for _, u := range input.Unknowns { + if bytes.Equal(u.Key, TapTreePSBTKey) { + if found { + return nil, fmt.Errorf("multiple tap tree " + + "entries found") + } + + if len(u.Value) == 0 { + return nil, fmt.Errorf( + "tap tree value is empty", + ) + } + + tapTreeValue = u.Value + found = true + } + } + + if !found { + return nil, fmt.Errorf("tap tree not found") + } + + return tapTreeValue, nil +} diff --git a/lib/tx/oor/taptree_test.go b/lib/tx/oor/taptree_test.go new file mode 100644 index 000000000..ed862be49 --- /dev/null +++ b/lib/tx/oor/taptree_test.go @@ -0,0 +1,27 @@ +package oor + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTapTreeRoundTrip asserts our v0 tap tree encoding is stable and +// round-trippable. +func TestTapTreeRoundTrip(t *testing.T) { + t.Parallel() + + leaves := [][]byte{ + {0x51, 0x51, 0x51}, + {0x6a}, + {0x00, 0x01, 0x02, 0x03}, + } + + encoded, err := EncodeTapTree(leaves) + require.NoError(t, err) + + decoded, err := DecodeTapTree(encoded) + require.NoError(t, err) + + require.Equal(t, leaves, decoded) +} diff --git a/wallet/wallet.go b/wallet/wallet.go index d973be5dc..2cfc3da06 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "sync" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -33,6 +34,17 @@ const ( // Subsystem is the log subsystem code for the boarding wallet actor. Subsystem = "ARKW" + + // listUnspentMaxRetries is the maximum number of times we'll retry a + // ListUnspent query within a single block epoch if we didn't detect any + // new boarding UTXOs. This mitigates a race where we receive a block + // epoch notification before the wallet's UTXO set is fully updated. + listUnspentMaxRetries = 5 + + // listUnspentRetryDelay is the delay between ListUnspent retries. + // We keep this small so confirmed boarding UTXOs are detected + // promptly without waiting for another block. + listUnspentRetryDelay = 200 * time.Millisecond ) // notifierInfo holds the configuration for a registered confirmation notifier. @@ -403,29 +415,53 @@ func (a *Ark) handleBlockEpoch(ctx context.Context, a.log.InfoS(ctx, "Processing new block epoch", slog.Int("height", int(epoch.Height))) - // A new block just arrived, we'll now poll ListUnspent for any new - // UTXOs since last time. - utxos, err := a.backend.ListUnspent( - ctx, MinBoardingConfs, MaxConfsForListUnspent, + // A new block just arrived, so poll ListUnspent for new UTXOs. + // Retry a few times because there can be a short lag between + // receiving the block epoch and the wallet reporting the UTXO with + // the expected confirmation count. + var ( + lastUtxos []*Utxo + foundNew bool ) - if err != nil { - a.log.WarnS(ctx, "Failed to list unspent UTXOs", err, - slog.Int("height", int(epoch.Height))) + for attempt := 0; attempt < listUnspentMaxRetries; attempt++ { + utxos, err := a.backend.ListUnspent( + ctx, MinBoardingConfs, MaxConfsForListUnspent, + ) + if err != nil { + a.log.WarnS(ctx, "Failed to list unspent UTXOs", err, + slog.Int("height", int(epoch.Height))) + + // Return success to avoid disrupting the actor. + // We'll try again on the next block. + return fn.Ok[WalletResp](nil) + } + + lastUtxos = utxos + + // For each UTXO, we'll check if it's new and belongs to a fresh + // boarding intent, dispatching notifications if needed. + for _, utxo := range utxos { + if a.processUtxo(ctx, epoch, utxo) { + foundNew = true + } + } - // Return success to avoid disrupting the actor - we'll try - // again on the next block. - return fn.Ok[WalletResp](nil) + if foundNew || attempt == listUnspentMaxRetries-1 { + break + } + + timer := time.NewTimer(listUnspentRetryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return fn.Ok[WalletResp](nil) + case <-timer.C: + } } a.log.InfoS(ctx, "ListUnspent returned UTXOs", slog.Int("height", int(epoch.Height)), - slog.Int("utxo_count", len(utxos))) - - // For Each UTXO, we'll check if it's new and belongs to a fresh - // boarding intent, dispatching notifications if needed. - for _, utxo := range utxos { - a.processUtxo(ctx, epoch, utxo) - } + slog.Int("utxo_count", len(lastUtxos))) // Block epoch handling doesn't require a response. return fn.Ok[WalletResp](nil) @@ -433,19 +469,19 @@ func (a *Ark) handleBlockEpoch(ctx context.Context, // processUtxo checks if a UTXO is new and belongs to a boarding address. func (a *Ark) processUtxo(ctx context.Context, - epoch chainsource.BlockEpoch, utxo *Utxo) { + epoch chainsource.BlockEpoch, utxo *Utxo) bool { // Make sure we haven't already seen this UTXO. key := NewUtxoKey(utxo.Outpoint) if a.seenUtxos.Contains(key) { - return + return false } // Check if this UTXO pays to a boarding address. addr, err := a.store.LookupBoardingAddress(ctx, utxo.PkScript) if err != nil { // Not a boarding address, ignore. - return + return false } // New boarding UTXO detected! @@ -462,7 +498,7 @@ func (a *Ark) processUtxo(ctx context.Context, a.log.WarnS(ctx, "Failed to fetch boarding transaction", err, btclog.Fmt("txid", "%v", utxo.Outpoint.Hash)) - return + return false } intent := BoardingIntent{ @@ -486,7 +522,7 @@ func (a *Ark) processUtxo(ctx context.Context, a.log.WarnS(ctx, "Failed to persist boarding intent", err, btclog.Fmt("outpoint", "%v", utxo.Outpoint)) - return + return false } a.seenUtxos.Add(key) @@ -503,6 +539,8 @@ func (a *Ark) processUtxo(ctx context.Context, } } } + + return true } // sendBacklog sends recent confirmations to a newly registered notifier. It