diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ebc3468a0..26d663ea5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,8 @@ package may import from a higher layer. | [`lib/tx/checkpoint`](lib/tx/checkpoint/) | Checkpoint PSBT construction for OOR transfers | | [`lib/tx/oor`](lib/tx/oor/) | OOR submit/finalize package builders and validators | | [`lib/tx/psbtutil`](lib/tx/psbtutil/) | PSBT encoding, decoding, and signature attachment helpers | +| [`lib/recovery`](lib/recovery/) | Immutable recovery proof graph, session state machine, TLV codec for unilateral exit | +| [`unrollplan`](unrollplan/) | Pure dependency-resolution planner driving unilateral-exit broadcast/sweep ordering | ### Layer 2: Infrastructure (Chain, Storage, Messaging) diff --git a/lib/recovery/AGENTS.md b/lib/recovery/AGENTS.md new file mode 100644 index 000000000..e8102ffad --- /dev/null +++ b/lib/recovery/AGENTS.md @@ -0,0 +1,62 @@ +# lib/recovery + +## Purpose + +Pure, immutable proof graph plus per-session planning state for unilateral +exit / recovery of a VTXO target outpoint. The package exposes the data model +(proof, session, durable state) and a TLV codec for crash-safe persistence; +actual broadcast orchestration lives downstream in later PRs. + +## Key Types + +- `Proof` — Immutable recovery graph: target outpoint, csv delay, topologically + layered transaction nodes, parent/child adjacencies, reachability-checked. +- `Node` / `NodeKind` — One recovery transaction and its role (tree / + checkpoint / ark). +- `Session` — Mutable planning object driven by caller-reported observations + (`MarkBroadcasted`, `MarkConfirmed`, `MarkFailed`). Goroutine-safe via + `sync.RWMutex`. +- `Snapshot` / `SessionStatus` — Caller-facing view of session progress at a + block height, including CSV maturity and ready/blocked frontiers. +- `SessionState` — Durable caller-owned state suitable for TLV persistence. + Optional fields use `fn.Option` instead of nilable pointers. +- `ComputeMaturityHeight` — Overflow-safe `targetConfirmHeight + csvDelay` + helper shared with `unrollplan`. + +## Relationships + +- **Depends on**: `lib/arkscript` (AnchorPkScript detection on nodes), + `lib/tree` (generic BFS `Queue[T]` for iterative ancestor traversal), + `github.com/lightningnetwork/lnd/fn/v2` (Option type), + `github.com/lightningnetwork/lnd/tlv` (state / proof codec). +- **Depended on by**: `unrollplan` (pure planning layer; re-uses + `Proof`, `Node`, `ComputeMaturityHeight`). Later recovery PRs (3/5, 4/5, + 5/5) will consume the codec for checkpoint persistence. + +## Invariants + +- `csvDelay` is a raw block count (not a BIP-68-encoded sequence) and is + capped at `MaxCSVDelay` (65535, the BIP-68 height-mode limit). +- `len(nodes)` is capped at `MaxProofNodes` to bound the cost of graph + validation against adversarial inputs. +- Every node in a `Proof` is reachable (via parents) from the target outpoint; + unreachable nodes fail construction. +- Parent/child reachability traversal uses an iterative BFS (`tree.Queue`), so + a deeply-adversarial graph cannot blow the goroutine stack. +- Every `MarkConfirmed` call requires prior `MarkBroadcasted`, all parents + confirmed, a non-negative height, and refuses re-confirmation at a + different height. A same-height re-confirmation is idempotent. +- `MarkFailed` refuses to overwrite an existing terminal failure so the root + cause survives across a restart. +- `Session` methods are safe for concurrent use under `RWMutex`; internal + helpers assume the caller already holds the lock. +- The TLV codec is canonical (sorted by raw hash bytes) and carries an + explicit version byte; version mismatch is a hard decode error. +- `parseHash` via `chainhash.NewHashFromStr` is intentionally absent: raw + 32-byte hashes are encoded directly to avoid the short-form / zero-pad + attack surface that JSON shipping with `chainhash.Hash.String()` would open. + +## Deep Docs + +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — System-wide package map. +- [lib/CLAUDE.md](../CLAUDE.md) — Parent lib package overview. diff --git a/lib/recovery/CLAUDE.md b/lib/recovery/CLAUDE.md new file mode 100644 index 000000000..e8102ffad --- /dev/null +++ b/lib/recovery/CLAUDE.md @@ -0,0 +1,62 @@ +# lib/recovery + +## Purpose + +Pure, immutable proof graph plus per-session planning state for unilateral +exit / recovery of a VTXO target outpoint. The package exposes the data model +(proof, session, durable state) and a TLV codec for crash-safe persistence; +actual broadcast orchestration lives downstream in later PRs. + +## Key Types + +- `Proof` — Immutable recovery graph: target outpoint, csv delay, topologically + layered transaction nodes, parent/child adjacencies, reachability-checked. +- `Node` / `NodeKind` — One recovery transaction and its role (tree / + checkpoint / ark). +- `Session` — Mutable planning object driven by caller-reported observations + (`MarkBroadcasted`, `MarkConfirmed`, `MarkFailed`). Goroutine-safe via + `sync.RWMutex`. +- `Snapshot` / `SessionStatus` — Caller-facing view of session progress at a + block height, including CSV maturity and ready/blocked frontiers. +- `SessionState` — Durable caller-owned state suitable for TLV persistence. + Optional fields use `fn.Option` instead of nilable pointers. +- `ComputeMaturityHeight` — Overflow-safe `targetConfirmHeight + csvDelay` + helper shared with `unrollplan`. + +## Relationships + +- **Depends on**: `lib/arkscript` (AnchorPkScript detection on nodes), + `lib/tree` (generic BFS `Queue[T]` for iterative ancestor traversal), + `github.com/lightningnetwork/lnd/fn/v2` (Option type), + `github.com/lightningnetwork/lnd/tlv` (state / proof codec). +- **Depended on by**: `unrollplan` (pure planning layer; re-uses + `Proof`, `Node`, `ComputeMaturityHeight`). Later recovery PRs (3/5, 4/5, + 5/5) will consume the codec for checkpoint persistence. + +## Invariants + +- `csvDelay` is a raw block count (not a BIP-68-encoded sequence) and is + capped at `MaxCSVDelay` (65535, the BIP-68 height-mode limit). +- `len(nodes)` is capped at `MaxProofNodes` to bound the cost of graph + validation against adversarial inputs. +- Every node in a `Proof` is reachable (via parents) from the target outpoint; + unreachable nodes fail construction. +- Parent/child reachability traversal uses an iterative BFS (`tree.Queue`), so + a deeply-adversarial graph cannot blow the goroutine stack. +- Every `MarkConfirmed` call requires prior `MarkBroadcasted`, all parents + confirmed, a non-negative height, and refuses re-confirmation at a + different height. A same-height re-confirmation is idempotent. +- `MarkFailed` refuses to overwrite an existing terminal failure so the root + cause survives across a restart. +- `Session` methods are safe for concurrent use under `RWMutex`; internal + helpers assume the caller already holds the lock. +- The TLV codec is canonical (sorted by raw hash bytes) and carries an + explicit version byte; version mismatch is a hard decode error. +- `parseHash` via `chainhash.NewHashFromStr` is intentionally absent: raw + 32-byte hashes are encoded directly to avoid the short-form / zero-pad + attack surface that JSON shipping with `chainhash.Hash.String()` would open. + +## Deep Docs + +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — System-wide package map. +- [lib/CLAUDE.md](../CLAUDE.md) — Parent lib package overview. diff --git a/lib/recovery/doc.go b/lib/recovery/doc.go new file mode 100644 index 000000000..a561697b6 --- /dev/null +++ b/lib/recovery/doc.go @@ -0,0 +1,63 @@ +// Package recovery models the data plane of unilateral-exit recovery for one +// VTXO target outpoint. +// +// # Mental model +// +// A "recovery proof" is the set of transactions a user must broadcast, in +// dependency order, to unilaterally materialize an on-chain output they own +// inside an Ark tree (or OOR lineage) — and, after a CSV timeout, spend it. +// Conceptually the proof is a DAG: +// +// roots (self-funded by the user) +// │ +// ▼ +// tree / checkpoint intermediates +// │ +// ▼ +// target node (creates the spendable outpoint) +// │ +// ▼ +// (CSV delay elapses) +// │ +// ▼ +// sweep (spends target outpoint to a destination) +// +// This package is deliberately narrow: it owns the graph (`Proof`), the +// per-session state machine (`Session`), and the durable projection of that +// state (`SessionState`) plus a TLV codec in state_codec.go / proof_codec.go. +// It does NOT: +// +// - broadcast transactions +// - talk to a chain backend +// - schedule retries +// - spawn goroutines +// +// All of those concerns live in downstream consumers (the planner in +// `unrollplan`, and the actor wiring that follows in later PRs in the stack). +// Keeping recovery I/O-free and synchronous makes the data model amenable to +// property-based testing and lets consumers pick their own reliability +// mechanics. +// +// # Layering +// +// Every node's position is precomputed by a Kahn-style topological layering +// so consumers never have to recurse over the DAG themselves; they iterate +// layer-by-layer from roots to the target and ask the session which nodes at +// each layer are ready, in flight, awaiting confirmation, or blocked. +// +// # Invariants worth knowing +// +// - CSV delay is a raw block count bounded by MaxCSVDelay (BIP-68 height- +// mode limit, 65535 blocks). Any caller who sources the delay from a +// BIP-68-encoded sequence must decode the block count first. +// - NewProof rejects: nil nodes, duplicate txids, a target not in the +// graph, an out-of-bounds target output index, unreachable nodes +// (nodes that cannot be connected to the target via the parents map), +// and cycles. +// - Session is goroutine-safe under an RWMutex. MarkConfirmed enforces the +// full topological invariant (parents confirmed before children) so that +// a reorg-aware caller cannot accidentally corrupt the session. +// - SessionState validation is symmetric with the Session state machine — +// a persisted state that would have been rejected by MarkConfirmed is +// also rejected by NewSessionFromState. +package recovery diff --git a/lib/recovery/proof.go b/lib/recovery/proof.go new file mode 100644 index 000000000..1fc403cb8 --- /dev/null +++ b/lib/recovery/proof.go @@ -0,0 +1,606 @@ +package recovery + +import ( + "bytes" + "fmt" + "sort" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// NodeKind identifies the type of recovery transaction a node represents. +// The kind is metadata only — it does not affect topological planning, which +// treats every node uniformly by txid and parent list. We keep the kind on +// the Node so downstream consumers (UI, logs, broadcaster policy) can make +// type-specific decisions (for example, fee-bumping policy differs between a +// tree tx and a checkpoint tx) without having to re-classify. +type NodeKind int + +const ( + // NodeKindTree marks a round tree transaction — an intermediate node in + // the VTXO Merkle tree produced by an Ark round. + NodeKindTree NodeKind = iota + + // NodeKindCheckpoint marks a checkpoint transaction in OOR lineage. A + // checkpoint pins a user's outgoing Ark payment to the chain before the + // Ark tx itself is finalized. + NodeKindCheckpoint + + // NodeKindArk marks an Ark transaction that spends checkpoint outputs + // to produce new VTXOs. In the recovery graph it is typically the + // target (or a near-target ancestor). + NodeKindArk +) + +// String returns the stable debug label for a NodeKind. +func (k NodeKind) String() string { + switch k { + case NodeKindTree: + return "tree" + + case NodeKindCheckpoint: + return "checkpoint" + + case NodeKindArk: + return "ark" + + default: + return fmt.Sprintf("unknown(%d)", k) + } +} + +// Node is one recovery transaction in a proof graph. A Node is a value +// object: the planner never mutates it once it is handed to NewProof. The +// planner also does not care whether the caller hands in a signed or +// unsigned tx — txid is invariant across signing for the transaction shapes +// used in Ark recovery (segwit / taproot inputs only), so the graph +// computation is the same either way. Callers who intend to actually +// broadcast must of course sign before doing so. +type Node struct { + // Kind describes the role of this transaction in the proof. + Kind NodeKind + + // Tx is the unsigned or signed transaction to materialize. The txid is + // identical in either form, so the recovery planner only requires the + // transaction itself. + Tx *wire.MsgTx +} + +// TXID returns the transaction hash for this recovery node. It guards +// against both a nil receiver and a nil Tx so the caller can safely propagate +// the error rather than panicking on a deferred dereference. +func (n *Node) TXID() (chainhash.Hash, error) { + if n == nil { + return chainhash.Hash{}, fmt.Errorf("node cannot be nil") + } + + if n.Tx == nil { + return chainhash.Hash{}, fmt.Errorf("node tx cannot be nil") + } + + return n.Tx.TxHash(), nil +} + +// Output returns the output at the requested index. +func (n *Node) Output(index uint32) (*wire.TxOut, error) { + if n == nil { + return nil, fmt.Errorf("node cannot be nil") + } + + if n.Tx == nil { + return nil, fmt.Errorf("node tx cannot be nil") + } + + if int(index) >= len(n.Tx.TxOut) { + return nil, fmt.Errorf("output index %d out of bounds", index) + } + + return n.Tx.TxOut[index], nil +} + +// AnchorOutputIndex returns the unique anchor output index, if present. +// Ark transactions typically carry a single ephemeral-anchor output +// (arkscript.AnchorPkScript) that downstream fee-bumping tools (CPFP, +// package relay) target. Having two would be a policy violation by the +// tx constructor; we surface it as an error so the planner sees the bug +// immediately rather than silently picking one. +func (n *Node) AnchorOutputIndex() (uint32, bool, error) { + if n == nil { + return 0, false, fmt.Errorf("node cannot be nil") + } + + if n.Tx == nil { + return 0, false, fmt.Errorf("node tx cannot be nil") + } + + found := false + var foundIndex uint32 + + for index, out := range n.Tx.TxOut { + if !bytes.Equal(out.PkScript, arkscript.AnchorPkScript) { + continue + } + + if found { + return 0, false, fmt.Errorf( + "multiple anchor outputs found", + ) + } + + found = true + foundIndex = uint32(index) + } + + return foundIndex, found, nil +} + +// AnchorOutpoint returns the anchor outpoint, if present. +func (n *Node) AnchorOutpoint() (wire.OutPoint, bool, error) { + txid, err := n.TXID() + if err != nil { + return wire.OutPoint{}, false, err + } + + index, ok, err := n.AnchorOutputIndex() + if err != nil { + return wire.OutPoint{}, false, err + } + + if !ok { + return wire.OutPoint{}, false, nil + } + + return wire.OutPoint{ + Hash: txid, + Index: index, + }, true, nil +} + +// MaxCSVDelay is the largest CSV delay we accept. BIP-68 caps height-mode +// sequence values at 16 bits (65535 blocks), so any larger value is either a +// misuse of the BIP-68-encoded form (bits 22+) or a tampered proof. We reject +// both to keep the CSV maturity math comfortably inside int32. +const MaxCSVDelay uint32 = 65535 + +// MaxProofNodes is the maximum number of recovery nodes we accept in a single +// proof. It is a defensive bound: real recovery proofs have well under a +// thousand nodes, but the Proof constructor walks the graph to verify +// reachability and a deeply adversarial graph could otherwise exhaust memory +// or (before the BFS rewrite) stack. +const MaxProofNodes = 100_000 + +// Proof is an immutable recovery graph for one target outpoint. All fields +// are computed by NewProof and never mutated afterwards; the Proof is safe +// to share across goroutines without synchronization. +// +// The redundancy between `parents`, `children`, `layers`, and `layerByTxid` +// is intentional: each serves a hot-path query in the planner (and saves us +// from having to traverse the DAG at plan time): +// +// - parents : answer "is this tx ready?" by checking the confirmation +// state of each parent. +// - children : answer "which nodes become ready after this one +// confirms?" (used in the topological sort, and potentially by +// downstream consumers that want to preempt broadcasts). +// - layers : answer "what should I try next?" — SnapshotAt walks layers +// in order so earlier nodes are never reported as blocked on later +// nodes. +// - layerByTxid: O(1) lookup of a node's layer index when the caller +// already holds a txid. +type Proof struct { + targetOutpoint wire.OutPoint + + // csvDelay is the post-confirmation timeout in raw blocks (NOT a + // BIP-68-encoded sequence value). Callers working with BIP-68 + // sequences (e.g. arkscript.CSV.Lock) must decode the block count + // before constructing a Proof. + csvDelay uint32 + + nodes map[chainhash.Hash]*Node + parents map[chainhash.Hash][]chainhash.Hash + children map[chainhash.Hash][]chainhash.Hash + layers [][]chainhash.Hash + layerByTxid map[chainhash.Hash]int +} + +// NewProof constructs and validates a recovery proof for one target +// outpoint. csvDelay must be a raw block count (not a BIP-68-encoded +// sequence) in the inclusive range [0, MaxCSVDelay]. +// +// Validation runs in five stages and short-circuits on the first failure: +// +// 1. Size & delay bounds — cheap guards against adversarial inputs before +// we allocate any per-node state. +// 2. Node indexing — build a txid→Node map, rejecting nil nodes and +// duplicate txids. A duplicate would make the rest of the graph +// ambiguous (two different bodies for the same hash). +// 3. Parent/child derivation — for each node, walk its TxIn list and +// connect only those inputs whose previous-outpoint txid is ALSO a +// node in this proof. External inputs (e.g. the user's funding utxo +// on a root tx) are intentionally ignored: the proof only tracks +// in-graph dependencies. Duplicate parent edges (two inputs from the +// same parent) are deduplicated because they represent the same +// scheduling dependency. +// 4. Reachability — every node must be an ancestor of the target. +// Nodes that are not reachable can never affect the target's +// spendability, and their presence almost always indicates a caller +// bug. We fail fast rather than silently ignore them. +// 5. Topological layering — a Kahn-style sort that both detects cycles +// and produces the deterministic layer index every downstream +// consumer uses for ordered broadcast. +// +// Determinism note: parent/child lists are sorted by raw txid byte order +// after construction so that two proofs built from the same node set in a +// different order produce byte-identical internal state. This matters for +// the TLV codec in proof_codec.go (its output hashes must be stable across +// invocations) and for deterministic test fixtures. +func NewProof(targetOutpoint wire.OutPoint, csvDelay uint32, + nodes ...*Node) (*Proof, error) { + + // Stage 1: cheap size / delay bounds. + if len(nodes) == 0 { + return nil, fmt.Errorf("at least one node is required") + } + + if len(nodes) > MaxProofNodes { + return nil, fmt.Errorf("proof exceeds max node count "+ + "(%d > %d)", len(nodes), MaxProofNodes) + } + + if csvDelay > MaxCSVDelay { + return nil, fmt.Errorf("csv delay %d exceeds max %d "+ + "(BIP-68 height-mode limit)", + csvDelay, MaxCSVDelay) + } + + // Stage 2: build the txid→Node index and reject nil / duplicates. + nodeMap := make(map[chainhash.Hash]*Node, len(nodes)) + for _, node := range nodes { + if node == nil { + return nil, fmt.Errorf("node cannot be nil") + } + + txid, err := node.TXID() + if err != nil { + return nil, err + } + + if _, exists := nodeMap[txid]; exists { + return nil, fmt.Errorf("duplicate node txid %s", txid) + } + + nodeMap[txid] = node + } + + // The target txid must exist in the proof; otherwise the graph cannot + // "terminate" at the target outpoint. The output index must also fit + // the target transaction's output count. + targetNode, ok := nodeMap[targetOutpoint.Hash] + if !ok { + return nil, fmt.Errorf("target txid %s not found in proof", + targetOutpoint.Hash) + } + + if int(targetOutpoint.Index) >= len(targetNode.Tx.TxOut) { + return nil, fmt.Errorf("target output index %d out of bounds", + targetOutpoint.Index) + } + + // Stage 3: derive in-graph parent/child adjacencies. + // + // Only transaction inputs whose previous-outpoint txid is ALSO a node + // in this proof become edges. External inputs (e.g. the user's + // funding utxo that seeds a root tx) are intentionally invisible + // here: the proof tracks scheduling dependencies, not the full input + // graph. + parents := make(map[chainhash.Hash][]chainhash.Hash, len(nodeMap)) + children := make(map[chainhash.Hash][]chainhash.Hash, len(nodeMap)) + + for txid, node := range nodeMap { + // A tx may legitimately spend the same parent at multiple + // outputs (e.g. to both an amount output and an anchor output + // sweep). Collapse those to a single edge — they express one + // scheduling dependency, not many. + seenParents := make(map[chainhash.Hash]struct{}) + + for _, txIn := range node.Tx.TxIn { + parentTxid := txIn.PreviousOutPoint.Hash + if _, exists := nodeMap[parentTxid]; !exists { + continue + } + + if _, seen := seenParents[parentTxid]; seen { + continue + } + + seenParents[parentTxid] = struct{}{} + parents[txid] = append(parents[txid], parentTxid) + children[parentTxid] = append( + children[parentTxid], txid, + ) + } + } + + // Sort edges deterministically so the Proof's internal state is a + // canonical function of its inputs regardless of Go map iteration + // order on the outer loop above. + for txid := range parents { + sortHashes(parents[txid]) + } + for txid := range children { + sortHashes(children[txid]) + } + + // Stage 4: reachability. Every node must be an ancestor of the + // target; otherwise it can't possibly affect the target's + // spendability and its presence is a caller bug. We do this in its + // own iterative BFS (see collectReachableAncestors) rather than + // folding it into the topological sort because (a) the failure + // message is more actionable — we tell the caller exactly which + // node is orphaned — and (b) the BFS is naturally stack-safe against + // adversarial depth. + reachable := collectReachableAncestors( + targetOutpoint.Hash, parents, + ) + + for txid := range nodeMap { + if reachable.Contains(txid) { + continue + } + + return nil, fmt.Errorf( + "node %s does not contribute to target %s", + txid, targetOutpoint, + ) + } + + // Stage 5: topological layering. This both detects cycles (any node + // that can't be processed leaves `processed < len(nodes)`, which + // buildLayers surfaces as an explicit "contains a cycle" error) and + // produces the layer index every downstream consumer uses to walk + // the graph in dependency order without recursion. + layers, layerByTxid, err := buildLayers(nodeMap, parents, children) + if err != nil { + return nil, err + } + + return &Proof{ + targetOutpoint: targetOutpoint, + csvDelay: csvDelay, + nodes: nodeMap, + parents: parents, + children: children, + layers: layers, + layerByTxid: layerByTxid, + }, nil +} + +// TargetOutpoint returns the outpoint this proof materializes. +func (p *Proof) TargetOutpoint() wire.OutPoint { + return p.targetOutpoint +} + +// CSVDelay returns the CSV delay that applies after the target confirms. +// The returned value is a raw block count (always in [0, MaxCSVDelay]) so +// callers can add it to a block height without further validation. +func (p *Proof) CSVDelay() uint32 { + return p.csvDelay +} + +// Node returns the recovery node for a txid, if present. +func (p *Proof) Node(txid chainhash.Hash) (*Node, bool) { + node, ok := p.nodes[txid] + return node, ok +} + +// TargetNode returns the node that creates the target outpoint. +func (p *Proof) TargetNode() (*Node, error) { + node, ok := p.Node(p.targetOutpoint.Hash) + if !ok { + return nil, fmt.Errorf("target node %s not found", + p.targetOutpoint.Hash) + } + + return node, nil +} + +// TargetOutput returns the txout referenced by the target outpoint. +func (p *Proof) TargetOutput() (*wire.TxOut, error) { + node, err := p.TargetNode() + if err != nil { + return nil, err + } + + return node.Output(p.targetOutpoint.Index) +} + +// ParentTxids returns the in-proof parent txids for the requested node. +// The returned slice is a defensive copy; the caller may freely mutate it +// without affecting the immutable Proof. +func (p *Proof) ParentTxids(txid chainhash.Hash) ([]chainhash.Hash, error) { + if _, ok := p.nodes[txid]; !ok { + return nil, fmt.Errorf("unknown txid %s", txid) + } + + return append([]chainhash.Hash(nil), p.parents[txid]...), nil +} + +// ChildTxids returns the in-proof child txids for the requested node. Like +// ParentTxids, the returned slice is a defensive copy. +func (p *Proof) ChildTxids(txid chainhash.Hash) ([]chainhash.Hash, error) { + if _, ok := p.nodes[txid]; !ok { + return nil, fmt.Errorf("unknown txid %s", txid) + } + + return append([]chainhash.Hash(nil), p.children[txid]...), nil +} + +// RootTxids returns the txids that have no in-proof parents. These are +// the first transactions the caller has to broadcast; every other node +// transitively depends on at least one of them. +func (p *Proof) RootTxids() []chainhash.Hash { + if len(p.layers) == 0 { + return nil + } + + return append([]chainhash.Hash(nil), p.layers[0]...) +} + +// Layer returns the topological layer index for the requested txid. Layer 0 +// is the set of roots; the target node's layer is always the maximum layer +// index. +func (p *Proof) Layer(txid chainhash.Hash) (int, error) { + layer, ok := p.layerByTxid[txid] + if !ok { + return 0, fmt.Errorf("unknown txid %s", txid) + } + + return layer, nil +} + +// Layers returns the full topological layering from roots to target. The +// result is a fresh two-level slice copy so consumers can freely mutate it. +// Consumers that only need to read should consider caching the result +// rather than calling Layers in a hot loop. +func (p *Proof) Layers() [][]chainhash.Hash { + result := make([][]chainhash.Hash, 0, len(p.layers)) + for _, layer := range p.layers { + result = append(result, + append([]chainhash.Hash(nil), layer...)) + } + + return result +} + +// buildLayers computes a deterministic topological layering for the proof. +// +// This is Kahn's algorithm, with two notable twists: +// +// 1. We emit "layers" (sets of nodes with no remaining unprocessed +// parents) rather than a single flat topological order. Grouping +// lets the planner reason about parallelism: every node in layer N +// can, in principle, be broadcast concurrently once layer N-1 is +// confirmed. The planner in `unrollplan` doesn't currently exploit +// this, but the shape is there for a future implementation. +// 2. The `ready` frontier is sorted (raw byte order) between rounds so +// the output is deterministic. Without the sort, Go's randomized +// map iteration would produce different `layers[i]` orderings on +// different runs, which would break the canonical TLV encoding. +// +// Cycle detection is a side-effect of the Kahn invariant: if the graph +// contains a cycle, no node in the cycle ever reaches in-degree zero, so +// the loop exits with `processed < len(nodes)`. We report that as an +// explicit "cycle" error rather than hiding it behind a generic "could +// not build layers" message so a debugger knows exactly what's wrong. +func buildLayers(nodes map[chainhash.Hash]*Node, + parents map[chainhash.Hash][]chainhash.Hash, + children map[chainhash.Hash][]chainhash.Hash) ([][]chainhash.Hash, + map[chainhash.Hash]int, error) { + + // Seed the in-degree table. Every node starts with an in-degree + // equal to its in-graph parent count (external inputs are excluded + // from `parents` by NewProof, so they don't inflate this). + indegree := make(map[chainhash.Hash]int, len(nodes)) + for txid := range nodes { + indegree[txid] = len(parents[txid]) + } + + // The initial ready frontier is every node with no parents — the + // roots of the DAG. + ready := make([]chainhash.Hash, 0) + for txid, count := range indegree { + if count == 0 { + ready = append(ready, txid) + } + } + sortHashes(ready) + + processed := 0 + layers := make([][]chainhash.Hash, 0) + layerByTxid := make(map[chainhash.Hash]int, len(nodes)) + + for len(ready) > 0 { + // Snapshot the current frontier as the next layer. We copy so + // the layers slice doesn't alias the `ready` backing array we + // reuse below. + current := append([]chainhash.Hash(nil), ready...) + ready = nil + + layerIndex := len(layers) + layers = append(layers, current) + + // Track which children become newly-ready so we don't add the + // same child twice if it has multiple parents in the current + // layer. + nextCounts := make(map[chainhash.Hash]struct{}) + for _, txid := range current { + processed++ + layerByTxid[txid] = layerIndex + + for _, child := range children[txid] { + indegree[child]-- + if indegree[child] == 0 { + nextCounts[child] = struct{}{} + } + } + } + + for txid := range nextCounts { + ready = append(ready, txid) + } + sortHashes(ready) + } + + // If we processed fewer nodes than exist, the remainder is a cycle + // (or strongly-connected component); there is no safe broadcast + // order for it, so we reject the proof. + if processed != len(nodes) { + return nil, nil, fmt.Errorf("recovery proof contains a cycle") + } + + return layers, layerByTxid, nil +} + +// sortHashes sorts hashes deterministically by raw byte order. We compare +// the 32-byte arrays directly to avoid the two-allocation-per-comparison cost +// of the bitcoin display (hex) form. +func sortHashes(hashes []chainhash.Hash) { + sort.Slice(hashes, func(i, j int) bool { + return bytes.Compare(hashes[i][:], hashes[j][:]) < 0 + }) +} + +// collectReachableAncestors returns the set of txids reachable from start by +// walking the parents map. We traverse iteratively with an explicit queue so a +// pathological proof (e.g. a 1M-deep linear chain) cannot blow the Go +// goroutine stack. The returned set always contains start itself. +func collectReachableAncestors(start chainhash.Hash, + parents map[chainhash.Hash][]chainhash.Hash) fn.Set[chainhash.Hash] { + + reachable := fn.NewSet[chainhash.Hash]() + queue := tree.NewQueue[chainhash.Hash]() + queue.Enqueue(start) + reachable.Add(start) + + for { + txid, ok := queue.Dequeue() + if !ok { + break + } + + for _, parent := range parents[txid] { + if reachable.Contains(parent) { + continue + } + + reachable.Add(parent) + queue.Enqueue(parent) + } + } + + return reachable +} diff --git a/lib/recovery/proof_accessors_test.go b/lib/recovery/proof_accessors_test.go new file mode 100644 index 000000000..3a1da4c56 --- /dev/null +++ b/lib/recovery/proof_accessors_test.go @@ -0,0 +1,163 @@ +package recovery + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/stretchr/testify/require" +) + +// TestNodeKindString checks the stable debug labels for NodeKind and also +// the unknown-kind path so future additions do not silently panic. +func TestNodeKindString(t *testing.T) { + require.Equal(t, "tree", NodeKindTree.String()) + require.Equal(t, "checkpoint", NodeKindCheckpoint.String()) + require.Equal(t, "ark", NodeKindArk.String()) + require.Contains(t, NodeKind(99).String(), "unknown") +} + +// TestTxStateString checks the debug labels for TxState. +func TestTxStateString(t *testing.T) { + require.Equal(t, "pending", TxStatePending.String()) + require.Equal(t, "broadcasted", TxStateBroadcasted.String()) + require.Equal(t, "confirmed", TxStateConfirmed.String()) + require.Contains(t, TxState(99).String(), "unknown") +} + +// TestSessionStatusString checks the debug labels for SessionStatus. +func TestSessionStatusString(t *testing.T) { + require.Equal(t, "materializing", SessionStatusMaterializing.String()) + require.Equal(t, "awaiting_csv", SessionStatusAwaitingCSV.String()) + require.Equal(t, "sweep_ready", SessionStatusSweepReady.String()) + require.Equal(t, "failed", SessionStatusFailed.String()) + require.Contains(t, SessionStatus(99).String(), "unknown") +} + +// TestNodeTXIDGuards checks both nil-receiver paths. +func TestNodeTXIDGuards(t *testing.T) { + var nilNode *Node + _, err := nilNode.TXID() + require.ErrorContains(t, err, "node cannot be nil") + + _, err = (&Node{}).TXID() + require.ErrorContains(t, err, "node tx cannot be nil") +} + +// TestNodeOutputGuards exercises nil, missing-tx, and out-of-bounds paths. +func TestNodeOutputGuards(t *testing.T) { + var nilNode *Node + _, err := nilNode.Output(0) + require.ErrorContains(t, err, "node cannot be nil") + + _, err = (&Node{}).Output(0) + require.ErrorContains(t, err, "node tx cannot be nil") + + tx := wire.NewMsgTx(1) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + _, err = (&Node{Tx: tx}).Output(5) + require.ErrorContains(t, err, "out of bounds") + + out, err := (&Node{Tx: tx}).Output(0) + require.NoError(t, err) + require.Equal(t, int64(1), out.Value) +} + +// TestNodeAnchorOutputIndex covers absent anchors, one anchor, and the +// duplicate-anchor error path. +func TestNodeAnchorOutputIndex(t *testing.T) { + var nilNode *Node + _, _, err := nilNode.AnchorOutputIndex() + require.ErrorContains(t, err, "node cannot be nil") + + _, _, err = (&Node{}).AnchorOutputIndex() + require.ErrorContains(t, err, "node tx cannot be nil") + + tx := wire.NewMsgTx(1) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + _, ok, err := (&Node{Tx: tx}).AnchorOutputIndex() + require.NoError(t, err) + require.False(t, ok) + + anchored := wire.NewMsgTx(1) + anchored.AddTxOut(&wire.TxOut{Value: 2, PkScript: []byte{0x51}}) + anchored.AddTxOut(arkscript.AnchorOutput()) + idx, ok, err := (&Node{Tx: anchored}).AnchorOutputIndex() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint32(1), idx) + + duplicate := wire.NewMsgTx(1) + duplicate.AddTxOut(arkscript.AnchorOutput()) + duplicate.AddTxOut(arkscript.AnchorOutput()) + _, _, err = (&Node{Tx: duplicate}).AnchorOutputIndex() + require.ErrorContains(t, err, "multiple anchor outputs") +} + +// TestNodeAnchorOutpoint exercises the composed AnchorOutpoint path. +func TestNodeAnchorOutpoint(t *testing.T) { + tx := wire.NewMsgTx(1) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + tx.AddTxOut(arkscript.AnchorOutput()) + + node := &Node{Tx: tx} + op, ok, err := node.AnchorOutpoint() + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, uint32(1), op.Index) + require.Equal(t, tx.TxHash(), op.Hash) + + var nilNode *Node + _, _, err = nilNode.AnchorOutpoint() + require.Error(t, err) + + noAnchor := wire.NewMsgTx(1) + noAnchor.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + _, ok, err = (&Node{Tx: noAnchor}).AnchorOutpoint() + require.NoError(t, err) + require.False(t, ok) +} + +// TestProofAccessors exercises the public read-only accessors to lock in +// their error paths. Keep the proof intentionally small so the set-up cost is +// low and the test stays focused on accessor behavior. +func TestProofAccessors(t *testing.T) { + root := makeProofTx('r', nil) + target := makeProofTx('t', []wire.OutPoint{ + {Hash: root.TxHash(), Index: 0}, + }) + + proof, err := NewProof( + wire.OutPoint{Hash: target.TxHash()}, + 5, + &Node{Kind: NodeKindTree, Tx: root}, + &Node{Kind: NodeKindArk, Tx: target}, + ) + require.NoError(t, err) + + targetNode, err := proof.TargetNode() + require.NoError(t, err) + require.Equal(t, target.TxHash(), targetNode.Tx.TxHash()) + + targetOut, err := proof.TargetOutput() + require.NoError(t, err) + require.Equal(t, int64('t')+1, targetOut.Value) + + roots := proof.RootTxids() + require.Equal(t, []chainhash.Hash{root.TxHash()}, roots) + + layer, err := proof.Layer(root.TxHash()) + require.NoError(t, err) + require.Equal(t, 0, layer) + + _, err = proof.Layer(chainhash.Hash{0xff}) + require.ErrorContains(t, err, "unknown txid") + + children, err := proof.ChildTxids(root.TxHash()) + require.NoError(t, err) + require.Equal(t, []chainhash.Hash{target.TxHash()}, children) + + _, err = proof.ChildTxids(chainhash.Hash{0xff}) + require.ErrorContains(t, err, "unknown txid") +} diff --git a/lib/recovery/proof_codec.go b/lib/recovery/proof_codec.go new file mode 100644 index 000000000..9ac23150a --- /dev/null +++ b/lib/recovery/proof_codec.go @@ -0,0 +1,396 @@ +package recovery + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/tlv" +) + +// ProofCodecVersion is the on-disk version byte for the Proof codec. Bumping +// this value lets us migrate the serialized form without silently +// re-interpreting older blobs. +const ProofCodecVersion uint8 = 1 + +const ( + // proofVersionRecordType carries the codec version byte. + proofVersionRecordType tlv.Type = 1 + + // proofTargetOutpointRecordType carries the 36-byte target outpoint + // (32-byte txid little-endian || 4-byte index big-endian). + proofTargetOutpointRecordType tlv.Type = 3 + + // proofCSVDelayRecordType carries the csv delay in raw blocks. + proofCSVDelayRecordType tlv.Type = 5 + + // proofNodesRecordType carries the length-prefixed list of nested + // per-Node TLV sub-streams. + proofNodesRecordType tlv.Type = 7 +) + +const ( + // nodeKindRecordType carries the 1-byte NodeKind. + nodeKindRecordType tlv.Type = 1 + + // nodeTxRecordType carries the serialized wire.MsgTx bytes. The + // serialization length is variable, so the record is dynamic. + nodeTxRecordType tlv.Type = 3 +) + +// Record returns a TLV record that encodes this Node as a nested sub-stream +// containing a NodeKind record and a wire.MsgTx record. Putting each Node in +// its own TLV stream (rather than a hand-packed binary frame) means we can +// add new per-Node fields later (signatures, metadata, version tags) by +// appending new odd-typed TLV records; older decoders will skip unknown +// records per the TLV spec's odd-is-optional rule. +func (n *Node) Record() tlv.Record { + sizeFn := func() uint64 { + // The record is dynamic because the inner MsgTx varies in + // length. We precompute the exact nested-stream size so the + // outer TLV emits the correct length prefix. + raw, err := encodeNodeStream(n) + if err != nil { + return 0 + } + + return uint64(len(raw)) + } + + return tlv.MakeDynamicRecord( + 0, n, sizeFn, nodeEncoder, nodeDecoder, + ) +} + +// nodeEncoder writes a Node as a nested TLV sub-stream. +func nodeEncoder(w io.Writer, val interface{}, _ *[8]byte) error { + node, ok := val.(*Node) + if !ok { + return tlv.NewTypeForEncodingErr(val, "*recovery.Node") + } + + raw, err := encodeNodeStream(node) + if err != nil { + return err + } + + _, err = w.Write(raw) + + return err +} + +// nodeDecoder reads a Node from a nested TLV sub-stream. +func nodeDecoder(r io.Reader, val interface{}, _ *[8]byte, l uint64) error { + node, ok := val.(*Node) + if !ok { + return tlv.NewTypeForDecodingErr( + val, "*recovery.Node", l, l, + ) + } + + buf := make([]byte, l) + if _, err := io.ReadFull(r, buf); err != nil { + return err + } + + decoded, err := decodeNodeStream(buf) + if err != nil { + return err + } + + *node = *decoded + + return nil +} + +// encodeNodeStream emits a Node as a standalone TLV stream: NodeKind followed +// by the serialized MsgTx. The caller is responsible for placing the +// resulting bytes inside an outer record (either the outer proof stream via +// nodeEncoder, or a length-prefixed list for the proof nodes record). +func encodeNodeStream(n *Node) ([]byte, error) { + if n == nil || n.Tx == nil { + return nil, fmt.Errorf("node missing tx") + } + + kind := uint8(n.Kind) + + // The MsgTx serializer writes directly to an io.Writer. We capture + // the bytes here so we can pass them as a primitive TLV payload + // instead of wrapping the serializer in a dynamic Record. + var txBuf bytes.Buffer + if err := n.Tx.Serialize(&txBuf); err != nil { + return nil, fmt.Errorf("serialize tx: %w", err) + } + txBytes := txBuf.Bytes() + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(nodeKindRecordType, &kind), + tlv.MakePrimitiveRecord(nodeTxRecordType, &txBytes), + ) + if err != nil { + return nil, err + } + + var out bytes.Buffer + if err := stream.Encode(&out); err != nil { + return nil, err + } + + return out.Bytes(), nil +} + +// decodeNodeStream reverses encodeNodeStream. It re-validates NodeKind to +// reject unknown kinds, preserving the invariant that a decoded Node is +// always a well-formed value. +func decodeNodeStream(raw []byte) (*Node, error) { + var ( + kind uint8 + txBytes []byte + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(nodeKindRecordType, &kind), + tlv.MakePrimitiveRecord(nodeTxRecordType, &txBytes), + ) + if err != nil { + return nil, err + } + + parsed, err := stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode node: %w", err) + } + + if _, ok := parsed[nodeKindRecordType]; !ok { + return nil, fmt.Errorf("node missing kind record") + } + if _, ok := parsed[nodeTxRecordType]; !ok { + return nil, fmt.Errorf("node missing tx record") + } + + nodeKind := NodeKind(kind) + if nodeKind < NodeKindTree || nodeKind > NodeKindArk { + return nil, fmt.Errorf("invalid node kind %d", kind) + } + + tx := &wire.MsgTx{} + if err := tx.Deserialize(bytes.NewReader(txBytes)); err != nil { + return nil, fmt.Errorf("deserialize tx: %w", err) + } + + return &Node{Kind: nodeKind, Tx: tx}, nil +} + +// EncodeProof serializes a Proof into a deterministic TLV byte slice. The +// node list is emitted in ascending txid byte order to make the encoding +// reproducible and easy to diff across runs. +func EncodeProof(proof *Proof) ([]byte, error) { + if proof == nil { + return nil, fmt.Errorf("proof cannot be nil") + } + + version := ProofCodecVersion + outpoint := encodeOutpoint(proof.targetOutpoint) + csvDelay := proof.csvDelay + nodesRaw, err := encodeProofNodes(proof.nodes) + if err != nil { + return nil, fmt.Errorf("encode proof nodes: %w", err) + } + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(proofVersionRecordType, &version), + tlv.MakePrimitiveRecord( + proofTargetOutpointRecordType, &outpoint, + ), + tlv.MakePrimitiveRecord(proofCSVDelayRecordType, &csvDelay), + tlv.MakePrimitiveRecord(proofNodesRecordType, &nodesRaw), + ) + if err != nil { + return nil, fmt.Errorf("create proof stream: %w", err) + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, fmt.Errorf("encode proof: %w", err) + } + + return buf.Bytes(), nil +} + +// DecodeProof reverses EncodeProof and runs the bytes back through NewProof +// so the validation invariants (cycle check, reachability, MaxCSVDelay, +// MaxProofNodes) all hold on the decoded result. +func DecodeProof(raw []byte) (*Proof, error) { + var ( + version uint8 + outpointRaw []byte + csvDelay uint32 + nodesRaw []byte + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(proofVersionRecordType, &version), + tlv.MakePrimitiveRecord( + proofTargetOutpointRecordType, &outpointRaw, + ), + tlv.MakePrimitiveRecord(proofCSVDelayRecordType, &csvDelay), + tlv.MakePrimitiveRecord(proofNodesRecordType, &nodesRaw), + ) + if err != nil { + return nil, fmt.Errorf("create proof stream: %w", err) + } + + _, err = stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode proof: %w", err) + } + + if version != ProofCodecVersion { + return nil, fmt.Errorf("unsupported proof codec version %d "+ + "(expected %d)", version, ProofCodecVersion) + } + + outpoint, err := decodeOutpoint(outpointRaw) + if err != nil { + return nil, fmt.Errorf("decode target outpoint: %w", err) + } + + nodes, err := decodeProofNodes(nodesRaw) + if err != nil { + return nil, fmt.Errorf("decode proof nodes: %w", err) + } + + // Rebuild through NewProof so every structural invariant (cycle- + // freedom, reachability, caps) is re-enforced on the decoded bytes. + return NewProof(outpoint, csvDelay, nodes...) +} + +// encodeOutpoint writes a 36-byte outpoint: 32-byte hash followed by a 4-byte +// big-endian index. +func encodeOutpoint(op wire.OutPoint) []byte { + out := make([]byte, chainhash.HashSize+4) + copy(out, op.Hash[:]) + binary.BigEndian.PutUint32(out[chainhash.HashSize:], op.Index) + + return out +} + +// decodeOutpoint reverses encodeOutpoint. +func decodeOutpoint(raw []byte) (wire.OutPoint, error) { + if len(raw) != chainhash.HashSize+4 { + return wire.OutPoint{}, fmt.Errorf( + "outpoint length %d invalid", len(raw), + ) + } + + var op wire.OutPoint + copy(op.Hash[:], raw[:chainhash.HashSize]) + op.Index = binary.BigEndian.Uint32(raw[chainhash.HashSize:]) + + return op, nil +} + +// encodeProofNodes emits each Node as a length-prefixed nested TLV +// sub-stream. Nodes are emitted in ascending txid byte order to make the +// encoding deterministic. The wrapper format is: +// +// 4-byte big-endian count +// for each node: +// 4-byte big-endian sub-stream length +// sub-stream bytes (encodeNodeStream output) +// +// We use a length-prefix rather than relying on TLV concatenation because +// the outer proof record expects a single opaque byte payload; nesting a +// TLV stream inside it means decoders can evolve the per-Node layout +// independently of the outer layout. +func encodeProofNodes(nodes map[chainhash.Hash]*Node) ([]byte, error) { + keys := sortedHashKeys(nodes) + + var buf bytes.Buffer + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(keys))) + if _, err := buf.Write(lenBuf[:]); err != nil { + return nil, err + } + + for _, key := range keys { + node := nodes[key] + raw, err := encodeNodeStream(node) + if err != nil { + return nil, fmt.Errorf("encode node %s: %w", key, err) + } + + var nodeLen [4]byte + binary.BigEndian.PutUint32(nodeLen[:], uint32(len(raw))) + if _, err := buf.Write(nodeLen[:]); err != nil { + return nil, err + } + if _, err := buf.Write(raw); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +// decodeProofNodes reverses encodeProofNodes, delegating each node's field +// parsing to decodeNodeStream so any future per-Node fields only need to be +// added in one place. +func decodeProofNodes(raw []byte) ([]*Node, error) { + if len(raw) < 4 { + return nil, fmt.Errorf("truncated proof node list") + } + + count := binary.BigEndian.Uint32(raw[:4]) + raw = raw[4:] + + if count > MaxProofNodes { + return nil, fmt.Errorf("proof node count %d exceeds max %d", + count, MaxProofNodes) + } + + nodes := make([]*Node, 0, count) + seen := make(map[chainhash.Hash]struct{}, count) + + for i := uint32(0); i < count; i++ { + if len(raw) < 4 { + return nil, fmt.Errorf( + "truncated proof node #%d header", i, + ) + } + + nodeLen := binary.BigEndian.Uint32(raw[:4]) + raw = raw[4:] + + if uint32(len(raw)) < nodeLen { + return nil, fmt.Errorf( + "truncated proof node #%d body", i, + ) + } + + node, err := decodeNodeStream(raw[:nodeLen]) + if err != nil { + return nil, fmt.Errorf("node #%d: %w", i, err) + } + raw = raw[nodeLen:] + + txid := node.Tx.TxHash() + if _, exists := seen[txid]; exists { + return nil, fmt.Errorf("duplicate proof node "+ + "txid %s", txid) + } + seen[txid] = struct{}{} + + nodes = append(nodes, node) + } + + if len(raw) != 0 { + return nil, fmt.Errorf("trailing %d bytes after proof "+ + "nodes", len(raw)) + } + + return nodes, nil +} diff --git a/lib/recovery/proof_codec_test.go b/lib/recovery/proof_codec_test.go new file mode 100644 index 000000000..dcfbee457 --- /dev/null +++ b/lib/recovery/proof_codec_test.go @@ -0,0 +1,306 @@ +package recovery + +import ( + "bytes" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestEncodeProofNilRejected verifies the top-level guard. +func TestEncodeProofNilRejected(t *testing.T) { + _, err := EncodeProof(nil) + require.ErrorContains(t, err, "proof cannot be nil") +} + +// TestProofCodecRoundTrip exercises a few hand-built proofs to catch +// regressions on the fixture shapes we care about in practice. +func TestProofCodecRoundTrip(t *testing.T) { + cases := []struct { + name string + build func(*testing.T) *Proof + }{ + { + name: "single_node", + build: func(t *testing.T) *Proof { + tx := makeProofTx('a', nil) + p, err := NewProof( + wire.OutPoint{Hash: tx.TxHash()}, + 10, + &Node{Kind: NodeKindArk, Tx: tx}, + ) + require.NoError(t, err) + + return p + }, + }, + { + name: "linear_chain", + build: func(t *testing.T) *Proof { + root := makeProofTx('r', nil) + mid := makeProofTx('m', []wire.OutPoint{ + {Hash: root.TxHash(), Index: 0}, + }) + target := makeProofTx('t', []wire.OutPoint{ + {Hash: mid.TxHash(), Index: 0}, + }) + + p, err := NewProof( + wire.OutPoint{ + Hash: target.TxHash(), + }, + 5, + &Node{ + Kind: NodeKindCheckpoint, + Tx: root, + }, + &Node{ + Kind: NodeKindCheckpoint, + Tx: mid, + }, + &Node{ + Kind: NodeKindArk, Tx: target, + }, + ) + require.NoError(t, err) + + return p + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + proof := tc.build(t) + + raw, err := EncodeProof(proof) + require.NoError(t, err) + + decoded, err := DecodeProof(raw) + require.NoError(t, err) + + require.Equal(t, + proof.TargetOutpoint(), + decoded.TargetOutpoint()) + require.Equal(t, + proof.CSVDelay(), + decoded.CSVDelay()) + + // Structural equality: same layer count and same + // txid set per layer. + orig := proof.Layers() + back := decoded.Layers() + require.Equal(t, len(orig), len(back)) + for i := range orig { + require.ElementsMatch(t, orig[i], back[i]) + } + + // Re-encoding must yield identical bytes. + raw2, err := EncodeProof(decoded) + require.NoError(t, err) + require.True(t, bytes.Equal(raw, raw2), + "proof encoding must be canonical") + }) + } +} + +// TestProofCodecVersionMismatch verifies unknown versions are rejected. +func TestProofCodecVersionMismatch(t *testing.T) { + tx := makeProofTx('x', nil) + proof, err := NewProof( + wire.OutPoint{Hash: tx.TxHash()}, + 3, + &Node{Kind: NodeKindTree, Tx: tx}, + ) + require.NoError(t, err) + + raw, err := EncodeProof(proof) + require.NoError(t, err) + + // The first TLV is the version record; its payload byte lives at + // offset 2 (type byte + length byte + value). + require.GreaterOrEqual(t, len(raw), 3) + raw[2] = 42 + + _, err = DecodeProof(raw) + require.ErrorContains(t, err, "unsupported proof codec") +} + +// TestDecodeProofRejectsInvalidKind verifies a tampered NodeKind byte fails +// loudly rather than silently mapping to an unknown kind. We build a +// well-formed per-Node nested TLV stream carrying an out-of-range kind byte +// and feed it through the outer list framing. +func TestDecodeProofRejectsInvalidKind(t *testing.T) { + tx := makeProofTx('a', nil) + badNode := encodeNodeFrame(t, NodeKindArk+50, tx) + + buf := bytes.Buffer{} + buf.Write([]byte{0, 0, 0, 1}) // count=1 + writeLen(&buf, len(badNode)) + buf.Write(badNode) + + _, err := decodeProofNodes(buf.Bytes()) + require.ErrorContains(t, err, "invalid node kind") +} + +// TestDecodeProofRejectsDuplicateTxid verifies a blob that encodes the same +// transaction twice is rejected by the decoder even though the encoder would +// never emit such a blob. +func TestDecodeProofRejectsDuplicateTxid(t *testing.T) { + tx := makeProofTx('a', nil) + nodeBytes := encodeNodeFrame(t, NodeKindArk, tx) + + buf := bytes.Buffer{} + buf.Write([]byte{0, 0, 0, 2}) // count=2 + + for i := 0; i < 2; i++ { + writeLen(&buf, len(nodeBytes)) + buf.Write(nodeBytes) + } + + _, err := decodeProofNodes(buf.Bytes()) + require.ErrorContains(t, err, "duplicate proof node") +} + +// encodeNodeFrame is a test helper that produces the same nested TLV +// sub-stream a Node would serialize to, but lets us inject arbitrary kind +// values for adversarial-input tests. +func encodeNodeFrame(t *testing.T, kind NodeKind, tx *wire.MsgTx) []byte { + t.Helper() + + raw, err := encodeNodeStream(&Node{Kind: kind, Tx: tx}) + require.NoError(t, err) + + return raw +} + +// writeLen appends a 4-byte big-endian length prefix to buf. Extracted so +// the test fixture builders stay readable. +func writeLen(buf *bytes.Buffer, n int) { + buf.Write([]byte{ + byte(n >> 24), byte(n >> 16), + byte(n >> 8), byte(n), + }) +} + +// TestProofCodecRapidRoundTrip generates random proofs (linear chains of +// varying depth) and asserts round-trip equivalence for the structural +// invariants callers rely on. Every generated proof is guaranteed to +// round-trip exactly; a failure shrinks to the minimum-size counterexample. +func TestProofCodecRapidRoundTrip(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + depth := rapid.IntRange(1, 6).Draw(t, "depth") + csvDelay := rapid.Uint32Range( + 0, MaxCSVDelay, + ).Draw(t, "csvDelay") + + var nodes []*Node + var prevTxid chainhash.Hash + for i := 0; i < depth; i++ { + var prevOuts []wire.OutPoint + if i > 0 { + prevOuts = []wire.OutPoint{ + {Hash: prevTxid, Index: 0}, + } + } + tag := byte(i + 1) + tx := makeProofTx(tag, prevOuts) + prevTxid = tx.TxHash() + + kind := NodeKind(rapid.IntRange( + int(NodeKindTree), int(NodeKindArk), + ).Draw(t, fmt.Sprintf("kind-%d", i))) + + nodes = append(nodes, &Node{Kind: kind, Tx: tx}) + } + + proof, err := NewProof( + wire.OutPoint{Hash: prevTxid, Index: 0}, + csvDelay, nodes..., + ) + if err != nil { + t.Fatalf("NewProof failed: %v", err) + } + + raw, err := EncodeProof(proof) + if err != nil { + t.Fatalf("EncodeProof failed: %v", err) + } + + decoded, err := DecodeProof(raw) + if err != nil { + t.Fatalf("DecodeProof failed: %v", err) + } + + if proof.TargetOutpoint() != decoded.TargetOutpoint() { + t.Fatal("target outpoint mismatch") + } + if proof.CSVDelay() != decoded.CSVDelay() { + t.Fatal("csv delay mismatch") + } + + orig := proof.Layers() + back := decoded.Layers() + if len(orig) != len(back) { + t.Fatal("layer count mismatch") + } + for i := range orig { + if len(orig[i]) != len(back[i]) { + t.Fatal("layer size mismatch") + } + for _, txid := range orig[i] { + found := false + for _, b := range back[i] { + if txid == b { + found = true + break + } + } + if !found { + t.Fatalf("missing txid %s", txid) + } + } + } + + raw2, err := EncodeProof(decoded) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + if !bytes.Equal(raw, raw2) { + t.Fatalf("encoding is not canonical") + } + }) +} + +// makeProofTx constructs a deterministic MsgTx for codec tests. Mirrors +// makeRecoveryTx in recovery_test.go but lives here so the codec tests are +// self-contained. +func makeProofTx(tag byte, prevOuts []wire.OutPoint) *wire.MsgTx { + tx := wire.NewMsgTx(2) + if len(prevOuts) == 0 { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{tag, 0xff}, + Index: uint32(tag), + }, + Sequence: wire.MaxTxInSequenceNum, + }) + } + for _, op := range prevOuts { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: op, + Sequence: wire.MaxTxInSequenceNum, + }) + } + tx.AddTxOut(&wire.TxOut{ + Value: int64(tag) + 1, + PkScript: []byte{0x51, tag}, + }) + + return tx +} diff --git a/lib/recovery/recovery_test.go b/lib/recovery/recovery_test.go new file mode 100644 index 000000000..9893cfe95 --- /dev/null +++ b/lib/recovery/recovery_test.go @@ -0,0 +1,491 @@ +package recovery + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/stretchr/testify/require" +) + +// TestProofLayersMergeParents verifies that a proof can represent a +// multi-input merge and exposes deterministic layers. +func TestProofLayersMergeParents(t *testing.T) { + rootATx := makeRecoveryTx('a', []wire.OutPoint{ + makeExternalOutpoint('x', 0), + }, true) + rootBTx := makeRecoveryTx('b', []wire.OutPoint{ + makeExternalOutpoint('y', 0), + }, true) + + rootATxid := rootATx.TxHash() + rootBTxid := rootBTx.TxHash() + + mergeTx := makeRecoveryTx('m', []wire.OutPoint{ + {Hash: rootATxid, Index: 0}, + {Hash: rootBTxid, Index: 0}, + }, true) + + proof, err := NewProof( + wire.OutPoint{Hash: mergeTx.TxHash(), Index: 0}, + 5, + &Node{Kind: NodeKindCheckpoint, Tx: rootATx}, + &Node{Kind: NodeKindCheckpoint, Tx: rootBTx}, + &Node{Kind: NodeKindArk, Tx: mergeTx}, + ) + require.NoError(t, err) + + layers := proof.Layers() + require.Len(t, layers, 2) + require.ElementsMatch(t, []chainhash.Hash{ + rootATxid, rootBTxid, + }, layers[0]) + require.Equal(t, []chainhash.Hash{mergeTx.TxHash()}, layers[1]) + + parentTxids, err := proof.ParentTxids(mergeTx.TxHash()) + require.NoError(t, err) + require.ElementsMatch(t, []chainhash.Hash{ + rootATxid, rootBTxid, + }, parentTxids) +} + +// TestProofLayersNestedMergeParents verifies that a proof can represent a +// nested ancestry graph where one target parent is itself a two-parent merge. +func TestProofLayersNestedMergeParents(t *testing.T) { + session := newNestedMergeSession(t) + proof := session.Proof() + + layers := proof.Layers() + require.Len(t, layers, 3) + require.Len(t, layers[0], 3) + require.Len(t, layers[1], 1) + require.Len(t, layers[2], 1) + + mergeBCTxid := layers[1][0] + targetTxid := layers[2][0] + + targetParents, err := proof.ParentTxids(targetTxid) + require.NoError(t, err) + require.Len(t, targetParents, 2) + require.Contains(t, targetParents, mergeBCTxid) + + var rootATxid chainhash.Hash + for _, parent := range targetParents { + if parent == mergeBCTxid { + continue + } + + rootATxid = parent + } + require.Contains(t, layers[0], rootATxid) + + mergeParents, err := proof.ParentTxids(mergeBCTxid) + require.NoError(t, err) + require.Len(t, mergeParents, 2) + require.NotContains(t, mergeParents, rootATxid) + require.Subset(t, layers[0], mergeParents) +} + +// TestSessionTracksMultiParentReadiness verifies that the session only +// releases a merge transaction once all in-proof parents confirm. +func TestSessionTracksMultiParentReadiness(t *testing.T) { + session := newMergeSession(t) + + snapshot, err := session.SnapshotAt(100) + require.NoError(t, err) + require.Equal(t, SessionStatusMaterializing, snapshot.Status) + require.Len(t, snapshot.ReadyToBroadcast, 2) + require.Empty(t, snapshot.AwaitingConfirmation) + require.Len(t, snapshot.Blocked, 1) + require.Len(t, snapshot.Blocked[0].MissingParents, 2) + + rootATxid := snapshot.ReadyToBroadcast[0].Txid + rootBTxid := snapshot.ReadyToBroadcast[1].Txid + if rootATxid.String() > rootBTxid.String() { + rootATxid, rootBTxid = rootBTxid, rootATxid + } + + require.NoError(t, session.MarkBroadcasted(rootATxid)) + snapshot, err = session.SnapshotAt(100) + require.NoError(t, err) + require.Equal(t, []chainhash.Hash{rootATxid}, + snapshot.AwaitingConfirmation) + require.Len(t, snapshot.ReadyToBroadcast, 1) + + require.NoError(t, session.MarkConfirmed(rootATxid, 101)) + snapshot, err = session.SnapshotAt(101) + require.NoError(t, err) + require.Len(t, snapshot.ReadyToBroadcast, 1) + require.Equal(t, rootBTxid, snapshot.ReadyToBroadcast[0].Txid) + require.Len(t, snapshot.Blocked, 1) + require.Equal(t, []chainhash.Hash{rootBTxid}, + snapshot.Blocked[0].MissingParents) + + require.NoError(t, session.MarkBroadcasted(rootBTxid)) + require.NoError(t, session.MarkConfirmed(rootBTxid, 102)) + + snapshot, err = session.SnapshotAt(102) + require.NoError(t, err) + require.Len(t, snapshot.ReadyToBroadcast, 1) + mergeTxid := snapshot.ReadyToBroadcast[0].Txid + + require.NoError(t, session.MarkBroadcasted(mergeTxid)) + require.NoError(t, session.MarkConfirmed(mergeTxid, 103)) + + snapshot, err = session.SnapshotAt(107) + require.NoError(t, err) + require.Equal(t, SessionStatusAwaitingCSV, snapshot.Status) + csv := snapshot.CSV.UnwrapOrFail(t) + require.Equal(t, int32(108), csv.MaturityHeight) + require.Equal(t, int32(1), csv.BlocksRemaining) + + snapshot, err = session.SnapshotAt(108) + require.NoError(t, err) + require.Equal(t, SessionStatusSweepReady, snapshot.Status) + require.True(t, snapshot.CSV.UnwrapOrFail(t).Ready) + require.Empty(t, snapshot.ReadyToBroadcast) + require.Empty(t, snapshot.AwaitingConfirmation) +} + +// TestSessionTracksNestedParentReadiness verifies that the session handles +// a target whose parents come from different origins and nested merges. +func TestSessionTracksNestedParentReadiness(t *testing.T) { + session := newNestedMergeSession(t) + proof := session.Proof() + layers := proof.Layers() + + require.Len(t, layers, 3) + require.Len(t, layers[0], 3) + + mergeBCTxid := layers[1][0] + targetTxid := layers[2][0] + + targetParents, err := proof.ParentTxids(targetTxid) + require.NoError(t, err) + + var rootATxid chainhash.Hash + for _, parent := range targetParents { + if parent == mergeBCTxid { + continue + } + + rootATxid = parent + } + + mergeBCParents, err := proof.ParentTxids(mergeBCTxid) + require.NoError(t, err) + require.Len(t, mergeBCParents, 2) + + snapshot, err := session.SnapshotAt(200) + require.NoError(t, err) + require.Equal(t, SessionStatusMaterializing, snapshot.Status) + require.ElementsMatch(t, layers[0], + readyActionTxids(snapshot.ReadyToBroadcast)) + + targetBlocked := blockedActionForTxid(t, snapshot.Blocked, targetTxid) + require.ElementsMatch(t, targetParents, + targetBlocked.MissingParents) + + mergeBlocked := blockedActionForTxid(t, snapshot.Blocked, mergeBCTxid) + require.ElementsMatch(t, mergeBCParents, mergeBlocked.MissingParents) + + require.NoError(t, session.MarkBroadcasted(rootATxid)) + require.NoError(t, session.MarkConfirmed(rootATxid, 201)) + + snapshot, err = session.SnapshotAt(201) + require.NoError(t, err) + require.ElementsMatch(t, mergeBCParents, + readyActionTxids(snapshot.ReadyToBroadcast)) + + targetBlocked = blockedActionForTxid(t, snapshot.Blocked, targetTxid) + require.Equal(t, []chainhash.Hash{mergeBCTxid}, + targetBlocked.MissingParents) + + mergeBlocked = blockedActionForTxid(t, snapshot.Blocked, mergeBCTxid) + require.ElementsMatch(t, mergeBCParents, mergeBlocked.MissingParents) + + for _, parentTxid := range mergeBCParents { + require.NoError(t, session.MarkBroadcasted(parentTxid)) + require.NoError(t, session.MarkConfirmed(parentTxid, 202)) + } + + snapshot, err = session.SnapshotAt(202) + require.NoError(t, err) + require.Equal(t, []chainhash.Hash{mergeBCTxid}, + readyActionTxids(snapshot.ReadyToBroadcast)) + + targetBlocked = blockedActionForTxid(t, snapshot.Blocked, targetTxid) + require.Equal(t, []chainhash.Hash{mergeBCTxid}, + targetBlocked.MissingParents) + + require.NoError(t, session.MarkBroadcasted(mergeBCTxid)) + require.NoError(t, session.MarkConfirmed(mergeBCTxid, 203)) + + snapshot, err = session.SnapshotAt(203) + require.NoError(t, err) + require.Equal(t, []chainhash.Hash{targetTxid}, + readyActionTxids(snapshot.ReadyToBroadcast)) + require.Empty(t, snapshot.Blocked) + + require.NoError(t, session.MarkBroadcasted(targetTxid)) + require.NoError(t, session.MarkConfirmed(targetTxid, 204)) + + snapshot, err = session.SnapshotAt(208) + require.NoError(t, err) + require.Equal(t, SessionStatusAwaitingCSV, snapshot.Status) + csv := snapshot.CSV.UnwrapOrFail(t) + require.Equal(t, int32(209), csv.MaturityHeight) + require.Equal(t, int32(1), csv.BlocksRemaining) + + snapshot, err = session.SnapshotAt(209) + require.NoError(t, err) + require.Equal(t, SessionStatusSweepReady, snapshot.Status) + require.True(t, snapshot.CSV.UnwrapOrFail(t).Ready) +} + +// TestSessionRejectsBroadcastBeforeParentsConfirmed verifies that the +// session enforces dependency order for multi-input nodes. +func TestSessionRejectsBroadcastBeforeParentsConfirmed(t *testing.T) { + session := newMergeSession(t) + + proof := session.Proof() + layers := proof.Layers() + require.Len(t, layers, 2) + mergeTxid := layers[1][0] + + err := session.MarkBroadcasted(mergeTxid) + require.Error(t, err) + require.Contains(t, err.Error(), "not ready") +} + +// TestProofRejectsUnrelatedNode verifies that a proof cannot contain nodes +// that do not contribute to the target. +func TestProofRejectsUnrelatedNode(t *testing.T) { + rootTx := makeRecoveryTx('r', []wire.OutPoint{ + makeExternalOutpoint('u', 0), + }, true) + targetTx := makeRecoveryTx('t', []wire.OutPoint{ + {Hash: rootTx.TxHash(), Index: 0}, + }, true) + unrelatedTx := makeRecoveryTx('z', []wire.OutPoint{ + makeExternalOutpoint('v', 0), + }, true) + + _, err := NewProof( + wire.OutPoint{Hash: targetTx.TxHash(), Index: 0}, + 1, + &Node{Kind: NodeKindTree, Tx: rootTx}, + &Node{Kind: NodeKindArk, Tx: targetTx}, + &Node{Kind: NodeKindCheckpoint, Tx: unrelatedTx}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "does not contribute") +} + +// TestSessionExportStateRestoresProgress verifies that callers can persist and +// restore pure recovery progress without a hydration-oriented manager object. +func TestSessionExportStateRestoresProgress(t *testing.T) { + session := newNestedMergeSession(t) + proof := session.Proof() + + initial, err := session.SnapshotAt(100) + require.NoError(t, err) + require.Len(t, initial.ReadyToBroadcast, 3) + + rootATxid := initial.ReadyToBroadcast[0].Txid + require.NoError(t, session.MarkBroadcasted(rootATxid)) + require.NoError(t, session.MarkConfirmed(rootATxid, 101)) + + exported := session.ExportState() + restored, err := NewSessionFromState(proof, exported) + require.NoError(t, err) + + restoredSnapshot, err := restored.SnapshotAt(101) + require.NoError(t, err) + require.Equal(t, SessionStatusMaterializing, + restoredSnapshot.Status) + + targetTxid := proof.TargetOutpoint().Hash + targetBlocked := blockedActionForTxid(t, restoredSnapshot.Blocked, + targetTxid) + require.NotEmpty(t, targetBlocked.MissingParents) +} + +// TestSessionRestoreRejectsInvalidState verifies that state import rejects +// missing or inconsistent node progress. +func TestSessionRestoreRejectsInvalidState(t *testing.T) { + session := newMergeSession(t) + proof := session.Proof() + state := session.ExportState() + + for txid := range state.TxStates { + delete(state.TxStates, txid) + break + } + + _, err := NewSessionFromState(proof, state) + require.Error(t, err) + require.Contains(t, err.Error(), "missing tx state") +} + +// TestSessionRestorePreservesFailure verifies that terminal node failures +// survive a restart round-trip. +func TestSessionRestorePreservesFailure(t *testing.T) { + session := newMergeSession(t) + initial, err := session.SnapshotAt(50) + require.NoError(t, err) + require.Len(t, initial.ReadyToBroadcast, 2) + + failedTxid := initial.ReadyToBroadcast[0].Txid + require.NoError(t, session.MarkFailed(failedTxid, + fmt.Errorf("package rejected"))) + + restored, err := NewSessionFromState(session.Proof(), + session.ExportState()) + require.NoError(t, err) + + snapshot, err := restored.SnapshotAt(50) + require.NoError(t, err) + require.Equal(t, SessionStatusFailed, snapshot.Status) + require.Equal(t, failedTxid, snapshot.FailedTxid.UnwrapOrFail(t)) + require.Error(t, snapshot.LastError) + require.Contains(t, snapshot.LastError.Error(), "package rejected") +} + +// makeExternalOutpoint constructs a stable outpoint that is not part of the +// proof graph. +func makeExternalOutpoint(tag byte, index uint32) wire.OutPoint { + hash := chainhash.Hash{} + hash[0] = tag + hash[1] = byte(index) + + return wire.OutPoint{ + Hash: hash, + Index: index, + } +} + +// makeRecoveryTx constructs a deterministic recovery transaction for tests. +func makeRecoveryTx(tag byte, prevOuts []wire.OutPoint, + withAnchor bool) *wire.MsgTx { + + tx := wire.NewMsgTx(3) + for _, prevOut := range prevOuts { + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: prevOut, + Sequence: wire.MaxTxInSequenceNum, + }) + } + + tx.AddTxOut(&wire.TxOut{ + Value: int64(tag) + 1, + PkScript: []byte{0x51, tag}, + }) + + if withAnchor { + tx.AddTxOut(arkscript.AnchorOutput()) + } + + return tx +} + +// newMergeSession constructs a reusable multi-parent merge session. +func newMergeSession(t *testing.T) *Session { + t.Helper() + + rootATx := makeRecoveryTx('a', []wire.OutPoint{ + makeExternalOutpoint('x', 0), + }, true) + rootBTx := makeRecoveryTx('b', []wire.OutPoint{ + makeExternalOutpoint('y', 0), + }, true) + + mergeTx := makeRecoveryTx('m', []wire.OutPoint{ + {Hash: rootATx.TxHash(), Index: 0}, + {Hash: rootBTx.TxHash(), Index: 0}, + }, true) + + proof, err := NewProof( + wire.OutPoint{Hash: mergeTx.TxHash(), Index: 0}, + 5, + &Node{Kind: NodeKindCheckpoint, Tx: rootATx}, + &Node{Kind: NodeKindCheckpoint, Tx: rootBTx}, + &Node{Kind: NodeKindArk, Tx: mergeTx}, + ) + require.NoError(t, err) + + session, err := NewSession(proof) + require.NoError(t, err) + + return session +} + +// newNestedMergeSession constructs a reusable nested ancestry session. +func newNestedMergeSession(t *testing.T) *Session { + t.Helper() + + rootATx := makeRecoveryTx('a', []wire.OutPoint{ + makeExternalOutpoint('x', 0), + }, true) + rootBTx := makeRecoveryTx('b', []wire.OutPoint{ + makeExternalOutpoint('y', 0), + }, true) + rootCTx := makeRecoveryTx('c', []wire.OutPoint{ + makeExternalOutpoint('z', 0), + }, true) + + mergeBCTx := makeRecoveryTx('d', []wire.OutPoint{ + {Hash: rootBTx.TxHash(), Index: 0}, + {Hash: rootCTx.TxHash(), Index: 0}, + }, true) + + targetTx := makeRecoveryTx('t', []wire.OutPoint{ + {Hash: rootATx.TxHash(), Index: 0}, + {Hash: mergeBCTx.TxHash(), Index: 0}, + }, true) + + proof, err := NewProof( + wire.OutPoint{Hash: targetTx.TxHash(), Index: 0}, + 5, + &Node{Kind: NodeKindTree, Tx: rootATx}, + &Node{Kind: NodeKindTree, Tx: rootBTx}, + &Node{Kind: NodeKindTree, Tx: rootCTx}, + &Node{Kind: NodeKindArk, Tx: mergeBCTx}, + &Node{Kind: NodeKindArk, Tx: targetTx}, + ) + require.NoError(t, err) + + session, err := NewSession(proof) + require.NoError(t, err) + + return session +} + +// readyActionTxids collects the txids in a ready-to-broadcast list. +func readyActionTxids(actions []BroadcastAction) []chainhash.Hash { + txids := make([]chainhash.Hash, 0, len(actions)) + for _, action := range actions { + txids = append(txids, action.Txid) + } + + return txids +} + +// blockedActionForTxid returns the blocked action for the requested txid. +func blockedActionForTxid(t *testing.T, actions []BlockedAction, + txid chainhash.Hash) BlockedAction { + + t.Helper() + + for _, action := range actions { + if action.Txid == txid { + return action + } + } + + require.Failf(t, "blocked action missing", "txid=%s", txid) + + return BlockedAction{} +} diff --git a/lib/recovery/session.go b/lib/recovery/session.go new file mode 100644 index 000000000..9b7ec2bf9 --- /dev/null +++ b/lib/recovery/session.go @@ -0,0 +1,642 @@ +package recovery + +import ( + "fmt" + "math" + "sync" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// TxState describes the caller-observed state of one recovery transaction. +// +// The three states form a strict progression — Pending → Broadcasted → +// Confirmed — and the Session state machine enforces the order: a tx cannot +// be confirmed without first being broadcast. "Broadcasted" means the +// caller handed the tx to the mempool / broadcaster; it is not a chain-level +// observation, so callers may legitimately observe a tx confirmed even if +// they never called MarkBroadcasted themselves (for example, after a +// restart with stale state). This is why NewSessionFromState accepts states +// where TxStates[txid] is already Confirmed without requiring an earlier +// Broadcasted transition — the session was hydrated from persistence, not +// built step-by-step. +type TxState int + +const ( + // TxStatePending means the caller has not yet broadcast or confirmed + // the transaction. + TxStatePending TxState = iota + + // TxStateBroadcasted means the caller broadcast the transaction and is + // now waiting for confirmation. + TxStateBroadcasted + + // TxStateConfirmed means the caller observed this transaction + // confirmed on-chain. + TxStateConfirmed +) + +// String returns the stable debug label for a TxState. +func (s TxState) String() string { + switch s { + case TxStatePending: + return "pending" + + case TxStateBroadcasted: + return "broadcasted" + + case TxStateConfirmed: + return "confirmed" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} + +// SessionStatus is the high-level state of a recovery session. It is a +// convenience summary derived at snapshot time from the per-node TxStates +// plus any terminal error; callers should never persist the status as their +// source of truth — persist the per-node states and let Snapshot derive the +// status. +// +// Transition diagram: +// +// Materializing ─┬─▶ AwaitingCSV ──▶ SweepReady +// │ +// └───────────────▶ Failed (any time, caller-reported) +// +// The lifecycle is monotonic on the happy path: once every proof node is +// confirmed we're AwaitingCSV; once the chain passes the maturity height +// we're SweepReady. There is no "DoneSweeping" status here — the sweep +// itself is tracked in the `unrollplan` package. This package's scope ends +// once the target outpoint is timeout-spendable. +type SessionStatus int + +const ( + // SessionStatusMaterializing means some proof nodes still need + // broadcasting or confirmation. + SessionStatusMaterializing SessionStatus = iota + + // SessionStatusAwaitingCSV means the target confirmed but its CSV delay + // has not yet matured. + SessionStatusAwaitingCSV + + // SessionStatusSweepReady means the target is now spendable by timeout. + SessionStatusSweepReady + + // SessionStatusFailed means the caller reported a terminal error. + SessionStatusFailed +) + +// String returns the stable debug label for a SessionStatus. +func (s SessionStatus) String() string { + switch s { + case SessionStatusMaterializing: + return "materializing" + + case SessionStatusAwaitingCSV: + return "awaiting_csv" + + case SessionStatusSweepReady: + return "sweep_ready" + + case SessionStatusFailed: + return "failed" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} + +// BroadcastAction describes one transaction the caller can broadcast now. +type BroadcastAction struct { + // Txid is the transaction hash of this action. + Txid chainhash.Hash + + // Node is the recovery node to materialize. + Node *Node + + // Layer is the topological layer of this tx within the proof graph. + Layer int + + // ParentTxids are the in-proof parents that must already be confirmed. + ParentTxids []chainhash.Hash +} + +// BlockedAction describes one pending transaction that still has unmet +// dependencies. +type BlockedAction struct { + // Txid is the blocked transaction hash. + Txid chainhash.Hash + + // Layer is the topological layer of this tx within the proof graph. + Layer int + + // MissingParents are the parent txids that are not yet confirmed. + MissingParents []chainhash.Hash +} + +// CSVStatus describes the target's CSV maturity state. +type CSVStatus struct { + // TargetConfirmHeight is the block height at which the target + // transaction confirmed. + TargetConfirmHeight int32 + + // MaturityHeight is the block height at which the target becomes + // timeout-spendable. + MaturityHeight int32 + + // BlocksRemaining is how many blocks remain until maturity. + BlocksRemaining int32 + + // Ready is true once the current height is at or past maturity. + Ready bool +} + +// Snapshot is the caller-facing view of a session at one block height. +type Snapshot struct { + // Status is the high-level session status. + Status SessionStatus + + // ReadyToBroadcast are the transactions the caller can materialize now. + ReadyToBroadcast []BroadcastAction + + // AwaitingConfirmation are already broadcast transactions still waiting + // for confirmation. + AwaitingConfirmation []chainhash.Hash + + // Blocked are pending transactions with unmet in-proof dependencies. + Blocked []BlockedAction + + // CSV is populated once the target has confirmed. + CSV fn.Option[CSVStatus] + + // FailedTxid is the txid associated with a terminal error, if any. + FailedTxid fn.Option[chainhash.Hash] + + // LastError is the terminal error reported by the caller, if any. + LastError error +} + +// Session is a pure planning object driven by caller-reported observations. +// +// The model is explicitly caller-driven. The Session does not subscribe to +// the chain, does not start goroutines, and does not produce side effects. +// Callers feed it three kinds of observations (broadcast, confirm, fail) +// and ask it for a Snapshot whenever they need the current plan. This +// inversion of control keeps the session portable across different +// broadcaster / mempool / watchtower implementations and makes its behavior +// trivially deterministic under tests. +// +// # State machine +// +// Per-tx: Pending ─▶ Broadcasted ─▶ Confirmed (parent-confirmed guarded). +// Per-session: terminal `lastError` latches on first MarkFailed and is +// never overwritten; subsequent MarkFailed calls fail so the root cause +// survives a restart. +// +// # Concurrency +// +// Session methods are safe for concurrent use. An RWMutex guards the +// mutable maps; readers (SnapshotAt, ExportState) hold an RLock and writers +// (MarkBroadcasted, MarkConfirmed, MarkFailed) hold the write lock for the +// duration of the call. Internal helpers (isReady, missingParents, +// materializationComplete, csvStatusAt) assume the caller already holds a +// lock — they do NOT acquire one themselves, because the Go RWMutex does +// not support re-entrancy. +type Session struct { + mu sync.RWMutex + + proof *Proof + txStates map[chainhash.Hash]TxState + confirmHeights map[chainhash.Hash]int32 + failedTxid fn.Option[chainhash.Hash] + lastError error +} + +// NewSession constructs a session for one immutable recovery proof. The +// constructor validates only that the proof is non-nil; per-node state is +// initialized to TxStatePending. +func NewSession(proof *Proof) (*Session, error) { + if proof == nil { + return nil, fmt.Errorf("proof cannot be nil") + } + + txStates := make(map[chainhash.Hash]TxState, len(proof.nodes)) + for txid := range proof.nodes { + txStates[txid] = TxStatePending + } + + return &Session{ + proof: proof, + txStates: txStates, + confirmHeights: make( + map[chainhash.Hash]int32, len(proof.nodes), + ), + }, nil +} + +// Proof returns the immutable proof this session is driving. +func (s *Session) Proof() *Proof { + return s.proof +} + +// MarkBroadcasted records that the caller broadcast a ready transaction. +// It enforces the three preconditions that make "broadcast" meaningful: +// +// 1. The session is not in a terminal failure state. +// 2. The tx belongs to this proof graph. +// 3. Every in-proof parent is confirmed. +// +// Idempotency is deliberately NOT granted here: a second call for the same +// txid returns an "already broadcasted" error rather than a silent no-op. +// This surfaces caller bugs (e.g. double-scheduling the same tx in a +// broadcast queue) quickly instead of masking them. +func (s *Session) MarkBroadcasted(txid chainhash.Hash) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.lastError != nil { + return fmt.Errorf("session is failed: %w", s.lastError) + } + + node, ok := s.proof.Node(txid) + if !ok || node == nil { + return fmt.Errorf("unknown txid %s", txid) + } + + state := s.txStates[txid] + if state == TxStateConfirmed { + return fmt.Errorf("tx %s already confirmed", txid) + } + + if state == TxStateBroadcasted { + return fmt.Errorf("tx %s already broadcasted", txid) + } + + // Parents must all be confirmed before we pay the fee to broadcast + // this tx. If we allowed out-of-order broadcast, the mempool would + // reject this tx as "missing inputs" and we'd lose visibility into + // which parent actually needs to go first. + ready, err := s.isReady(txid) + if err != nil { + return err + } + + if !ready { + return fmt.Errorf("tx %s is not ready to broadcast", txid) + } + + s.txStates[txid] = TxStateBroadcasted + + return nil +} + +// MarkConfirmed records that the caller observed a tx confirmed on-chain. +// The transition is rejected if the session is failed, if the tx was never +// broadcast, if any in-proof parent is still unconfirmed, if the height is +// negative, or if the caller attempts to re-confirm at a different height. +// A repeat call at the original height is idempotent so that redundant chain +// notifications do not surface as errors. +// +// # Why each guard exists +// +// - "session is failed": once a terminal failure has been reported, we +// stop advancing the session so the caller's failure-handling code +// path runs to completion before any new chain observations replace +// the root-cause error. +// - "cannot confirm before broadcast": child-confirmed-without-parent is +// the exact shape that enables the int32-overflow / instant-sweep +// class of bugs described in the C-findings on the PR review. The +// state machine refuses to produce it. +// - "cannot confirm with unconfirmed parents": even if the caller +// bypassed MarkBroadcasted (e.g. via a fake backend), a child cannot +// confirm before its parent on a canonical chain. A state claiming +// otherwise is either tampered or caused by a bug in the caller's +// chain reorg handling, and we refuse to proceed. +// - "cannot reconfirm at different height": if the caller observes the +// same tx at two different heights, they are either watching two +// chains or there was a reorg; either way they should use a reorg +// API to revert the old height before reconfirming. Silently +// overwriting would invite drift between Session state and the chain. +// - Idempotency at the same height is granted because redundant chain +// notifications (e.g. on connection re-establishment) are common and +// should not surface as user-visible errors. +func (s *Session) MarkConfirmed(txid chainhash.Hash, height int32) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.lastError != nil { + return fmt.Errorf("session is failed: %w", s.lastError) + } + + node, ok := s.proof.Node(txid) + if !ok || node == nil { + return fmt.Errorf("unknown txid %s", txid) + } + + if height < 0 { + return fmt.Errorf("confirm height %d is negative", height) + } + + switch s.txStates[txid] { + case TxStatePending: + return fmt.Errorf("tx %s cannot confirm before broadcast", + txid) + + case TxStateBroadcasted: + // Fall through to the post-switch block where we verify + // parents are confirmed before applying the transition. + + case TxStateConfirmed: + // Idempotent at the original height; otherwise a caller bug. + existing := s.confirmHeights[txid] + if existing == height { + return nil + } + + return fmt.Errorf("tx %s already confirmed at height %d, "+ + "cannot reconfirm at %d", txid, existing, height) + } + + ready, err := s.isReady(txid) + if err != nil { + return err + } + + if !ready { + return fmt.Errorf("tx %s cannot confirm with unconfirmed "+ + "parents", txid) + } + + s.txStates[txid] = TxStateConfirmed + s.confirmHeights[txid] = height + + return nil +} + +// MarkFailed records a terminal failure reported by the caller. A session +// that has already been marked failed rejects subsequent MarkFailed calls so +// that a downstream symptom cannot overwrite and hide the root cause across a +// restart. +func (s *Session) MarkFailed(txid chainhash.Hash, err error) error { + s.mu.Lock() + defer s.mu.Unlock() + + if err == nil { + return fmt.Errorf("failure error cannot be nil") + } + + if s.lastError != nil { + return fmt.Errorf("session already failed: %w", s.lastError) + } + + node, ok := s.proof.Node(txid) + if !ok || node == nil { + return fmt.Errorf("unknown txid %s", txid) + } + + s.failedTxid = fn.Some(txid) + s.lastError = err + + return nil +} + +// SnapshotAt returns the current planning view at the given block height. +// +// The walk visits the proof's layers in topological order (roots first) and +// classifies each pending tx into one of three buckets: +// +// - "ready": every in-proof parent is confirmed — the caller can +// broadcast this tx next. +// - "blocked": at least one parent is still unconfirmed — the snapshot +// lists the missing parents so the caller knows what to wait for. +// - "awaiting confirmation": the tx was already broadcast; we're waiting +// for the chain. +// +// Confirmed txs are intentionally omitted from the snapshot — there is +// nothing to do about them. Once every node is confirmed we derive the +// target's CSV maturity and flip the session status accordingly. +// +// The walk is O(N) over the graph on every call. At expected recovery +// sizes (hundreds of nodes at most) this is fine and it keeps the logic +// straightforward. For larger proofs a caller could cache the last +// snapshot and invalidate on each Mark* transition, but that optimization +// is not needed today. +func (s *Session) SnapshotAt(height int32) (*Snapshot, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + ready := make([]BroadcastAction, 0) + awaiting := make([]chainhash.Hash, 0) + blocked := make([]BlockedAction, 0) + + for layerIndex, layer := range s.proof.layers { + for _, txid := range layer { + state := s.txStates[txid] + + switch state { + case TxStateBroadcasted: + awaiting = append(awaiting, txid) + + case TxStateConfirmed: + continue + + case TxStatePending: + missingParents, err := s.missingParents(txid) + if err != nil { + return nil, err + } + + node, _ := s.proof.Node(txid) + if len(missingParents) == 0 { + parentTxids, err := s.proof.ParentTxids( + txid, + ) + if err != nil { + return nil, err + } + + ready = append(ready, BroadcastAction{ + Txid: txid, + Node: node, + Layer: layerIndex, + ParentTxids: parentTxids, + }) + + continue + } + + blocked = append(blocked, BlockedAction{ + Txid: txid, + Layer: layerIndex, + MissingParents: missingParents, + }) + } + } + } + + sortHashes(awaiting) + + snapshot := &Snapshot{ + Status: SessionStatusMaterializing, + ReadyToBroadcast: ready, + AwaitingConfirmation: awaiting, + Blocked: blocked, + LastError: s.lastError, + FailedTxid: s.failedTxid, + } + + if s.lastError != nil { + snapshot.Status = SessionStatusFailed + snapshot.ReadyToBroadcast = nil + snapshot.AwaitingConfirmation = nil + snapshot.Blocked = nil + + // A terminal failure is an externally-reported state, not an + // internal planning error. The failure is surfaced via + // snapshot.LastError and Status=Failed so the caller can see + // it without losing access to the snapshot. + return snapshot, nil + } + + if !s.materializationComplete() { + return snapshot, nil + } + + csvStatus, err := s.csvStatusAt(height) + if err != nil { + return nil, err + } + + snapshot.CSV = fn.Some(csvStatus) + if csvStatus.Ready { + snapshot.Status = SessionStatusSweepReady + } else { + snapshot.Status = SessionStatusAwaitingCSV + } + + return snapshot, nil +} + +// isReady returns true once every in-proof parent is confirmed. +func (s *Session) isReady(txid chainhash.Hash) (bool, error) { + missingParents, err := s.missingParents(txid) + if err != nil { + return false, err + } + + return len(missingParents) == 0, nil +} + +// missingParents returns the parent txids that are not yet confirmed. +func (s *Session) missingParents(txid chainhash.Hash) ([]chainhash.Hash, + error) { + + parentTxids, err := s.proof.ParentTxids(txid) + if err != nil { + return nil, err + } + + missing := make([]chainhash.Hash, 0, len(parentTxids)) + for _, parentTxid := range parentTxids { + if s.txStates[parentTxid] == TxStateConfirmed { + continue + } + + missing = append(missing, parentTxid) + } + + sortHashes(missing) + + return missing, nil +} + +// materializationComplete returns true once every proof node is confirmed. +func (s *Session) materializationComplete() bool { + for txid := range s.proof.nodes { + if s.txStates[txid] != TxStateConfirmed { + return false + } + } + + return true +} + +// csvStatusAt derives the target's CSV maturity state at one block height. +func (s *Session) csvStatusAt(height int32) (CSVStatus, error) { + targetTxid := s.proof.TargetOutpoint().Hash + targetConfirmHeight, ok := s.confirmHeights[targetTxid] + if !ok { + return CSVStatus{}, fmt.Errorf("target %s is not confirmed", + targetTxid) + } + + maturityHeight, err := ComputeMaturityHeight( + targetConfirmHeight, s.proof.CSVDelay(), + ) + if err != nil { + return CSVStatus{}, err + } + + blocksRemaining := maturityHeight - height + if blocksRemaining < 0 { + blocksRemaining = 0 + } + + return CSVStatus{ + TargetConfirmHeight: targetConfirmHeight, + MaturityHeight: maturityHeight, + BlocksRemaining: blocksRemaining, + Ready: height >= maturityHeight, + }, nil +} + +// ComputeMaturityHeight returns targetConfirmHeight + csvDelay using int64 +// arithmetic, rejecting any overflow past int32 range. +// +// # Why this is its own function +// +// The naive expression `targetConfirmHeight + int32(csvDelay)` has two +// classes of bugs: +// +// 1. Signed overflow: a targetConfirmHeight close to MaxInt32 plus a +// non-trivial csvDelay wraps into a NEGATIVE number, and downstream +// code that compares "current >= maturity" reads Ready=true +// indefinitely. +// 2. Unsigned-to-signed overflow: `int32(uint32)` where the uint32 has +// its high bit set flips sign. `int32(MaxUint32) == -1`, so +// `targetConfirmHeight + (-1) == targetConfirmHeight - 1`, and a +// tampered csvDelay reports Ready=true about 136 years early. +// +// NewProof already caps csvDelay at MaxCSVDelay and +// validateSessionState / unrollplan.State.Validate reject negative +// targetConfirmHeight, so overflow is only reachable if a caller bypasses +// both guards. But this function is exported to let unrollplan reuse the +// same overflow-safe path and to keep the "belt and braces" property that +// even a buggy caller cannot construct an instant-sweep maturity height. +// +// This is exported so the unrollplan package and any other consumer +// reuses the single overflow-safe path. +func ComputeMaturityHeight(targetConfirmHeight int32, csvDelay uint32) (int32, + error) { + + if targetConfirmHeight < 0 { + return 0, fmt.Errorf("target confirm height %d is negative", + targetConfirmHeight) + } + + if csvDelay > MaxCSVDelay { + return 0, fmt.Errorf("csv delay %d exceeds max %d", + csvDelay, MaxCSVDelay) + } + + maturity := int64(targetConfirmHeight) + int64(csvDelay) + if maturity > math.MaxInt32 { + return 0, fmt.Errorf("csv maturity %d overflows int32", + maturity) + } + + return int32(maturity), nil +} diff --git a/lib/recovery/session_guards_test.go b/lib/recovery/session_guards_test.go new file mode 100644 index 000000000..2624fc0f9 --- /dev/null +++ b/lib/recovery/session_guards_test.go @@ -0,0 +1,184 @@ +package recovery + +import ( + "fmt" + "sync" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestNewSessionRejectsNilProof verifies the constructor's nil guard. +func TestNewSessionRejectsNilProof(t *testing.T) { + _, err := NewSession(nil) + require.ErrorContains(t, err, "proof cannot be nil") +} + +// TestMarkConfirmedRejectsNegativeHeight guards against the overflow path +// that would otherwise turn a poisoned confirm height into a premature +// sweep-ready signal. +func TestMarkConfirmedRejectsNegativeHeight(t *testing.T) { + session := newMergeSession(t) + txid := session.Proof().RootTxids()[0] + + require.NoError(t, session.MarkBroadcasted(txid)) + err := session.MarkConfirmed(txid, -1) + require.ErrorContains(t, err, "negative") +} + +// TestMarkConfirmedRejectsUnbroadcastedTx verifies the state-machine guard +// that a tx must be broadcast before it can be marked confirmed. +func TestMarkConfirmedRejectsUnbroadcastedTx(t *testing.T) { + session := newMergeSession(t) + txid := session.Proof().RootTxids()[0] + + err := session.MarkConfirmed(txid, 100) + require.ErrorContains(t, err, "cannot confirm before broadcast") +} + +// TestMarkConfirmedIdempotentAtSameHeight re-confirming the same txid at the +// same height should be a no-op rather than an error. +func TestMarkConfirmedIdempotentAtSameHeight(t *testing.T) { + session := newMergeSession(t) + txid := session.Proof().RootTxids()[0] + + require.NoError(t, session.MarkBroadcasted(txid)) + require.NoError(t, session.MarkConfirmed(txid, 100)) + require.NoError(t, session.MarkConfirmed(txid, 100)) + + err := session.MarkConfirmed(txid, 200) + require.ErrorContains(t, err, "cannot reconfirm") +} + +// TestMarkConfirmedRequiresParents verifies child cannot be confirmed before +// parent. Without this guard the CSV maturity view can become incoherent +// when reorgs or out-of-order chain notifications race. +func TestMarkConfirmedRequiresParents(t *testing.T) { + session := newMergeSession(t) + proof := session.Proof() + mergeTxid := proof.Layers()[1][0] + + // The merge tx was not broadcast first; also its parents are still + // pending. Both conditions should cause MarkConfirmed to refuse. + err := session.MarkConfirmed(mergeTxid, 100) + require.Error(t, err) +} + +// TestMarkFailedRejectsOverwrite verifies the H-6 guard: subsequent failures +// cannot overwrite an existing terminal error. +func TestMarkFailedRejectsOverwrite(t *testing.T) { + session := newMergeSession(t) + roots := session.Proof().RootTxids() + require.Len(t, roots, 2) + + require.NoError(t, session.MarkFailed(roots[0], fmt.Errorf("first"))) + + err := session.MarkFailed(roots[1], fmt.Errorf("second")) + require.ErrorContains(t, err, "session already failed") +} + +// TestMarkFailedRejectsNilErrAndUnknownTxid exercises the remaining +// MarkFailed guards. +func TestMarkFailedRejectsNilErrAndUnknownTxid(t *testing.T) { + session := newMergeSession(t) + + require.ErrorContains(t, + session.MarkFailed(chainhash.Hash{0xaa}, nil), + "failure error cannot be nil") + + require.ErrorContains(t, + session.MarkFailed(chainhash.Hash{0xaa}, fmt.Errorf("x")), + "unknown txid") +} + +// TestSessionConcurrencySafety fires MarkBroadcasted / MarkConfirmed / +// SnapshotAt from multiple goroutines to verify the RWMutex prevents the +// concurrent-map-read/write fatal that unsynchronized access would produce. +// Run with -race to confirm. +func TestSessionConcurrencySafety(t *testing.T) { + session := newMergeSession(t) + roots := session.Proof().RootTxids() + + var wg sync.WaitGroup + const iters = 200 + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iters; i++ { + _ = session.MarkBroadcasted(roots[0]) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iters; i++ { + _, _ = session.SnapshotAt(int32(i)) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < iters; i++ { + _, _ = session.SnapshotAt(int32(i)) + } + }() + + wg.Wait() +} + +// TestComputeMaturityHeightOverflow asserts that csvDelay values larger than +// MaxCSVDelay are rejected even when callers bypass NewProof. +func TestComputeMaturityHeightOverflow(t *testing.T) { + _, err := ComputeMaturityHeight(100, MaxCSVDelay+1) + require.ErrorContains(t, err, "exceeds max") + + _, err = ComputeMaturityHeight(-1, 10) + require.ErrorContains(t, err, "negative") + + // Near-MaxInt32 targetConfirmHeight + any csvDelay > remaining room + // overflows int32 even though both inputs alone are in-range. + _, err = ComputeMaturityHeight(2_147_483_000, MaxCSVDelay) + require.ErrorContains(t, err, "overflows") +} + +// TestNewProofRejectsOversizedCSV validates the NewProof entry-point guard +// rather than just the helper. +func TestNewProofRejectsOversizedCSV(t *testing.T) { + tx := wire.NewMsgTx(1) + tx.AddTxIn(&wire.TxIn{Sequence: wire.MaxTxInSequenceNum}) + tx.AddTxOut(&wire.TxOut{Value: 1, PkScript: []byte{0x51}}) + _, err := NewProof( + wire.OutPoint{Hash: tx.TxHash()}, MaxCSVDelay+1, + &Node{Kind: NodeKindTree, Tx: tx}, + ) + require.ErrorContains(t, err, "csv delay") +} + +// TestNewSessionFromStateRejectsBadParents exercises the H-2 parent-confirmed +// invariant for persisted state. +func TestNewSessionFromStateRejectsBadParents(t *testing.T) { + session := newMergeSession(t) + proof := session.Proof() + mergeTxid := proof.Layers()[1][0] + + // Manually craft a state that marks the merge tx confirmed without + // confirming its parents. Before H-2 this was silently accepted. + bad := &SessionState{ + TxStates: map[chainhash.Hash]TxState{}, + ConfirmHeights: map[chainhash.Hash]int32{ + mergeTxid: 100, + }, + } + for txid := range session.txStates { + bad.TxStates[txid] = TxStatePending + } + bad.TxStates[mergeTxid] = TxStateConfirmed + + _, err := NewSessionFromState(proof, bad) + require.ErrorContains(t, err, "confirmed with unconfirmed parent") +} diff --git a/lib/recovery/state.go b/lib/recovery/state.go new file mode 100644 index 000000000..23a7b8cac --- /dev/null +++ b/lib/recovery/state.go @@ -0,0 +1,251 @@ +package recovery + +import ( + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// SessionState is the durable caller-owned state for one recovery session. +// +// # Persistence philosophy +// +// The proof graph itself is immutable and stored separately (encoded via +// proof_codec.go). Only caller observations that must survive restart live +// here. Restart recovery is thus a two-step rehydrate: decode the Proof, +// decode the SessionState, and call NewSessionFromState. The separation +// means the (expensive) graph validation only runs once, when the Proof is +// first built and persisted; subsequent restarts only pay for state +// validation against an already-validated graph. +// +// # Invariants (mirrored by validateSessionState) +// +// - TxStates has an entry for every node in the proof. +// - A node with TxStateConfirmed has a matching ConfirmHeights entry and +// that height is non-negative. +// - A node with TxStatePending or TxStateBroadcasted has NO +// ConfirmHeights entry. +// - A confirmed node's in-proof parents are all confirmed too (no +// "dangling child" states). +// - FailedTxid and LastError are either both set or both empty, and the +// failed txid exists in the proof. +// +// These invariants are exactly the ones the Session state machine would +// have enforced at runtime; validating them on load means a caller cannot +// "sneak in" an inconsistent state by editing the blob directly. +type SessionState struct { + // TxStates records the caller-observed state for each proof node. + TxStates map[chainhash.Hash]TxState + + // ConfirmHeights records the confirmation height for confirmed nodes. + ConfirmHeights map[chainhash.Hash]int32 + + // FailedTxid is the node associated with the terminal failure, if any. + FailedTxid fn.Option[chainhash.Hash] + + // LastError carries the terminal failure string, if any. + LastError string +} + +// NewSessionFromState constructs a session from immutable proof data and a +// previously exported caller-owned state snapshot. Validation runs before +// any copy so an invalid state can never produce a partially-constructed +// Session (which would be a landmine for any caller that inspected it). +// +// The LastError string is wrapped back into a sentinel `error` value via +// fmt.Errorf("%s", ...) — we deliberately lose the original error type, +// because the type is not serialized by ExportState and any claim to +// preserve it would be misleading. +func NewSessionFromState(proof *Proof, state *SessionState) (*Session, + error) { + + if proof == nil { + return nil, fmt.Errorf("proof cannot be nil") + } + + if state == nil { + return nil, fmt.Errorf("session state cannot be nil") + } + + if err := validateSessionState(proof, state); err != nil { + return nil, err + } + + txStates := make(map[chainhash.Hash]TxState, len(state.TxStates)) + for txid, txState := range state.TxStates { + txStates[txid] = txState + } + + confirmHeights := make(map[chainhash.Hash]int32, + len(state.ConfirmHeights)) + for txid, height := range state.ConfirmHeights { + confirmHeights[txid] = height + } + + session := &Session{ + proof: proof, + txStates: txStates, + confirmHeights: confirmHeights, + failedTxid: state.FailedTxid, + } + + if state.LastError != "" { + session.lastError = fmt.Errorf("%s", state.LastError) + } + + return session, nil +} + +// ExportState returns a durable snapshot of the session's caller-owned state. +func (s *Session) ExportState() *SessionState { + s.mu.RLock() + defer s.mu.RUnlock() + + txStates := make(map[chainhash.Hash]TxState, len(s.txStates)) + for txid, txState := range s.txStates { + txStates[txid] = txState + } + + confirmHeights := make(map[chainhash.Hash]int32, + len(s.confirmHeights)) + for txid, height := range s.confirmHeights { + confirmHeights[txid] = height + } + + state := &SessionState{ + TxStates: txStates, + ConfirmHeights: confirmHeights, + FailedTxid: s.failedTxid, + } + + if s.lastError != nil { + state.LastError = s.lastError.Error() + } + + return state +} + +// validateSessionState checks that a durable session state is consistent +// with the immutable proof graph it claims to execute. +// +// # Why mirror the Session state machine here +// +// The Session state machine enforces every invariant at each transition +// (MarkBroadcasted, MarkConfirmed, MarkFailed). But a persisted state can +// also be produced by: (a) an earlier version of this code, (b) a caller +// editing the blob directly, (c) a bug in the TLV codec. So before we +// hydrate a SessionState into a Session we re-run every invariant that the +// state machine would have enforced. A state that passes Validate is +// guaranteed to behave the same whether it was reached via a series of +// Mark* calls or loaded from disk. +// +// The checks are ordered so the cheapest failures (nil maps, missing +// per-node entries) surface first, and the per-node topological invariant +// (confirmed child requires confirmed parent) runs last since it is the +// most expensive. +func validateSessionState(proof *Proof, state *SessionState) error { + if state.TxStates == nil { + return fmt.Errorf("tx states cannot be nil") + } + + if state.ConfirmHeights == nil { + return fmt.Errorf("confirm heights cannot be nil") + } + + if state.FailedTxid.IsNone() != (state.LastError == "") { + return fmt.Errorf("failed txid and last error " + + "must be set together") + } + + var failedErr error + state.FailedTxid.WhenSome(func(txid chainhash.Hash) { + if _, ok := proof.Node(txid); !ok { + failedErr = fmt.Errorf("failed txid %s is not "+ + "in proof", txid) + } + }) + if failedErr != nil { + return failedErr + } + + for txid := range proof.nodes { + txState, ok := state.TxStates[txid] + if !ok { + return fmt.Errorf("missing tx state for %s", txid) + } + + confirmHeight, hasConfirmHeight := state.ConfirmHeights[txid] + + switch txState { + case TxStatePending, TxStateBroadcasted: + if hasConfirmHeight { + return fmt.Errorf("tx %s has unexpected "+ + "confirmation height", txid) + } + + case TxStateConfirmed: + if !hasConfirmHeight { + return fmt.Errorf("confirmed tx %s missing "+ + "confirmation height", txid) + } + + // A negative confirm height is never valid and, absent + // this guard, overflows csv maturity arithmetic into + // a small positive number that reports Ready=true. + if confirmHeight < 0 { + return fmt.Errorf("confirmed tx %s has "+ + "negative height %d", txid, + confirmHeight) + } + + default: + return fmt.Errorf("unknown tx state %d for %s", + txState, txid) + } + } + + for txid := range state.TxStates { + if _, ok := proof.Node(txid); ok { + continue + } + + return fmt.Errorf("tx state contains unknown txid %s", txid) + } + + for txid := range state.ConfirmHeights { + if _, ok := proof.Node(txid); ok { + continue + } + + return fmt.Errorf("confirm heights contains unknown txid %s", + txid) + } + + // A confirmed node may not have any unconfirmed parent. This is the + // topological invariant that the Session state machine enforces in + // MarkConfirmed; we mirror it here so a persisted state that bypassed + // the state machine (e.g. direct JSON/TLV surgery, or a coding bug) + // cannot pass validation. + for txid, txState := range state.TxStates { + if txState != TxStateConfirmed { + continue + } + + parents, err := proof.ParentTxids(txid) + if err != nil { + return err + } + + for _, parent := range parents { + if state.TxStates[parent] == TxStateConfirmed { + continue + } + + return fmt.Errorf("tx %s confirmed with unconfirmed "+ + "parent %s", txid, parent) + } + } + + return nil +} diff --git a/lib/recovery/state_codec.go b/lib/recovery/state_codec.go new file mode 100644 index 000000000..605d7f559 --- /dev/null +++ b/lib/recovery/state_codec.go @@ -0,0 +1,328 @@ +package recovery + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "sort" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/tlv" +) + +// SessionStateCodecVersion is the on-disk version byte written by Encode and +// accepted by Decode. Bumping this value lets us migrate the wire format in a +// later release while still rejecting unknown versions clearly. +const SessionStateCodecVersion uint8 = 1 + +const ( + // sessionStateVersionRecordType carries the single-byte codec + // version. It MUST come first so decoders that want to fast-fail on + // version mismatch can do so without parsing further records. + sessionStateVersionRecordType tlv.Type = 1 + + // sessionStateTxStatesRecordType carries the (txid, TxState) list. + sessionStateTxStatesRecordType tlv.Type = 3 + + // sessionStateConfirmHeightsRecordType carries the + // (txid, confirmHeight) list. + sessionStateConfirmHeightsRecordType tlv.Type = 5 + + // sessionStateFailedTxidRecordType is the optional failed-txid record. + // Absent when the session has no terminal failure. + sessionStateFailedTxidRecordType tlv.Type = 7 + + // sessionStateLastErrorRecordType is the optional terminal error + // string. Absent when the session has no terminal failure. + sessionStateLastErrorRecordType tlv.Type = 9 +) + +// EncodeSessionState serializes a SessionState into a length-prefix-free TLV +// byte slice. The returned bytes can be concatenated with other TLV records +// by the caller if needed; for standalone persistence, the caller should +// length-prefix the blob at the outer layer. +func EncodeSessionState(state *SessionState) ([]byte, error) { + if state == nil { + return nil, fmt.Errorf("session state cannot be nil") + } + + version := SessionStateCodecVersion + txStates, err := encodeTxStateMap(state.TxStates) + if err != nil { + return nil, fmt.Errorf("encode tx states: %w", err) + } + confirmHeights, err := encodeConfirmHeightMap(state.ConfirmHeights) + if err != nil { + return nil, fmt.Errorf("encode confirm heights: %w", err) + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord( + sessionStateVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + sessionStateTxStatesRecordType, &txStates, + ), + tlv.MakePrimitiveRecord( + sessionStateConfirmHeightsRecordType, &confirmHeights, + ), + } + + state.FailedTxid.WhenSome(func(hash chainhash.Hash) { + failedTxid := hash[:] + records = append(records, tlv.MakePrimitiveRecord( + sessionStateFailedTxidRecordType, &failedTxid, + )) + }) + + if state.LastError != "" { + lastError := []byte(state.LastError) + records = append(records, tlv.MakePrimitiveRecord( + sessionStateLastErrorRecordType, &lastError, + )) + } + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, fmt.Errorf("create session state stream: %w", err) + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, fmt.Errorf("encode session state: %w", err) + } + + return buf.Bytes(), nil +} + +// DecodeSessionState parses a TLV-encoded SessionState. Only the current +// codec version is accepted; forward/backward compatibility must go through +// an explicit version bump plus migration. +func DecodeSessionState(raw []byte) (*SessionState, error) { + var ( + version uint8 + txStatesRaw []byte + heightsRaw []byte + failedTxid []byte + lastErrorBytes []byte + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord( + sessionStateVersionRecordType, &version, + ), + tlv.MakePrimitiveRecord( + sessionStateTxStatesRecordType, &txStatesRaw, + ), + tlv.MakePrimitiveRecord( + sessionStateConfirmHeightsRecordType, &heightsRaw, + ), + tlv.MakePrimitiveRecord( + sessionStateFailedTxidRecordType, &failedTxid, + ), + tlv.MakePrimitiveRecord( + sessionStateLastErrorRecordType, &lastErrorBytes, + ), + ) + if err != nil { + return nil, fmt.Errorf("create session state stream: %w", err) + } + + parsed, err := stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode session state: %w", err) + } + + if _, ok := parsed[sessionStateVersionRecordType]; !ok { + return nil, fmt.Errorf("session state missing version record") + } + if version != SessionStateCodecVersion { + return nil, fmt.Errorf("unsupported session state codec "+ + "version %d (expected %d)", version, + SessionStateCodecVersion) + } + + state := &SessionState{} + + state.TxStates, err = decodeTxStateMap(txStatesRaw) + if err != nil { + return nil, fmt.Errorf("decode tx states: %w", err) + } + + state.ConfirmHeights, err = decodeConfirmHeightMap(heightsRaw) + if err != nil { + return nil, fmt.Errorf("decode confirm heights: %w", err) + } + + if _, ok := parsed[sessionStateFailedTxidRecordType]; ok { + if len(failedTxid) != chainhash.HashSize { + return nil, fmt.Errorf("failed txid length %d "+ + "invalid", len(failedTxid)) + } + + var hash chainhash.Hash + copy(hash[:], failedTxid) + state.FailedTxid = fn.Some(hash) + } + + if _, ok := parsed[sessionStateLastErrorRecordType]; ok { + state.LastError = string(lastErrorBytes) + } + + return state, nil +} + +// encodeTxStateMap serializes the tx-state map as a length-prefixed list of +// (hash || uint8 state) entries. Entries are emitted in ascending hash byte +// order so the encoded form is deterministic for a given logical state. +func encodeTxStateMap(states map[chainhash.Hash]TxState) ([]byte, error) { + keys := sortedHashKeys(states) + + var buf bytes.Buffer + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(keys))) + if _, err := buf.Write(lenBuf[:]); err != nil { + return nil, err + } + + for _, key := range keys { + state := states[key] + if state < 0 || state > 255 { + return nil, fmt.Errorf("tx state %d out of range", + state) + } + + if _, err := buf.Write(key[:]); err != nil { + return nil, err + } + if err := buf.WriteByte(byte(state)); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +// decodeTxStateMap reverses encodeTxStateMap and rejects duplicate keys. A +// duplicate key would make the resulting Go map non-deterministic because +// last-write-wins, so we fail loudly instead. +func decodeTxStateMap(raw []byte) (map[chainhash.Hash]TxState, error) { + if len(raw) < 4 { + return nil, fmt.Errorf("truncated tx state map") + } + + count := binary.BigEndian.Uint32(raw[:4]) + raw = raw[4:] + + const entrySize = chainhash.HashSize + 1 + if uint64(len(raw)) != uint64(count)*uint64(entrySize) { + return nil, fmt.Errorf("tx state map length mismatch: "+ + "count=%d payload=%d", count, len(raw)) + } + + out := make(map[chainhash.Hash]TxState, count) + for i := uint32(0); i < count; i++ { + var hash chainhash.Hash + copy(hash[:], raw[:chainhash.HashSize]) + state := TxState(raw[chainhash.HashSize]) + raw = raw[entrySize:] + + if _, exists := out[hash]; exists { + return nil, fmt.Errorf("duplicate tx state key %s", + hash) + } + + out[hash] = state + } + + return out, nil +} + +// encodeConfirmHeightMap serializes the confirm-height map as a +// length-prefixed list of (hash || big-endian int32) entries in sorted hash +// order. +func encodeConfirmHeightMap( + heights map[chainhash.Hash]int32) ([]byte, error) { + + keys := sortedHashKeys(heights) + + var buf bytes.Buffer + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(keys))) + if _, err := buf.Write(lenBuf[:]); err != nil { + return nil, err + } + + for _, key := range keys { + if _, err := buf.Write(key[:]); err != nil { + return nil, err + } + var heightBuf [4]byte + binary.BigEndian.PutUint32( + heightBuf[:], uint32(heights[key]), + ) + if _, err := buf.Write(heightBuf[:]); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +// decodeConfirmHeightMap reverses encodeConfirmHeightMap and rejects +// duplicate keys for the same reason as decodeTxStateMap. +func decodeConfirmHeightMap(raw []byte) (map[chainhash.Hash]int32, error) { + if len(raw) < 4 { + return nil, fmt.Errorf("truncated confirm height map") + } + + count := binary.BigEndian.Uint32(raw[:4]) + raw = raw[4:] + + const entrySize = chainhash.HashSize + 4 + if uint64(len(raw)) != uint64(count)*uint64(entrySize) { + return nil, fmt.Errorf("confirm height map length mismatch: "+ + "count=%d payload=%d", count, len(raw)) + } + + out := make(map[chainhash.Hash]int32, count) + for i := uint32(0); i < count; i++ { + var hash chainhash.Hash + copy(hash[:], raw[:chainhash.HashSize]) + height := int32( + binary.BigEndian.Uint32(raw[chainhash.HashSize:]), + ) + raw = raw[entrySize:] + + if _, exists := out[hash]; exists { + return nil, fmt.Errorf("duplicate confirm height "+ + "key %s", hash) + } + + out[hash] = height + } + + return out, nil +} + +// sortedHashKeys returns the keys of a map keyed by chainhash.Hash sorted in +// ascending byte order. Centralized so encoders do not need to re-implement +// the same sort and so the determinism invariant holds across map types. +func sortedHashKeys[V any](m map[chainhash.Hash]V) []chainhash.Hash { + keys := make([]chainhash.Hash, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return bytes.Compare(keys[i][:], keys[j][:]) < 0 + }) + + return keys +} + +// assertWriter is a compile-time check that *bytes.Buffer implements +// io.Writer. Kept as an anchor so future refactors that replace the buffer +// type still satisfy the TLV Writer contract. +var _ io.Writer = (*bytes.Buffer)(nil) diff --git a/lib/recovery/state_codec_test.go b/lib/recovery/state_codec_test.go new file mode 100644 index 000000000..faf54b1a3 --- /dev/null +++ b/lib/recovery/state_codec_test.go @@ -0,0 +1,287 @@ +package recovery + +import ( + "bytes" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestEncodeSessionStateNilRejected verifies the encoder refuses a nil input +// rather than panicking. +func TestEncodeSessionStateNilRejected(t *testing.T) { + _, err := EncodeSessionState(nil) + require.ErrorContains(t, err, "session state cannot be nil") +} + +// TestSessionStateCodecRoundTrip exercises a deliberately chosen concrete +// state including both success and failure arms so a regression on any +// single field gets caught in isolation. +func TestSessionStateCodecRoundTrip(t *testing.T) { + h1 := hashFromByte(1) + h2 := hashFromByte(2) + h3 := hashFromByte(3) + + cases := []struct { + name string + state *SessionState + }{ + { + name: "happy_path", + state: &SessionState{ + TxStates: map[chainhash.Hash]TxState{ + h1: TxStatePending, + h2: TxStateBroadcasted, + h3: TxStateConfirmed, + }, + ConfirmHeights: map[chainhash.Hash]int32{ + h3: 123, + }, + }, + }, + { + name: "failure", + state: &SessionState{ + TxStates: map[chainhash.Hash]TxState{ + h1: TxStateConfirmed, + h2: TxStatePending, + }, + ConfirmHeights: map[chainhash.Hash]int32{ + h1: 0, + }, + FailedTxid: fn.Some(h2), + LastError: "package rejected", + }, + }, + { + name: "empty", + state: &SessionState{ + TxStates: map[chainhash.Hash]TxState{}, + ConfirmHeights: map[chainhash.Hash]int32{}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + raw, err := EncodeSessionState(tc.state) + require.NoError(t, err) + + decoded, err := DecodeSessionState(raw) + require.NoError(t, err) + require.Equal(t, tc.state, decoded) + + // Encoding the decoded state must round-trip + // byte-for-byte. This is the invariant that makes the + // codec suitable for hashing/signing by downstream + // consumers. + raw2, err := EncodeSessionState(decoded) + require.NoError(t, err) + require.True(t, bytes.Equal(raw, raw2), + "encoding must be deterministic") + }) + } +} + +// TestSessionStateCodecVersionMismatchRejected verifies that a blob written +// under an unknown version is rejected with a clear error. +func TestSessionStateCodecVersionMismatchRejected(t *testing.T) { + raw, err := EncodeSessionState(&SessionState{ + TxStates: map[chainhash.Hash]TxState{}, + ConfirmHeights: map[chainhash.Hash]int32{}, + }) + require.NoError(t, err) + + // Corrupt the version byte. The version record is the first TLV and + // its payload byte is at offset 2 (type=1, length=1, value=version). + require.GreaterOrEqual(t, len(raw), 3) + raw[2] = 99 + + _, err = DecodeSessionState(raw) + require.ErrorContains(t, err, "unsupported session state codec") +} + +// TestSessionStateCodecDuplicateKeyRejected verifies that a crafted blob with +// duplicate txids in a map decodes with an explicit error rather than +// silently folding entries together. +func TestSessionStateCodecDuplicateKeyRejected(t *testing.T) { + h := hashFromByte(1) + + // Two entries for the same hash in tx_states. decodeTxStateMap must + // reject so a tampered file cannot mask a confirmation. + bad := make([]byte, 0, 4+2*(chainhash.HashSize+1)) + bad = append(bad, 0, 0, 0, 2) + bad = append(bad, h[:]...) + bad = append(bad, byte(TxStateConfirmed)) + bad = append(bad, h[:]...) + bad = append(bad, byte(TxStatePending)) + + _, err := decodeTxStateMap(bad) + require.ErrorContains(t, err, "duplicate tx state key") + + h2 := hashFromByte(1) + bad2 := make([]byte, 0, 4+2*(chainhash.HashSize+4)) + bad2 = append(bad2, 0, 0, 0, 2) + bad2 = append(bad2, h2[:]...) + bad2 = append(bad2, 0, 0, 0, 100) + bad2 = append(bad2, h2[:]...) + bad2 = append(bad2, 0, 0, 0, 200) + + _, err = decodeConfirmHeightMap(bad2) + require.ErrorContains(t, err, "duplicate confirm height key") +} + +// TestSessionStateCodecTruncatedRejected verifies that short blobs surface +// explicit decode errors instead of trusting the length prefix blindly. +func TestSessionStateCodecTruncatedRejected(t *testing.T) { + _, err := decodeTxStateMap([]byte{0, 0}) + require.ErrorContains(t, err, "truncated tx state map") + + _, err = decodeConfirmHeightMap([]byte{0, 0}) + require.ErrorContains(t, err, "truncated confirm height map") + + // Count claims two entries but payload holds only one. + shortHash := hashFromByte(1) + short := make([]byte, 0, 4+chainhash.HashSize+1) + short = append(short, 0, 0, 0, 2) + short = append(short, shortHash[:]...) + short = append(short, byte(TxStatePending)) + _, err = decodeTxStateMap(short) + require.ErrorContains(t, err, "length mismatch") +} + +// TestSessionStateRapidRoundTrip exercises the codec over randomly generated +// states. If any (TxStates, ConfirmHeights, FailedTxid, LastError) +// combination fails to round-trip exactly, rapid shrinks to the minimal +// counterexample automatically. +func TestSessionStateRapidRoundTrip(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + state := drawSessionState(t) + + raw, err := EncodeSessionState(state) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + decoded, err := DecodeSessionState(raw) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if !sessionStatesEqual(state, decoded) { + t.Fatalf("round-trip mismatch:\nwant %+v\ngot %+v", + state, decoded) + } + + // Encoding the decoded state must match the original bytes: + // the codec is required to be canonical so downstream + // hash/signing paths produce stable outputs. + raw2, err := EncodeSessionState(decoded) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + if !bytes.Equal(raw, raw2) { + t.Fatalf("encoding is not canonical") + } + }) +} + +// drawSessionState builds a random, internally-consistent SessionState. +func drawSessionState(t *rapid.T) *SessionState { + numNodes := rapid.IntRange(0, 8).Draw(t, "numNodes") + + txStates := make(map[chainhash.Hash]TxState, numNodes) + confirmHeights := make(map[chainhash.Hash]int32) + + for i := 0; i < numNodes; i++ { + var h chainhash.Hash + bytesSlice := rapid.SliceOfN( + rapid.Byte(), chainhash.HashSize, + chainhash.HashSize, + ).Draw(t, fmt.Sprintf("hash-%d", i)) + copy(h[:], bytesSlice) + + if _, exists := txStates[h]; exists { + continue + } + + stateVal := TxState(rapid.IntRange( + int(TxStatePending), int(TxStateConfirmed), + ).Draw(t, fmt.Sprintf("state-%d", i))) + txStates[h] = stateVal + + if stateVal == TxStateConfirmed { + confirmHeights[h] = rapid.Int32Range( + 0, 1_000_000, + ).Draw(t, fmt.Sprintf("height-%d", i)) + } + } + + state := &SessionState{ + TxStates: txStates, + ConfirmHeights: confirmHeights, + } + + hasFailure := rapid.Bool().Draw(t, "hasFailure") + if hasFailure && numNodes > 0 { + // Pick any tx at random to fail. + for txid := range txStates { + hash := txid + state.FailedTxid = fn.Some(hash) + break + } + state.LastError = rapid.StringN(1, 40, -1).Draw( + t, "lastError", + ) + } + + return state +} + +// sessionStatesEqual deeply compares two SessionState values including the +// fn.Option fields that require WhenSome unwrapping. +func sessionStatesEqual(a, b *SessionState) bool { + if len(a.TxStates) != len(b.TxStates) { + return false + } + for k, v := range a.TxStates { + if b.TxStates[k] != v { + return false + } + } + + if len(a.ConfirmHeights) != len(b.ConfirmHeights) { + return false + } + for k, v := range a.ConfirmHeights { + if b.ConfirmHeights[k] != v { + return false + } + } + + if a.FailedTxid.IsSome() != b.FailedTxid.IsSome() { + return false + } + if a.FailedTxid.IsSome() { + aTxid := a.FailedTxid.UnsafeFromSome() + bTxid := b.FailedTxid.UnsafeFromSome() + if aTxid != bTxid { + return false + } + } + + return a.LastError == b.LastError +} + +// hashFromByte constructs a chainhash.Hash whose first byte is the given +// marker; useful for building stable, human-readable test fixtures. +func hashFromByte(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + return h +} diff --git a/unrollplan/AGENTS.md b/unrollplan/AGENTS.md new file mode 100644 index 000000000..9dfa74cbc --- /dev/null +++ b/unrollplan/AGENTS.md @@ -0,0 +1,67 @@ +# unrollplan + +## Purpose + +Pure dependency-resolution planner for unilateral-exit recovery. Given an +immutable `recovery.Proof`, a caller-owned `State`, and a current block +height, the planner answers: which proof transactions are ready to broadcast, +which are blocked, and when is the target CSV-mature for sweeping? No I/O, +no actors — callers own the durable state and the planner re-derives +everything from first principles on each call. + +## Key Types + +- `Planner` — Constructor-validated wrapper around one immutable + `recovery.Proof`; only method is `Plan(height, state)`. +- `State` — Durable caller-owned progress: `ConfirmedTxids`, `InFlightTxids`, + optional `TargetConfirmHeight`, and the final `Sweep` lifecycle. Validated + against the proof graph before planning. +- `Snapshot` — Caller-facing planning view: `Ready`, `InFlight`, `Blocked` + frontiers plus CSV info and the `NeedSweep` / `Done` flags. +- `TxFrontier` / `BlockedTx` — Sorted (layer, txid) entries carrying the + immutable proof node and any missing parents. +- `SweepState` / `SweepStatus` — Pending / Broadcasted / Confirmed lifecycle + for the final sweep, with optional txid + confirm height as `fn.Option`. +- `CSVInfo` — Maturity view at a height (target confirm height, maturity + height, blocks remaining, ready flag). + +## Relationships + +- **Depends on**: `lib/recovery` (immutable `Proof` + `Node` + + `ComputeMaturityHeight`), `github.com/lightningnetwork/lnd/fn/v2` + (`Option`, `MapOptionZ`), `github.com/lightningnetwork/lnd/tlv` (state + codec). +- **Depended on by**: later recovery PRs (3/5 wiring, 4/5, 5/5) will consume + `Planner` + `EncodeState` / `DecodeState` from their actor layer. + +## Invariants + +- `State.Validate` is run on every `Plan` call; callers may pass mutable state + structs and expect a fresh validation each time. +- `ConfirmedTxids` and `InFlightTxids` must be disjoint, duplicate-free, and + contained in the proof's node set. +- A confirmed child requires all parents confirmed (topological invariant + mirrored from `lib/recovery.validateSessionState`). +- `TargetConfirmHeight` must be non-negative and may be set only when the + target appears in `ConfirmedTxids`; inversely, a confirmed target requires + `TargetConfirmHeight` to be set. +- `SweepStatusBroadcasted` requires the target to be in `ConfirmedTxids` — + the sweep tx spends the target via a CSV-timelocked path and could not + land in a mempool otherwise. +- `SweepStatusConfirmed` requires a non-negative confirm height that is + at or past `target_confirm_height + csv_delay` (via + `recovery.ComputeMaturityHeight`). +- `Sweep.Txid` must never collide with a proof node txid; a collision would + make the planner treat the same hash as both a confirmed proof node and a + sweep step. +- CSV maturity is computed via the overflow-safe `recovery.ComputeMaturityHeight` + rather than inline `+ int32(...)` math, so bogus inputs fail loudly instead + of reporting `Ready=true` after an int32 wrap. +- The TLV state codec is canonical (sorted hash lists, single-value + optionals), carries a version byte, and rejects duplicate keys eagerly. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. +- [doc.go](doc.go) — Package overview comment. +- [lib/recovery/CLAUDE.md](../lib/recovery/CLAUDE.md) — Upstream proof model. diff --git a/unrollplan/CLAUDE.md b/unrollplan/CLAUDE.md new file mode 100644 index 000000000..9dfa74cbc --- /dev/null +++ b/unrollplan/CLAUDE.md @@ -0,0 +1,67 @@ +# unrollplan + +## Purpose + +Pure dependency-resolution planner for unilateral-exit recovery. Given an +immutable `recovery.Proof`, a caller-owned `State`, and a current block +height, the planner answers: which proof transactions are ready to broadcast, +which are blocked, and when is the target CSV-mature for sweeping? No I/O, +no actors — callers own the durable state and the planner re-derives +everything from first principles on each call. + +## Key Types + +- `Planner` — Constructor-validated wrapper around one immutable + `recovery.Proof`; only method is `Plan(height, state)`. +- `State` — Durable caller-owned progress: `ConfirmedTxids`, `InFlightTxids`, + optional `TargetConfirmHeight`, and the final `Sweep` lifecycle. Validated + against the proof graph before planning. +- `Snapshot` — Caller-facing planning view: `Ready`, `InFlight`, `Blocked` + frontiers plus CSV info and the `NeedSweep` / `Done` flags. +- `TxFrontier` / `BlockedTx` — Sorted (layer, txid) entries carrying the + immutable proof node and any missing parents. +- `SweepState` / `SweepStatus` — Pending / Broadcasted / Confirmed lifecycle + for the final sweep, with optional txid + confirm height as `fn.Option`. +- `CSVInfo` — Maturity view at a height (target confirm height, maturity + height, blocks remaining, ready flag). + +## Relationships + +- **Depends on**: `lib/recovery` (immutable `Proof` + `Node` + + `ComputeMaturityHeight`), `github.com/lightningnetwork/lnd/fn/v2` + (`Option`, `MapOptionZ`), `github.com/lightningnetwork/lnd/tlv` (state + codec). +- **Depended on by**: later recovery PRs (3/5 wiring, 4/5, 5/5) will consume + `Planner` + `EncodeState` / `DecodeState` from their actor layer. + +## Invariants + +- `State.Validate` is run on every `Plan` call; callers may pass mutable state + structs and expect a fresh validation each time. +- `ConfirmedTxids` and `InFlightTxids` must be disjoint, duplicate-free, and + contained in the proof's node set. +- A confirmed child requires all parents confirmed (topological invariant + mirrored from `lib/recovery.validateSessionState`). +- `TargetConfirmHeight` must be non-negative and may be set only when the + target appears in `ConfirmedTxids`; inversely, a confirmed target requires + `TargetConfirmHeight` to be set. +- `SweepStatusBroadcasted` requires the target to be in `ConfirmedTxids` — + the sweep tx spends the target via a CSV-timelocked path and could not + land in a mempool otherwise. +- `SweepStatusConfirmed` requires a non-negative confirm height that is + at or past `target_confirm_height + csv_delay` (via + `recovery.ComputeMaturityHeight`). +- `Sweep.Txid` must never collide with a proof node txid; a collision would + make the planner treat the same hash as both a confirmed proof node and a + sweep step. +- CSV maturity is computed via the overflow-safe `recovery.ComputeMaturityHeight` + rather than inline `+ int32(...)` math, so bogus inputs fail loudly instead + of reporting `Ready=true` after an int32 wrap. +- The TLV state codec is canonical (sorted hash lists, single-value + optionals), carries a version byte, and rejects duplicate keys eagerly. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. +- [doc.go](doc.go) — Package overview comment. +- [lib/recovery/CLAUDE.md](../lib/recovery/CLAUDE.md) — Upstream proof model. diff --git a/unrollplan/doc.go b/unrollplan/doc.go new file mode 100644 index 000000000..660d87e1f --- /dev/null +++ b/unrollplan/doc.go @@ -0,0 +1,43 @@ +// Package unrollplan provides pure planning for unilateral-exit execution. +// +// # Mental model +// +// Given an immutable `recovery.Proof` (the DAG of transactions the user must +// broadcast to reach a target outpoint plus a final sweep), the planner +// answers three questions at each block height: +// +// 1. Which proof transactions are ready to broadcast right now? +// (all in-proof parents are confirmed) +// 2. Which transactions are blocked, and on what? +// (the set of unconfirmed parent txids) +// 3. Has the target's CSV delay matured; do we need to broadcast the sweep; +// is the sweep already broadcast or confirmed; is the session "done"? +// +// The planner owns no state. Callers hand in a durable `State` struct on +// every `Plan` call; the planner re-derives the frontier from first +// principles against the immutable proof. This makes crash recovery trivial +// (restore State from disk, call Plan, continue) and makes the planner +// amenable to property-based testing. +// +// # No I/O +// +// Like `lib/recovery`, this package is deliberately synchronous and +// I/O-free. It does not broadcast, does not watch the chain, does not +// schedule retries. The actor that wires this planner into a daemon (later +// PRs in the stack) is responsible for those concerns. +// +// # Validation symmetry +// +// `State.Validate` is the single source of truth for "is this state +// self-consistent with the proof graph?". Every call to `Plan` runs it. +// The same invariants are mirrored in `lib/recovery.validateSessionState` so +// a state that passes one layer is guaranteed to pass the other — callers +// can rely on this when choosing where to deserialize state. +// +// # On-disk form +// +// `State` is TLV-encoded via state_codec.go. Optional fields use `fn.Option` +// instead of nilable pointers so a caller cannot accidentally confuse +// "absent" with "zero value". The codec is canonical (sorted txid lists, +// single-value optionals) and carries a version byte for forward-migration. +package unrollplan diff --git a/unrollplan/planner.go b/unrollplan/planner.go new file mode 100644 index 000000000..e7ff14384 --- /dev/null +++ b/unrollplan/planner.go @@ -0,0 +1,781 @@ +package unrollplan + +import ( + "bytes" + "fmt" + "sort" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightningnetwork/lnd/fn/v2" +) + +// Planner evaluates unilateral-exit progress for one immutable recovery +// proof. +// +// The Planner is intentionally stateless: it wraps a *recovery.Proof and +// nothing else. All progress state lives in the caller-owned `State` passed +// to Plan. This shape makes the Planner trivially re-usable across restarts +// (build once, call Plan whenever state changes) and avoids the +// cache-invalidation bugs a stateful planner would invite when the durable +// state evolves on disk. +type Planner struct { + proof *recovery.Proof +} + +// NewPlanner creates a pure planner for one immutable recovery proof. The +// only failure mode is a nil proof, which indicates a caller bug; there is +// no filesystem or network I/O. +func NewPlanner(proof *recovery.Proof) (*Planner, error) { + if proof == nil { + return nil, fmt.Errorf("proof cannot be nil") + } + + return &Planner{proof: proof}, nil +} + +// Proof returns the immutable recovery proof the planner is evaluating. +// A nil receiver returns nil so that defensive callers do not need to +// separately check `planner == nil` before reaching through for the proof. +func (p *Planner) Proof() *recovery.Proof { + if p == nil { + return nil + } + + return p.proof +} + +// State captures the durable, caller-owned progress needed to resume planning. +// The canonical serialization is TLV (see state_codec.go); no JSON tags are +// provided because go's default JSON marshaler for chainhash.Hash is +// parser-differential (accepts short forms and legacy arrays) and would admit +// key-collision attacks against a persisted state file. +type State struct { + // ConfirmedTxids lists proof txids the caller has observed + // confirmed. + ConfirmedTxids []chainhash.Hash + + // InFlightTxids lists proof txids currently being + // materialized by the caller but not yet observed confirmed. + InFlightTxids []chainhash.Hash + + // TargetConfirmHeight records the confirmation height of the target + // tx. Some once the target has confirmed, None while it is still + // pending. + TargetConfirmHeight fn.Option[int32] + + // Sweep records the final sweep lifecycle state. + Sweep SweepState +} + +// SweepStatus describes the caller-observed state of the final sweep. +type SweepStatus int + +const ( + // SweepStatusPending means the sweep has not been broadcast yet. + SweepStatusPending SweepStatus = iota + + // SweepStatusBroadcasted means the sweep was broadcast and is awaiting + // confirmation. + SweepStatusBroadcasted + + // SweepStatusConfirmed means the sweep confirmed on-chain. + SweepStatusConfirmed +) + +// String returns the stable debug label for a SweepStatus. +func (s SweepStatus) String() string { + switch s { + case SweepStatusPending: + return "pending" + + case SweepStatusBroadcasted: + return "broadcasted" + + case SweepStatusConfirmed: + return "confirmed" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} + +// SweepState captures the durable state of the final sweep. +type SweepState struct { + // Status records whether the sweep is pending, broadcasted, + // or confirmed. + Status SweepStatus + + // Txid records the sweep txid once broadcast. None while the sweep is + // still pending. + Txid fn.Option[chainhash.Hash] + + // ConfirmHeight records the sweep confirmation height once known. + // None until the sweep confirms. + ConfirmHeight fn.Option[int32] +} + +// Validate checks that the durable state is internally consistent with the +// immutable proof graph. +// +// Ordered so the cheapest checks run first: +// +// 1. Non-nil proof + state (cheap) +// 2. ConfirmedTxids and InFlightTxids convert cleanly to fn.Sets +// (catches duplicates inside each slice) +// 3. The two sets are disjoint (a tx cannot be both confirmed and +// in-flight) +// 4. Every txid references a real proof node +// 5. TargetConfirmHeight presence symmetry: set only when the target is +// confirmed, required when the target IS confirmed, non-negative. +// 6. Sweep state is internally consistent per its three-state lifecycle. +// 7. Topological invariant: every confirmed / in-flight node has all +// in-proof parents confirmed. This is the expensive check. +// +// The order matters only for fast-fail under adversarial inputs; a +// well-formed state passes every step without any measurable cost even +// at realistic proof sizes. +func (s *State) Validate(proof *recovery.Proof) error { + if proof == nil { + return fmt.Errorf("proof cannot be nil") + } + + if s == nil { + return fmt.Errorf("state cannot be nil") + } + + confirmed, err := hashSetFromSlice(s.ConfirmedTxids, "confirmed txids") + if err != nil { + return err + } + + inflight, err := hashSetFromSlice(s.InFlightTxids, "in-flight txids") + if err != nil { + return err + } + + if err := ensureDisjoint(confirmed, inflight); err != nil { + return err + } + + for txid := range confirmed { + if _, ok := proof.Node(txid); !ok { + return fmt.Errorf("confirmed txid %s is not "+ + "in proof", txid) + } + } + + for txid := range inflight { + if _, ok := proof.Node(txid); !ok { + return fmt.Errorf("in-flight txid %s is not"+ + " in proof", txid) + } + } + + targetConfirmed := confirmedTxidSetContains( + confirmed, proof.TargetOutpoint().Hash, + ) + if s.TargetConfirmHeight.IsSome() && !targetConfirmed { + return fmt.Errorf("target confirm height set " + + "without confirmed target") + } + if targetConfirmed && s.TargetConfirmHeight.IsNone() { + return fmt.Errorf("target confirmed without " + + "target confirm height") + } + confirmHeightErr := fn.MapOptionZ(s.TargetConfirmHeight, + func(h int32) error { + if h < 0 { + return fmt.Errorf("target confirm height "+ + "%d is negative", h) + } + + return nil + }, + ) + if confirmHeightErr != nil { + return confirmHeightErr + } + + err = validateSweepState( + s.Sweep, confirmed, proof, s.TargetConfirmHeight, + ) + if err != nil { + return err + } + + for txid := range confirmed { + err := ensureParentsConfirmed( + proof, confirmed, txid, + ) + if err != nil { + return err + } + } + + for txid := range inflight { + err := ensureParentsConfirmed( + proof, confirmed, txid, + ) + if err != nil { + return fmt.Errorf( + "in-flight tx %s: %w", txid, err, + ) + } + } + + return nil +} + +// Snapshot is the caller-facing planning view at one block height. +type Snapshot struct { + // Ready are transactions the caller should try to materialize next. + Ready []TxFrontier + + // InFlight are transactions already handed to the broadcaster and still + // awaiting confirmation. + InFlight []TxFrontier + + // Blocked are transactions that still have unmet in-proof dependencies. + Blocked []BlockedTx + + // TargetConfirmed is true once the target transaction itself + // is confirmed. + TargetConfirmed bool + + // TargetConfirmHeight is populated once the target confirms. + TargetConfirmHeight fn.Option[int32] + + // CSV is populated once the target confirms. + CSV fn.Option[CSVInfo] + + // AllProofConfirmed is true once every proof node is confirmed. + AllProofConfirmed bool + + // NeedSweep is true when the target is CSV-mature and the sweep has not + // yet been broadcast. + NeedSweep bool + + // Done is true once the final sweep confirms. + Done bool + + // Sweep carries the durable sweep state for the target. + Sweep SweepState +} + +// CSVInfo describes the target's CSV maturity state. +type CSVInfo struct { + // TargetConfirmHeight is the block height at which the target + // confirmed. + TargetConfirmHeight int32 + + // MaturityHeight is the block height at which the target becomes + // timeout-spendable. + MaturityHeight int32 + + // BlocksRemaining is how many blocks remain until maturity. + BlocksRemaining int32 + + // Ready is true once the current height is at or past maturity. + Ready bool +} + +// TxFrontier describes one proof transaction and the parents it depends on. +type TxFrontier struct { + // Txid is the proof transaction hash. + Txid chainhash.Hash + + // Node is the immutable proof node for this transaction. + Node *recovery.Node + + // Layer is the topological layer index within the proof graph. + Layer int + + // ParentTxids are the in-proof parents that must already be confirmed. + ParentTxids []chainhash.Hash +} + +// BlockedTx describes one proof transaction that is not yet ready. +type BlockedTx struct { + // TxFrontier contains the immutable proof node details. + TxFrontier + + // MissingParents are the in-proof parents that are still unconfirmed. + MissingParents []chainhash.Hash +} + +// Plan evaluates the proof at a given height and returns the current +// frontier. +// +// The algorithm walks the proof's precomputed topological layers in order +// (roots first, target last) and classifies each non-confirmed txid into +// exactly one bucket: +// +// - confirmed: skipped (nothing for the caller to do) +// - in flight: the caller told us it was handed to the broadcaster; we +// still surface it in the snapshot so the caller can track it to +// confirmation. +// - ready: every in-proof parent is confirmed — safe to broadcast. +// - blocked: has at least one unconfirmed parent; we include the +// list of missing parents so the caller knows what to wait for. +// +// After the layer walk we derive the post-materialization state: is the +// target confirmed, is the CSV delay mature, does the caller need to +// broadcast the final sweep, or is the sweep already done. +// +// Validation runs unconditionally on every call — the planner re-derives +// everything from first principles and refuses to operate on inconsistent +// state. This is slightly more expensive than caching a validation flag +// but makes crash recovery trivially correct. +func (p *Planner) Plan(height int32, state *State) (*Snapshot, error) { + if p == nil || p.proof == nil { + return nil, fmt.Errorf("planner proof cannot be nil") + } + + if state == nil { + return nil, fmt.Errorf("state cannot be nil") + } + + // Validate first. The planner never plans against a state that + // violates an invariant, because any downstream broadcast decisions + // would be incorrect by construction. + if err := state.Validate(p.proof); err != nil { + return nil, err + } + + confirmed, err := hashSetFromSlice( + state.ConfirmedTxids, "confirmed txids", + ) + if err != nil { + return nil, err + } + + inflight, err := hashSetFromSlice( + state.InFlightTxids, "in-flight txids", + ) + if err != nil { + return nil, err + } + + snapshot := &Snapshot{ + Sweep: copySweepState(state.Sweep), + TargetConfirmHeight: state.TargetConfirmHeight, + } + + layers := p.proof.Layers() + for layerIndex, layer := range layers { + for _, txid := range layer { + node, _ := p.proof.Node(txid) + parentTxids, err := p.proof.ParentTxids(txid) + if err != nil { + return nil, err + } + + if confirmed.Contains(txid) { + continue + } + + if inflight.Contains(txid) { + snapshot.InFlight = append( + snapshot.InFlight, TxFrontier{ + Txid: txid, + Node: node, + Layer: layerIndex, + ParentTxids: parentTxids, + }, + ) + + continue + } + + missingParents, err := missingParentsFromSet( + p.proof, confirmed, txid, + ) + if err != nil { + return nil, err + } + + if len(missingParents) == 0 { + snapshot.Ready = append( + snapshot.Ready, TxFrontier{ + Txid: txid, + Node: node, + Layer: layerIndex, + ParentTxids: parentTxids, + }, + ) + + continue + } + + snapshot.Blocked = append( + snapshot.Blocked, BlockedTx{ + TxFrontier: TxFrontier{ + Txid: txid, + Node: node, + Layer: layerIndex, + ParentTxids: parentTxids, + }, + MissingParents: missingParents, + }, + ) + } + } + + snapshot.TargetConfirmed = confirmedTxidSetContains( + confirmed, p.proof.TargetOutpoint().Hash, + ) + snapshot.AllProofConfirmed = allProofTxidsConfirmed( + p.proof, confirmed, + ) + + if snapshot.TargetConfirmed { + csv, err := csvInfoAt( + p.proof, height, state.TargetConfirmHeight, + ) + if err != nil { + return nil, err + } + + snapshot.CSV = fn.Some(csv) + snapshot.NeedSweep = csv.Ready && + snapshot.Sweep.Status == SweepStatusPending + } + + snapshot.Done = snapshot.Sweep.Status == SweepStatusConfirmed + + sortFrontier(snapshot.Ready) + sortFrontier(snapshot.InFlight) + sortBlocked(snapshot.Blocked) + + return snapshot, nil +} + +// csvInfoAt derives the target's CSV maturity view at the given block +// height. Uses int64 math (via recovery.ComputeMaturityHeight) to guarantee +// no silent int32 overflow even if the proof or state somehow carries +// values outside the usual recovery-package bounds. +// +// See recovery.ComputeMaturityHeight for the overflow reasoning; it is +// worth keeping the single overflow-safe implementation shared between +// packages so a fix to one side automatically benefits the other. +func csvInfoAt(proof *recovery.Proof, height int32, + targetConfirmHeight fn.Option[int32]) (CSVInfo, error) { + + confirmHeight, err := targetConfirmHeight.UnwrapOrErr( + fmt.Errorf("target confirm height cannot be nil"), + ) + if err != nil { + return CSVInfo{}, err + } + + maturityHeight, err := recovery.ComputeMaturityHeight( + confirmHeight, proof.CSVDelay(), + ) + if err != nil { + return CSVInfo{}, err + } + + blocksRemaining := maturityHeight - height + if blocksRemaining < 0 { + blocksRemaining = 0 + } + + return CSVInfo{ + TargetConfirmHeight: confirmHeight, + MaturityHeight: maturityHeight, + BlocksRemaining: blocksRemaining, + Ready: height >= maturityHeight, + }, nil +} + +// allProofTxidsConfirmed returns true iff every node in the proof graph is in +// the confirmed set. The planner walks the layers-ordered traversal rather +// than iterating the nodes map so the check short-circuits at the topmost +// unconfirmed layer, which is usually cheaper than a full scan. +func allProofTxidsConfirmed(proof *recovery.Proof, + confirmed fn.Set[chainhash.Hash]) bool { + + for _, layer := range proof.Layers() { + for _, txid := range layer { + if confirmed.Contains(txid) { + continue + } + + return false + } + } + + return true +} + +// missingParentsFromSet lists the parents of txid that are NOT yet confirmed. +// An empty return value means the tx is ready to broadcast. Results are +// sorted by raw byte order so Snapshot output is deterministic. +func missingParentsFromSet(proof *recovery.Proof, + confirmed fn.Set[chainhash.Hash], txid chainhash.Hash) ( + []chainhash.Hash, error) { + + parentTxids, err := proof.ParentTxids(txid) + if err != nil { + return nil, err + } + + missing := make([]chainhash.Hash, 0, len(parentTxids)) + for _, parent := range parentTxids { + if confirmed.Contains(parent) { + continue + } + + missing = append(missing, parent) + } + + sortHashes(missing) + + return missing, nil +} + +// ensureParentsConfirmed errors if txid has any unconfirmed parent. It is the +// topological invariant enforced by Validate: a "confirmed" or "in-flight" +// tx in the persisted state must not depend on a still-pending parent. +func ensureParentsConfirmed(proof *recovery.Proof, + confirmed fn.Set[chainhash.Hash], txid chainhash.Hash) error { + + missing, err := missingParentsFromSet(proof, confirmed, txid) + if err != nil { + return err + } + + if len(missing) == 0 { + return nil + } + + return fmt.Errorf("tx %s has unconfirmed parents %v", txid, missing) +} + +// validateSweepState checks that the durable sweep state is internally +// consistent with the confirmed set and the proof graph. +// +// # Sweep lifecycle model +// +// The sweep is the final transaction the caller broadcasts to convert +// the target outpoint into an address they control. Its three-state +// lifecycle is: +// +// Pending : nothing broadcast yet. Neither Txid nor ConfirmHeight +// is set. This is the only state where the planner will +// flip NeedSweep=true (once CSV matures). +// Broadcasted : the sweep was broadcast. Txid is set but +// ConfirmHeight is not. The target MUST be confirmed at +// this point; otherwise the sweep tx couldn't have been +// valid (it spends the target via a CSV-timelocked +// path). +// Confirmed : the sweep confirmed on-chain. Both Txid and +// ConfirmHeight are set. The target must be confirmed +// AND the sweep's confirm height must be at or past +// target_confirm_height + csv_delay. +// +// A Txid that collides with any proof node txid is rejected up front, +// regardless of status: a collision would leave the planner treating the +// same hash as both a confirmed proof node AND a sweep step, which is +// logically incoherent and masks genuine progress. +func validateSweepState(sweep SweepState, confirmed fn.Set[chainhash.Hash], + proof *recovery.Proof, targetConfirmHeight fn.Option[int32]) error { + + // A sweep Txid that collides with a proof node would leave the planner + // treating the same hash as both a confirmed proof node and a sweep + // step. Reject eagerly regardless of status. + collideErr := fn.MapOptionZ(sweep.Txid, + func(txid chainhash.Hash) error { + if _, collides := proof.Node(txid); collides { + return fmt.Errorf("sweep txid %s collides "+ + "with proof node", txid) + } + + return nil + }, + ) + if collideErr != nil { + return collideErr + } + + switch sweep.Status { + case SweepStatusPending: + if sweep.Txid.IsSome() { + return fmt.Errorf("pending sweep must not have a txid") + } + if sweep.ConfirmHeight.IsSome() { + return fmt.Errorf("pending sweep must not " + + "have a confirm height") + } + + case SweepStatusBroadcasted: + if sweep.Txid.IsNone() { + return fmt.Errorf("broadcasted sweep must have a txid") + } + if sweep.ConfirmHeight.IsSome() { + return fmt.Errorf("broadcasted sweep must " + + "not have a confirm height") + } + + // A broadcasted sweep is only reachable from a confirmed + // target: the sweep tx input is the target outpoint with a CSV + // delay, so the target must have matured before any valid + // broadcast could land in the mempool. Rejecting this state + // also prevents a tampered file from suppressing a legitimate + // sweep-ready signal. + if !confirmed.Contains(proof.TargetOutpoint().Hash) { + return fmt.Errorf("broadcasted sweep requires " + + "confirmed target") + } + + case SweepStatusConfirmed: + if sweep.Txid.IsNone() { + return fmt.Errorf("confirmed sweep must have a txid") + } + confirmHeight, err := sweep.ConfirmHeight.UnwrapOrErr( + fmt.Errorf("confirmed sweep must have a " + + "confirm height"), + ) + if err != nil { + return err + } + if confirmHeight < 0 { + return fmt.Errorf("sweep confirm height %d is "+ + "negative", confirmHeight) + } + + targetTxid := proof.TargetOutpoint().Hash + if !confirmed.Contains(targetTxid) { + return fmt.Errorf("confirmed sweep requires " + + "confirmed target") + } + + // A confirmed sweep tx could not have been mined before the + // target's CSV maturity: the sweep spends the target via a + // relative-timelocked path. If the persisted state claims + // otherwise, the file is either tampered or logically + // incoherent. + targetHeight, err := targetConfirmHeight.UnwrapOrErr( + fmt.Errorf("confirmed sweep requires target " + + "confirm height"), + ) + if err != nil { + return err + } + maturityHeight, err := recovery.ComputeMaturityHeight( + targetHeight, proof.CSVDelay(), + ) + if err != nil { + return fmt.Errorf("confirmed sweep: %w", err) + } + if confirmHeight < maturityHeight { + return fmt.Errorf("sweep confirmed at height %d "+ + "before csv maturity %d", + confirmHeight, maturityHeight) + } + + default: + return fmt.Errorf("unknown sweep status %d", sweep.Status) + } + + return nil +} + +// copySweepState returns a value-copy of the input. With fn.Option-valued +// fields the copy is already deep by virtue of the Option type wrapping the +// inner value, so the helper is now just a readability alias for the +// canonical "don't mutate the caller's SweepState" intent at call sites. +func copySweepState(s SweepState) SweepState { + return SweepState{ + Status: s.Status, + Txid: s.Txid, + ConfirmHeight: s.ConfirmHeight, + } +} + +// hashSetFromSlice copies a slice of txids into an fn.Set, rejecting any +// duplicate entry. Using fn.Set rather than a bare map[chainhash.Hash]struct{} +// lets the rest of the planner use idiomatic Contains/Diff/Intersect calls +// without reinventing those helpers per-caller. +func hashSetFromSlice(values []chainhash.Hash, + label string) (fn.Set[chainhash.Hash], error) { + + set := fn.NewSet[chainhash.Hash]() + for _, value := range values { + if set.Contains(value) { + return nil, fmt.Errorf("duplicate %s entry %s", label, + value) + } + + set.Add(value) + } + + return set, nil +} + +// ensureDisjoint verifies no txid appears in both the confirmed and the +// in-flight sets. The two states are mutually exclusive by definition: a tx +// is either confirmed on-chain or still in the broadcaster's queue, never +// both. A state that claims otherwise is either tampered or the caller has a +// state-machine bug. +func ensureDisjoint(a, b fn.Set[chainhash.Hash]) error { + overlap := a.Intersect(b) + if overlap.IsEmpty() { + return nil + } + + // Pick any offender for the error message; iteration order is not + // deterministic but the first collision is enough to identify the bug. + for txid := range overlap { + return fmt.Errorf("txid %s cannot be both confirmed and "+ + "in-flight", txid) + } + + return nil +} + +// confirmedTxidSetContains is preserved as a named helper (rather than an +// inline set.Contains call) because the planner reads much better when the +// "is this the confirmed target?" intent is spelled out at the call site. +func confirmedTxidSetContains(set fn.Set[chainhash.Hash], + txid chainhash.Hash) bool { + + return set.Contains(txid) +} + +// sortFrontier sorts transactions deterministically: ascending layer first, +// then by raw txid byte order within a layer. Raw-byte comparison avoids the +// per-comparison hex encoding that chainhash.Hash.String() performs. +func sortFrontier(frontier []TxFrontier) { + sort.Slice(frontier, func(i, j int) bool { + if frontier[i].Layer != frontier[j].Layer { + return frontier[i].Layer < frontier[j].Layer + } + + return bytes.Compare( + frontier[i].Txid[:], frontier[j].Txid[:], + ) < 0 + }) +} + +// sortBlocked sorts blocked transactions deterministically using the same +// (layer, txid) ordering as sortFrontier. +func sortBlocked(frontier []BlockedTx) { + sort.Slice(frontier, func(i, j int) bool { + if frontier[i].Layer != frontier[j].Layer { + return frontier[i].Layer < frontier[j].Layer + } + + return bytes.Compare( + frontier[i].Txid[:], frontier[j].Txid[:], + ) < 0 + }) +} + +// sortHashes sorts hashes deterministically by raw byte order. +func sortHashes(hashes []chainhash.Hash) { + sort.Slice(hashes, func(i, j int) bool { + return bytes.Compare(hashes[i][:], hashes[j][:]) < 0 + }) +} diff --git a/unrollplan/planner_guards_test.go b/unrollplan/planner_guards_test.go new file mode 100644 index 000000000..66a882e44 --- /dev/null +++ b/unrollplan/planner_guards_test.go @@ -0,0 +1,178 @@ +package unrollplan + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestSweepStatusString locks in the stable debug labels. +func TestSweepStatusString(t *testing.T) { + require.Equal(t, "pending", SweepStatusPending.String()) + require.Equal(t, "broadcasted", SweepStatusBroadcasted.String()) + require.Equal(t, "confirmed", SweepStatusConfirmed.String()) + require.Contains(t, SweepStatus(99).String(), "unknown") +} + +// TestPlannerProofAccessor verifies the receiver-nil path and the happy path. +func TestPlannerProofAccessor(t *testing.T) { + var nilPlanner *Planner + require.Nil(t, nilPlanner.Proof()) + + planner, proof := newPlannerFixture(t, linearProofFixture(t)) + require.Same(t, proof, planner.Proof()) +} + +// TestValidateSweepConfirmedRejectsEarlyHeight exercises the M-5 CSV-maturity +// invariant: a persisted state cannot claim the sweep confirmed before the +// target's csv delay has elapsed. +func TestValidateSweepConfirmedRejectsEarlyHeight(t *testing.T) { + planner, proof := newPlannerFixture(t, linearProofFixture(t)) + + confirmHeight := int32(100) + sweepTxid := hashFromLabel("sweep-early") + sweepHeight := int32(101) // CSV is 5, so 105 is the earliest valid. + _, err := planner.Plan(110, &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(confirmHeight), + Sweep: SweepState{ + Status: SweepStatusConfirmed, + Txid: fn.Some(sweepTxid), + ConfirmHeight: fn.Some(sweepHeight), + }, + }) + require.ErrorContains(t, err, "before csv maturity") +} + +// TestValidateSweepTxidCollision verifies the M-7 guard: the sweep txid +// cannot match any proof node's txid. +func TestValidateSweepTxidCollision(t *testing.T) { + planner, proof := newPlannerFixture(t, linearProofFixture(t)) + + // Use an existing proof node's txid as the sweep txid. + collision := proof.Layers()[0][0] + + _, err := planner.Plan(100, &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(int32(100)), + Sweep: SweepState{ + Status: SweepStatusBroadcasted, + Txid: fn.Some(collision), + }, + }) + require.ErrorContains(t, err, "collides with proof node") +} + +// TestValidateSweepBroadcastedRequiresConfirmedTarget exercises H-5. +func TestValidateSweepBroadcastedRequiresConfirmedTarget(t *testing.T) { + planner, _ := newPlannerFixture(t, linearProofFixture(t)) + + sweep := hashFromLabel("sweep-no-target") + _, err := planner.Plan(100, &State{ + Sweep: SweepState{ + Status: SweepStatusBroadcasted, + Txid: fn.Some(sweep), + }, + }) + require.ErrorContains(t, err, + "broadcasted sweep requires confirmed target") +} + +// TestValidateSweepNegativeHeightRejected covers the negative-height guard +// in validateSweepState for confirmed sweeps. +func TestValidateSweepNegativeHeightRejected(t *testing.T) { + planner, proof := newPlannerFixture(t, linearProofFixture(t)) + + sweep := hashFromLabel("sweep-neg") + _, err := planner.Plan(100, &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(int32(100)), + Sweep: SweepState{ + Status: SweepStatusConfirmed, + Txid: fn.Some(sweep), + ConfirmHeight: fn.Some(int32(-1)), + }, + }) + require.ErrorContains(t, err, "negative") +} + +// TestValidateSweepPendingWithTxidRejected exercises the SweepStatusPending +// arm of validateSweepState, which forbids a txid or confirm height on a +// pending sweep. +func TestValidateSweepPendingWithTxidRejected(t *testing.T) { + planner, _ := newPlannerFixture(t, linearProofFixture(t)) + + _, err := planner.Plan(100, &State{ + Sweep: SweepState{ + Status: SweepStatusPending, + Txid: fn.Some(hashFromLabel("sweep-pending")), + }, + }) + require.ErrorContains(t, err, "pending sweep must not have a txid") +} + +// TestStateValidateNegativeTargetHeight exercises the C-2 guard on +// TargetConfirmHeight. +func TestStateValidateNegativeTargetHeight(t *testing.T) { + _, proof := newPlannerFixture(t, linearProofFixture(t)) + state := &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(int32(-1)), + } + require.ErrorContains(t, state.Validate(proof), + "target confirm height") +} + +// TestCSVInfoRejectsMissingTargetHeight ensures the planner surfaces a clear +// error when the target is confirmed but a caller crafts the state without a +// target confirm height. +func TestCSVInfoRejectsMissingTargetHeight(t *testing.T) { + _, proof := newPlannerFixture(t, linearProofFixture(t)) + _, err := csvInfoAt(proof, 100, fn.None[int32]()) + require.Error(t, err) +} + +// TestSortBlockedDeterminism exercises the sortBlocked helper directly so its +// per-layer and per-txid ordering rules are covered even when Plan never +// produces enough blocked entries to trigger both code paths. +func TestSortBlockedDeterminism(t *testing.T) { + hashA := hashFromByte(1) + hashB := hashFromByte(2) + hashC := hashFromByte(3) + + _ = chainhash.Hash{} // keep import used + + tb := []BlockedTx{ + {TxFrontier: TxFrontier{Txid: hashC, Layer: 2}}, + {TxFrontier: TxFrontier{Txid: hashA, Layer: 1}}, + {TxFrontier: TxFrontier{Txid: hashB, Layer: 1}}, + } + + sortBlocked(tb) + require.Equal(t, hashA, tb[0].Txid) + require.Equal(t, hashB, tb[1].Txid) + require.Equal(t, hashC, tb[2].Txid) +} + +// TestProofAccessor verifies the Proof accessor returns the passed-in proof +// so callers can retrieve the immutable graph without a separate ref. +func TestProofAccessor(t *testing.T) { + planner, proof := newPlannerFixture(t, linearProofFixture(t)) + require.IsType(t, &recovery.Proof{}, proof) + require.Same(t, proof, planner.Proof()) +} diff --git a/unrollplan/planner_test.go b/unrollplan/planner_test.go new file mode 100644 index 000000000..0a7954f90 --- /dev/null +++ b/unrollplan/planner_test.go @@ -0,0 +1,405 @@ +package unrollplan + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/lib/recovery" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +func TestPlanLinearProof(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + snapshot, err := planner.Plan(100, &State{}) + require.NoError(t, err) + + require.Len(t, snapshot.Ready, 1) + require.Equal(t, proof.RootTxids()[0], snapshot.Ready[0].Txid) + require.Len(t, snapshot.Blocked, 1) + require.Equal(t, proof.TargetOutpoint().Hash, snapshot.Blocked[0].Txid) + require.False(t, snapshot.TargetConfirmed) + require.False(t, snapshot.AllProofConfirmed) + require.False(t, snapshot.NeedSweep) + require.False(t, snapshot.Done) +} + +func TestPlanMultiParentProof(t *testing.T) { + planner, proof := newPlannerFixture(t, + multiParentProofFixture(t), + ) + + snapshot, err := planner.Plan(100, &State{}) + require.NoError(t, err) + + require.Len(t, snapshot.Ready, 2) + require.Equal(t, proof.Layers()[0][0], snapshot.Ready[0].Txid) + require.Equal(t, proof.Layers()[0][1], snapshot.Ready[1].Txid) + require.Len(t, snapshot.Blocked, 1) + require.ElementsMatch(t, + []chainhash.Hash{ + proof.Layers()[0][0], + proof.Layers()[0][1], + }, + snapshot.Blocked[0].MissingParents, + ) + require.False(t, snapshot.AllProofConfirmed) +} + +func TestPlanPartialConfirmation(t *testing.T) { + planner, proof := newPlannerFixture(t, + threeLayerProofFixture(t), + ) + + root := proof.Layers()[0][0] + state := &State{ + ConfirmedTxids: []chainhash.Hash{root}, + } + + snapshot, err := planner.Plan(100, state) + require.NoError(t, err) + + require.Len(t, snapshot.Ready, 1) + require.Equal(t, proof.Layers()[1][0], snapshot.Ready[0].Txid) + require.Len(t, snapshot.InFlight, 0) + require.Len(t, snapshot.Blocked, 1) + require.Equal(t, proof.TargetOutpoint().Hash, snapshot.Blocked[0].Txid) + require.Equal(t, []chainhash.Hash{proof.Layers()[1][0]}, + snapshot.Blocked[0].MissingParents) + require.False(t, snapshot.AllProofConfirmed) +} + +func TestPlanTargetConfirmedCSVPending(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + confirmHeight := int32(100) + state := &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(confirmHeight), + } + + snapshot, err := planner.Plan(102, state) + require.NoError(t, err) + + require.True(t, snapshot.TargetConfirmed) + require.True(t, snapshot.AllProofConfirmed) + require.Equal(t, confirmHeight, + snapshot.TargetConfirmHeight.UnwrapOrFail(t)) + csv := snapshot.CSV.UnwrapOrFail(t) + require.False(t, csv.Ready) + require.Equal(t, int32(3), csv.BlocksRemaining) + require.False(t, snapshot.NeedSweep) + require.False(t, snapshot.Done) +} + +func TestPlanCSVReadyNeedsSweep(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + confirmHeight := int32(100) + state := &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(confirmHeight), + } + + snapshot, err := planner.Plan(105, state) + require.NoError(t, err) + + require.True(t, snapshot.TargetConfirmed) + require.True(t, snapshot.CSV.UnwrapOrFail(t).Ready) + require.True(t, snapshot.NeedSweep) + require.True(t, snapshot.AllProofConfirmed) + require.False(t, snapshot.Done) +} + +func TestPlanSweepBroadcastedNotNeedSweep(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + confirmHeight := int32(100) + sweepTxid := hashFromLabel("sweep") + state := &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(confirmHeight), + Sweep: SweepState{ + Status: SweepStatusBroadcasted, + Txid: fn.Some(sweepTxid), + }, + } + + snapshot, err := planner.Plan(105, state) + require.NoError(t, err) + + require.False(t, snapshot.NeedSweep) + require.False(t, snapshot.Done) + require.Equal(t, SweepStatusBroadcasted, snapshot.Sweep.Status) + require.Equal(t, sweepTxid, snapshot.Sweep.Txid.UnwrapOrFail(t)) + require.True(t, snapshot.AllProofConfirmed) +} + +func TestPlanSweepConfirmedDone(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + confirmHeight := int32(100) + sweepHeight := int32(106) + sweepTxid := hashFromLabel("sweep") + state := &State{ + ConfirmedTxids: []chainhash.Hash{ + proof.Layers()[0][0], + proof.TargetOutpoint().Hash, + }, + TargetConfirmHeight: fn.Some(confirmHeight), + Sweep: SweepState{ + Status: SweepStatusConfirmed, + Txid: fn.Some(sweepTxid), + ConfirmHeight: fn.Some(sweepHeight), + }, + } + + snapshot, err := planner.Plan(106, state) + require.NoError(t, err) + + require.True(t, snapshot.Done) + require.False(t, snapshot.NeedSweep) + require.Equal(t, SweepStatusConfirmed, snapshot.Sweep.Status) + require.Equal(t, sweepHeight, + snapshot.Sweep.ConfirmHeight.UnwrapOrFail(t)) + require.True(t, snapshot.AllProofConfirmed) +} + +func TestPlanInvalidStateDuplicateConfirmed(t *testing.T) { + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + duplicate := hashFromLabel("dup") + _, err := planner.Plan(100, &State{ + ConfirmedTxids: []chainhash.Hash{duplicate, duplicate}, + }) + require.ErrorContains(t, err, "duplicate confirmed txids") +} + +func TestPlanInvalidStateUnknownTxid(t *testing.T) { + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + _, err := planner.Plan(100, &State{ + ConfirmedTxids: []chainhash.Hash{hashFromLabel("unknown")}, + }) + require.ErrorContains(t, err, "is not in proof") +} + +func TestPlanInvalidStateTargetHeightWithoutTargetConfirmation(t *testing.T) { + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + confirmHeight := int32(100) + _, err := planner.Plan(100, &State{ + TargetConfirmHeight: fn.Some(confirmHeight), + }) + require.ErrorContains( + t, err, + "target confirm height set without confirmed target", + ) +} + +func TestPlanInvalidSweepState(t *testing.T) { + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + _, err := planner.Plan(100, &State{ + Sweep: SweepState{ + Status: SweepStatusBroadcasted, + }, + }) + require.ErrorContains(t, err, "broadcasted sweep must have a txid") +} + +func TestPlanInvalidStateDuplicateInFlight(t *testing.T) { + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + duplicate := hashFromLabel("dup-inflight") + _, err := planner.Plan(100, &State{ + InFlightTxids: []chainhash.Hash{duplicate, duplicate}, + }) + require.ErrorContains(t, err, "duplicate in-flight txids") +} + +func TestPlanInvalidStateOverlapConfirmedAndInFlight(t *testing.T) { + planner, proof := newPlannerFixture(t, + linearProofFixture(t), + ) + + root := proof.Layers()[0][0] + _, err := planner.Plan(100, &State{ + ConfirmedTxids: []chainhash.Hash{root}, + InFlightTxids: []chainhash.Hash{root}, + }) + require.ErrorContains(t, err, "cannot be both confirmed and in-flight") +} + +func TestPlanInvalidStateInFlightWithUnconfirmedParents(t *testing.T) { + planner, proof := newPlannerFixture(t, + threeLayerProofFixture(t), + ) + + middle := proof.Layers()[1][0] + _, err := planner.Plan(100, &State{ + InFlightTxids: []chainhash.Hash{middle}, + }) + require.ErrorContains(t, err, "in-flight tx") + require.ErrorContains(t, err, "unconfirmed parents") +} + +func TestPlannerRejectsNilInputs(t *testing.T) { + _, err := NewPlanner(nil) + require.Error(t, err) + + planner, _ := newPlannerFixture(t, + linearProofFixture(t), + ) + + _, err = planner.Plan(100, nil) + require.Error(t, err) +} + +func newPlannerFixture(t *testing.T, proof *recovery.Proof) (*Planner, + *recovery.Proof) { + + t.Helper() + + planner, err := NewPlanner(proof) + require.NoError(t, err) + + return planner, proof +} + +func linearProofFixture(t *testing.T) *recovery.Proof { + t.Helper() + + root := newTx(nil, 1, "root") + rootTxid := root.TxHash() + target := newTx([]wire.OutPoint{{ + Hash: rootTxid, + Index: 0, + }}, 1, "target") + + proof, err := recovery.NewProof( + wire.OutPoint{ + Hash: target.TxHash(), + Index: 0, + }, + 5, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: root}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: target}, + ) + require.NoError(t, err) + + return proof +} + +func multiParentProofFixture(t *testing.T) *recovery.Proof { + t.Helper() + + left := newTx(nil, 1, "left") + right := newTx(nil, 1, "right") + child := newTx([]wire.OutPoint{ + { + Hash: left.TxHash(), + Index: 0, + }, + { + Hash: right.TxHash(), + Index: 0, + }, + }, 1, "child") + + proof, err := recovery.NewProof( + wire.OutPoint{ + Hash: child.TxHash(), + Index: 0, + }, + 6, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: left}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: right}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: child}, + ) + require.NoError(t, err) + + return proof +} + +func threeLayerProofFixture(t *testing.T) *recovery.Proof { + t.Helper() + + root := newTx(nil, 1, "root") + middle := newTx([]wire.OutPoint{{ + Hash: root.TxHash(), + Index: 0, + }}, 1, "middle") + target := newTx([]wire.OutPoint{{ + Hash: middle.TxHash(), + Index: 0, + }}, 1, "target") + + proof, err := recovery.NewProof( + wire.OutPoint{ + Hash: target.TxHash(), + Index: 0, + }, + 7, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: root}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: middle}, + &recovery.Node{Kind: recovery.NodeKindTree, Tx: target}, + ) + require.NoError(t, err) + + return proof +} + +func newTx(inputs []wire.OutPoint, numOutputs int, label string) *wire.MsgTx { + tx := wire.NewMsgTx(2) + + for _, input := range inputs { + in := wire.NewTxIn(&input, nil, nil) + tx.AddTxIn(in) + } + + for i := 0; i < numOutputs; i++ { + tx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: []byte(fmt.Sprintf("%s-%d", label, i)), + }) + } + + return tx +} + +func hashFromLabel(label string) chainhash.Hash { + return chainhash.HashH([]byte(label)) +} diff --git a/unrollplan/state_codec.go b/unrollplan/state_codec.go new file mode 100644 index 000000000..929283851 --- /dev/null +++ b/unrollplan/state_codec.go @@ -0,0 +1,322 @@ +package unrollplan + +import ( + "bytes" + "encoding/binary" + "fmt" + "sort" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/tlv" +) + +// StateCodecVersion is the on-disk version byte for the unrollplan state +// codec. It lives in its own constant rather than being shared with the +// recovery-side codec because the two states are independent on the wire. +const StateCodecVersion uint8 = 1 + +const ( + // stateVersionRecordType carries the codec version byte. + stateVersionRecordType tlv.Type = 1 + + // stateConfirmedTxidsRecordType carries the confirmed-txid list. + stateConfirmedTxidsRecordType tlv.Type = 3 + + // stateInFlightTxidsRecordType carries the in-flight txid list. + stateInFlightTxidsRecordType tlv.Type = 5 + + // stateTargetConfirmHeightRecordType is optional; present only when + // TargetConfirmHeight is non-nil. + stateTargetConfirmHeightRecordType tlv.Type = 7 + + // stateSweepRecordType carries the nested sweep encoding. + stateSweepRecordType tlv.Type = 9 +) + +const ( + // sweepStatusRecordType carries the SweepStatus byte. + sweepStatusRecordType tlv.Type = 1 + + // sweepTxidRecordType is optional; present only when Txid is + // non-nil. + sweepTxidRecordType tlv.Type = 3 + + // sweepConfirmHeightRecordType is optional; present only when + // ConfirmHeight is non-nil. + sweepConfirmHeightRecordType tlv.Type = 5 +) + +// EncodeState serializes a State to a TLV byte slice. The returned bytes are +// deterministic for a given logical state: txid lists are sorted in ascending +// byte order and duplicate entries are rejected eagerly. +func EncodeState(state *State) ([]byte, error) { + if state == nil { + return nil, fmt.Errorf("state cannot be nil") + } + + version := StateCodecVersion + confirmed, err := encodeHashList(state.ConfirmedTxids, "confirmed") + if err != nil { + return nil, err + } + inflight, err := encodeHashList(state.InFlightTxids, "in-flight") + if err != nil { + return nil, err + } + sweep, err := encodeSweepState(state.Sweep) + if err != nil { + return nil, fmt.Errorf("encode sweep: %w", err) + } + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(stateVersionRecordType, &version), + tlv.MakePrimitiveRecord( + stateConfirmedTxidsRecordType, &confirmed, + ), + tlv.MakePrimitiveRecord( + stateInFlightTxidsRecordType, &inflight, + ), + } + + state.TargetConfirmHeight.WhenSome(func(h int32) { + height := uint32(h) + records = append(records, tlv.MakePrimitiveRecord( + stateTargetConfirmHeightRecordType, &height, + )) + }) + + records = append(records, tlv.MakePrimitiveRecord( + stateSweepRecordType, &sweep, + )) + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, fmt.Errorf("create state stream: %w", err) + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, fmt.Errorf("encode state: %w", err) + } + + return buf.Bytes(), nil +} + +// DecodeState parses a TLV-encoded State and rejects unknown codec versions. +func DecodeState(raw []byte) (*State, error) { + var ( + version uint8 + confirmedRaw []byte + inflightRaw []byte + targetConfirmHeight uint32 + sweepRaw []byte + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(stateVersionRecordType, &version), + tlv.MakePrimitiveRecord( + stateConfirmedTxidsRecordType, &confirmedRaw, + ), + tlv.MakePrimitiveRecord( + stateInFlightTxidsRecordType, &inflightRaw, + ), + tlv.MakePrimitiveRecord( + stateTargetConfirmHeightRecordType, + &targetConfirmHeight, + ), + tlv.MakePrimitiveRecord(stateSweepRecordType, &sweepRaw), + ) + if err != nil { + return nil, fmt.Errorf("create state stream: %w", err) + } + + parsed, err := stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + + if _, ok := parsed[stateVersionRecordType]; !ok { + return nil, fmt.Errorf("state missing version record") + } + if version != StateCodecVersion { + return nil, fmt.Errorf("unsupported state codec version %d "+ + "(expected %d)", version, StateCodecVersion) + } + + state := &State{} + + state.ConfirmedTxids, err = decodeHashList(confirmedRaw, "confirmed") + if err != nil { + return nil, err + } + + state.InFlightTxids, err = decodeHashList(inflightRaw, "in-flight") + if err != nil { + return nil, err + } + + if _, ok := parsed[stateTargetConfirmHeightRecordType]; ok { + state.TargetConfirmHeight = fn.Some(int32(targetConfirmHeight)) + } + + if _, ok := parsed[stateSweepRecordType]; ok { + sweep, err := decodeSweepState(sweepRaw) + if err != nil { + return nil, fmt.Errorf("decode sweep: %w", err) + } + state.Sweep = sweep + } + + return state, nil +} + +// encodeHashList serializes a slice of chainhash.Hash as a 4-byte big-endian +// count followed by sorted raw 32-byte hashes. Duplicate entries are rejected +// at encode time so the persisted file is never self-contradictory. +func encodeHashList(hashes []chainhash.Hash, label string) ([]byte, error) { + seen := make(map[chainhash.Hash]struct{}, len(hashes)) + for _, h := range hashes { + if _, ok := seen[h]; ok { + return nil, fmt.Errorf("duplicate %s txid %s", + label, h) + } + seen[h] = struct{}{} + } + + sorted := append([]chainhash.Hash(nil), hashes...) + sort.Slice(sorted, func(i, j int) bool { + return bytes.Compare(sorted[i][:], sorted[j][:]) < 0 + }) + + var buf bytes.Buffer + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(sorted))) + if _, err := buf.Write(lenBuf[:]); err != nil { + return nil, err + } + + for _, h := range sorted { + if _, err := buf.Write(h[:]); err != nil { + return nil, err + } + } + + return buf.Bytes(), nil +} + +// decodeHashList reverses encodeHashList and rejects duplicates again — a +// malformed or tampered blob could otherwise reintroduce the very collision +// the encoder guards against. +func decodeHashList(raw []byte, label string) ([]chainhash.Hash, error) { + if len(raw) < 4 { + return nil, fmt.Errorf("truncated %s txid list", label) + } + + count := binary.BigEndian.Uint32(raw[:4]) + raw = raw[4:] + + if uint64(len(raw)) != uint64(count)*chainhash.HashSize { + return nil, fmt.Errorf("%s txid list length mismatch: "+ + "count=%d payload=%d", label, count, len(raw)) + } + + out := make([]chainhash.Hash, 0, count) + seen := make(map[chainhash.Hash]struct{}, count) + for i := uint32(0); i < count; i++ { + var h chainhash.Hash + copy(h[:], raw[:chainhash.HashSize]) + raw = raw[chainhash.HashSize:] + + if _, ok := seen[h]; ok { + return nil, fmt.Errorf("duplicate %s txid %s", + label, h) + } + seen[h] = struct{}{} + + out = append(out, h) + } + + return out, nil +} + +// encodeSweepState serializes a SweepState as a TLV sub-stream. The sweep is +// nested rather than flattened into the outer state stream so a future codec +// change to the sweep shape does not require re-numbering the outer record +// types. +func encodeSweepState(sweep SweepState) ([]byte, error) { + status := uint8(sweep.Status) + + records := []tlv.Record{ + tlv.MakePrimitiveRecord(sweepStatusRecordType, &status), + } + + sweep.Txid.WhenSome(func(hash chainhash.Hash) { + txid := hash[:] + records = append(records, tlv.MakePrimitiveRecord( + sweepTxidRecordType, &txid, + )) + }) + + sweep.ConfirmHeight.WhenSome(func(h int32) { + height := uint32(h) + records = append(records, tlv.MakePrimitiveRecord( + sweepConfirmHeightRecordType, &height, + )) + }) + + stream, err := tlv.NewStream(records...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := stream.Encode(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// decodeSweepState reverses encodeSweepState. +func decodeSweepState(raw []byte) (SweepState, error) { + var ( + statusByte uint8 + txid []byte + confirmHeight uint32 + ) + + stream, err := tlv.NewStream( + tlv.MakePrimitiveRecord(sweepStatusRecordType, &statusByte), + tlv.MakePrimitiveRecord(sweepTxidRecordType, &txid), + tlv.MakePrimitiveRecord( + sweepConfirmHeightRecordType, &confirmHeight, + ), + ) + if err != nil { + return SweepState{}, err + } + + parsed, err := stream.DecodeWithParsedTypes(bytes.NewReader(raw)) + if err != nil { + return SweepState{}, err + } + + sweep := SweepState{Status: SweepStatus(statusByte)} + + if _, ok := parsed[sweepTxidRecordType]; ok { + if len(txid) != chainhash.HashSize { + return SweepState{}, fmt.Errorf("sweep txid length "+ + "%d invalid", len(txid)) + } + var hash chainhash.Hash + copy(hash[:], txid) + sweep.Txid = fn.Some(hash) + } + + if _, ok := parsed[sweepConfirmHeightRecordType]; ok { + sweep.ConfirmHeight = fn.Some(int32(confirmHeight)) + } + + return sweep, nil +} diff --git a/unrollplan/state_codec_test.go b/unrollplan/state_codec_test.go new file mode 100644 index 000000000..5105f2e3a --- /dev/null +++ b/unrollplan/state_codec_test.go @@ -0,0 +1,343 @@ +package unrollplan + +import ( + "bytes" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// TestEncodeStateNilRejected verifies the guard at the top of EncodeState. +func TestEncodeStateNilRejected(t *testing.T) { + _, err := EncodeState(nil) + require.ErrorContains(t, err, "state cannot be nil") +} + +// TestStateCodecRoundTrip exercises hand-built states across the three sweep +// statuses plus empty / target-only shapes. +func TestStateCodecRoundTrip(t *testing.T) { + h1 := hashFromByte(1) + h2 := hashFromByte(2) + h3 := hashFromByte(3) + sweep := hashFromByte(9) + + cases := []struct { + name string + state *State + }{ + { + name: "empty", + state: &State{}, + }, + { + name: "confirmed_without_sweep", + state: &State{ + ConfirmedTxids: []chainhash.Hash{h1, h2}, + InFlightTxids: []chainhash.Hash{h3}, + TargetConfirmHeight: fn.Some(int32( + 200, + )), + }, + }, + { + name: "sweep_broadcasted", + state: &State{ + ConfirmedTxids: []chainhash.Hash{h1, h2}, + Sweep: SweepState{ + Status: SweepStatusBroadcasted, + Txid: fn.Some(sweep), + }, + }, + }, + { + name: "sweep_confirmed", + state: &State{ + ConfirmedTxids: []chainhash.Hash{h1, h2}, + Sweep: SweepState{ + Status: SweepStatusConfirmed, + Txid: fn.Some(sweep), + ConfirmHeight: fn.Some(int32(210)), + }, + TargetConfirmHeight: fn.Some(int32(100)), + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + raw, err := EncodeState(tc.state) + require.NoError(t, err) + + decoded, err := DecodeState(raw) + require.NoError(t, err) + requireStateEqual(t, tc.state, decoded) + + raw2, err := EncodeState(decoded) + require.NoError(t, err) + require.True(t, bytes.Equal(raw, raw2), + "encoding must be canonical") + }) + } +} + +// TestStateCodecVersionMismatch verifies unknown versions are rejected. +func TestStateCodecVersionMismatch(t *testing.T) { + raw, err := EncodeState(&State{}) + require.NoError(t, err) + + // Version record is the first TLV; payload byte at offset 2. + require.GreaterOrEqual(t, len(raw), 3) + raw[2] = 99 + + _, err = DecodeState(raw) + require.ErrorContains(t, err, "unsupported state codec") +} + +// TestStateCodecDuplicateHashRejected covers the encoder's duplicate-input +// guard on both the confirmed and in-flight slices. +func TestStateCodecDuplicateHashRejected(t *testing.T) { + dup := hashFromByte(1) + _, err := EncodeState(&State{ + ConfirmedTxids: []chainhash.Hash{dup, dup}, + }) + require.ErrorContains(t, err, "duplicate confirmed") + + _, err = EncodeState(&State{ + InFlightTxids: []chainhash.Hash{dup, dup}, + }) + require.ErrorContains(t, err, "duplicate in-flight") +} + +// TestDecodeHashListRejectsShort exercises the truncation guard. +func TestDecodeHashListRejectsShort(t *testing.T) { + _, err := decodeHashList([]byte{0, 0}, "test") + require.ErrorContains(t, err, "truncated") + + only := hashFromByte(1) + bad := []byte{0, 0, 0, 2} + bad = append(bad, only[:]...) + _, err = decodeHashList(bad, "test") + require.ErrorContains(t, err, "length mismatch") +} + +// TestStateCodecRapidRoundTrip asserts that every logically-consistent State +// round-trips byte-for-byte through Encode/Decode. +func TestStateCodecRapidRoundTrip(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + state := drawState(t) + + raw, err := EncodeState(state) + if err != nil { + t.Fatalf("encode failed: %v", err) + } + + decoded, err := DecodeState(raw) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + + if !statesEqual(state, decoded) { + t.Fatalf("round-trip mismatch:\nwant %#v\ngot %#v", + state, decoded) + } + + raw2, err := EncodeState(decoded) + if err != nil { + t.Fatalf("re-encode failed: %v", err) + } + if !bytes.Equal(raw, raw2) { + t.Fatalf("canonical encoding violated") + } + }) +} + +// drawState builds a random, internally consistent State. +func drawState(t *rapid.T) *State { + state := &State{} + + numConfirmed := rapid.IntRange(0, 5).Draw(t, "numConfirmed") + confirmed := drawDistinctHashes( + t, numConfirmed, "confirmed", + ) + state.ConfirmedTxids = confirmed + + numInflight := rapid.IntRange(0, 5).Draw(t, "numInflight") + // In-flight hashes must be disjoint from confirmed ones to satisfy + // Validate; we pre-seed the disallowed set below. + used := make(map[chainhash.Hash]struct{}, len(confirmed)) + for _, h := range confirmed { + used[h] = struct{}{} + } + inflight := drawDistinctHashesExcluding( + t, numInflight, "inflight", used, + ) + state.InFlightTxids = inflight + + if rapid.Bool().Draw(t, "hasTargetHeight") { + state.TargetConfirmHeight = fn.Some(rapid.Int32Range( + 0, 1_000_000, + ).Draw(t, "targetHeight")) + } + + state.Sweep = drawSweepState(t) + + return state +} + +func drawSweepState(t *rapid.T) SweepState { + status := SweepStatus(rapid.IntRange( + int(SweepStatusPending), int(SweepStatusConfirmed), + ).Draw(t, "sweepStatus")) + + sweep := SweepState{Status: status} + + switch status { + case SweepStatusPending: + // Pending sweep: neither field is set. + + case SweepStatusBroadcasted: + sweep.Txid = fn.Some(drawHash(t, "sweepTxid")) + + case SweepStatusConfirmed: + sweep.Txid = fn.Some(drawHash(t, "sweepTxid")) + sweep.ConfirmHeight = fn.Some(rapid.Int32Range( + 0, 1_000_000, + ).Draw(t, "sweepHeight")) + } + + return sweep +} + +func drawDistinctHashes(t *rapid.T, n int, label string) []chainhash.Hash { + return drawDistinctHashesExcluding( + t, n, label, map[chainhash.Hash]struct{}{}, + ) +} + +func drawDistinctHashesExcluding(t *rapid.T, n int, label string, + used map[chainhash.Hash]struct{}) []chainhash.Hash { + + out := make([]chainhash.Hash, 0, n) + attempts := 0 + for len(out) < n && attempts < n*4 { + attempts++ + h := drawHash( + t, fmt.Sprintf("%s-%d", label, attempts), + ) + if _, dup := used[h]; dup { + continue + } + used[h] = struct{}{} + out = append(out, h) + } + + return out +} + +func drawHash(t *rapid.T, label string) chainhash.Hash { + bytesSlice := rapid.SliceOfN( + rapid.Byte(), chainhash.HashSize, chainhash.HashSize, + ).Draw(t, label) + var h chainhash.Hash + copy(h[:], bytesSlice) + + return h +} + +// statesEqual deeply compares two State values including fn.Option fields. +func statesEqual(a, b *State) bool { + if !hashSlicesEqualAsSet(a.ConfirmedTxids, b.ConfirmedTxids) { + return false + } + if !hashSlicesEqualAsSet(a.InFlightTxids, b.InFlightTxids) { + return false + } + if !optsEqual(a.TargetConfirmHeight, b.TargetConfirmHeight) { + return false + } + + return sweepStatesEqual(a.Sweep, b.Sweep) +} + +func sweepStatesEqual(a, b SweepState) bool { + if a.Status != b.Status { + return false + } + if !optsEqual(a.Txid, b.Txid) { + return false + } + + return optsEqual(a.ConfirmHeight, b.ConfirmHeight) +} + +func optsEqual[T comparable](a, b fn.Option[T]) bool { + if a.IsSome() != b.IsSome() { + return false + } + if a.IsNone() { + return true + } + + return a.UnsafeFromSome() == b.UnsafeFromSome() +} + +func hashSlicesEqualAsSet(a, b []chainhash.Hash) bool { + if len(a) != len(b) { + return false + } + seen := make(map[chainhash.Hash]int, len(a)) + for _, h := range a { + seen[h]++ + } + for _, h := range b { + seen[h]-- + } + for _, v := range seen { + if v != 0 { + return false + } + } + + return true +} + +// requireStateEqual compares two States and fails with a clear diagnostic. +func requireStateEqual(t *testing.T, want, got *State) { + t.Helper() + require.ElementsMatch(t, want.ConfirmedTxids, got.ConfirmedTxids) + require.ElementsMatch(t, want.InFlightTxids, got.InFlightTxids) + require.Equal(t, + want.TargetConfirmHeight.IsSome(), + got.TargetConfirmHeight.IsSome()) + if want.TargetConfirmHeight.IsSome() { + require.Equal(t, + want.TargetConfirmHeight.UnsafeFromSome(), + got.TargetConfirmHeight.UnsafeFromSome()) + } + require.Equal(t, want.Sweep.Status, got.Sweep.Status) + require.Equal(t, want.Sweep.Txid.IsSome(), got.Sweep.Txid.IsSome()) + if want.Sweep.Txid.IsSome() { + require.Equal(t, + want.Sweep.Txid.UnsafeFromSome(), + got.Sweep.Txid.UnsafeFromSome()) + } + require.Equal(t, + want.Sweep.ConfirmHeight.IsSome(), + got.Sweep.ConfirmHeight.IsSome()) + if want.Sweep.ConfirmHeight.IsSome() { + require.Equal(t, + want.Sweep.ConfirmHeight.UnsafeFromSome(), + got.Sweep.ConfirmHeight.UnsafeFromSome()) + } +} + +func hashFromByte(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + return h +}