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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions batchcanon/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# batchcanon

## Purpose

Client-side **batch canonicality data model** for the reorg-safety epic
(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch
(commitment) transaction is faring against the best chain: its canonicality
state, current confirmation observation, recompute inputs for effective
expiry, the inputs it consumes, the VTXOs it anchors, and the reverse
dependencies needed to restore a provisionally consumed VTXO.

This package is **data + query/update interface only**. It contains no
interpretation, no chain watching, and no admission behavior — those belong to
the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model
in its own package, separate from `chainsource` (raw observation) and `vtxo`
(admission), preserves the epic's observation → interpretation → action split.

## Key Types

- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`,
`StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`,
`StateConflictFinalized`. Reorg-reversible; **no state is a terminal
verdict** at this layer. Persisted as an append-only typed INTEGER column —
values must never be renumbered.
- `PolicyState` — reserved policy classification slot (`PolicyStateDefault`
only); persisted and round-tripped, no business meaning yet.
- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**,
never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute
only. `EffectiveExpiry()` derives the absolute expiry as
`ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed —
the structural guarantee that expiry is recomputed on every
reconfirmation rather than frozen.
- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer
batch) enabling VTXO restore if a consumer batch never becomes canonical.
- `Store` — behavior-free durable query/update interface. Implemented by
`db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled
from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`.

## Relationships

- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only.
- **Depended on by**: `db` (concrete store), and — in later tasks — the
batch canonicality manager and `vtxo` admission.

## Invariants

- Identity is by txid / outpoint, never by `(txid, block hash)`.
- Expiry is never persisted as a standalone or terminal value; it is always
derived from `CSVExpiryDelta` + the current confirmation observation.
- State enum integer values are append-only (persisted column).

## Expiry-as-terminal audit (darepo#454 C2)

C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a
one-way terminal fact. These are flagged for rework when the
BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto
`Record.EffectiveExpiry()`; **no behavior is changed by C2**:

- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable:
false}`, and the Critical/Expired escalations) — the primary offender: a
reorg that lowers the confirmation height could otherwise push a VTXO
permanently into non-recoverable `Failed`.
- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the
frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable)
expiry instead.
- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`.
- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a
most-restrictive absolute `batchExpiry`.
- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset",
not terminal; benign, documented for completeness.

## Deep Docs

- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map.
</content>
75 changes: 75 additions & 0 deletions batchcanon/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# batchcanon

## Purpose

Client-side **batch canonicality data model** for the reorg-safety epic
(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch
(commitment) transaction is faring against the best chain: its canonicality
state, current confirmation observation, recompute inputs for effective
expiry, the inputs it consumes, the VTXOs it anchors, and the reverse
dependencies needed to restore a provisionally consumed VTXO.

This package is **data + query/update interface only**. It contains no
interpretation, no chain watching, and no admission behavior — those belong to
the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model
in its own package, separate from `chainsource` (raw observation) and `vtxo`
(admission), preserves the epic's observation → interpretation → action split.

## Key Types

- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`,
`StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`,
`StateConflictFinalized`. Reorg-reversible; **no state is a terminal
verdict** at this layer. Persisted as an append-only typed INTEGER column —
values must never be renumbered.
- `PolicyState` — reserved policy classification slot (`PolicyStateDefault`
only); persisted and round-tripped, no business meaning yet.
- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**,
never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute
only. `EffectiveExpiry()` derives the absolute expiry as
`ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed —
the structural guarantee that expiry is recomputed on every
reconfirmation rather than frozen.
- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer
batch) enabling VTXO restore if a consumer batch never becomes canonical.
- `Store` — behavior-free durable query/update interface. Implemented by
`db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled
from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`.

## Relationships

- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only.
- **Depended on by**: `db` (concrete store), and — in later tasks — the
batch canonicality manager and `vtxo` admission.

## Invariants

- Identity is by txid / outpoint, never by `(txid, block hash)`.
- Expiry is never persisted as a standalone or terminal value; it is always
derived from `CSVExpiryDelta` + the current confirmation observation.
- State enum integer values are append-only (persisted column).

## Expiry-as-terminal audit (darepo#454 C2)

C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a
one-way terminal fact. These are flagged for rework when the
BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto
`Record.EffectiveExpiry()`; **no behavior is changed by C2**:

- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable:
false}`, and the Critical/Expired escalations) — the primary offender: a
reorg that lowers the confirmation height could otherwise push a VTXO
permanently into non-recoverable `Failed`.
- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the
frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable)
expiry instead.
- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`.
- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a
most-restrictive absolute `batchExpiry`.
- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset",
not terminal; benign, documented for completeness.

## Deep Docs

- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map.
</content>
26 changes: 26 additions & 0 deletions batchcanon/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Package batchcanon holds the client-side batch canonicality data model:
// the durable record of how each batch (commitment) transaction is faring
// against the best chain, the inputs it consumes, the VTXOs it anchors, and
// the reverse-dependency edges needed to restore a provisionally consumed
// VTXO if its consumer batch never becomes canonical.
//
// This package is the data substrate for the reorg-safety epic
// (darepo#454). It deliberately contains NO interpretation or admission
// behavior: it persists and retrieves observations only. The
// BatchCanonicalityManager (a later task) is the sole interpreter that
// drives state transitions from chainsource observations, and the VTXO
// manager remains the admission boundary. Keeping the model here, separate
// from both chainsource (raw observation) and vtxo (admission), preserves
// the observation -> interpretation -> action split the epic mandates.
//
// Two principles shape the model:
//
// - Identity is by txid / outpoint, never by (txid, block hash). A reorg
// that re-mines the same batch tx in a different block is the SAME
// batch; the block hash is only an observation attribute.
//
// - Expiry is never stored as a terminal fact. The model stores a
// CSV-relative delta plus the current confirmation height and derives
// the effective (absolute) expiry on demand, so a reorg-and-reconfirm
// at a new height recomputes expiry instead of freezing it.
package batchcanon
82 changes: 82 additions & 0 deletions batchcanon/record.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package batchcanon

import (
"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/wire/v2"
fn "github.com/lightningnetwork/lnd/fn/v2"
)

// Record is the durable canonicality view of one batch (commitment)
// transaction, keyed by its txid. It bundles the interpreted State, the
// current confirmation observation, the recompute inputs for effective
// expiry, the consumed inputs and dependent VTXOs, and the reserved policy
// slot.
type Record struct {
// BatchTxID is the commitment transaction id and the record's
// identity. Identity is by txid, never by (txid, block hash): a reorg
// that re-mines the same tx in a different block is the same batch.
BatchTxID chainhash.Hash

// State is the interpreted canonicality state.
State State

// ConfirmationHeight is the best-chain height at which the batch tx
// is currently observed confirmed. None when the batch is not
// currently confirmed (unseen or reorged out). A reorg clears it; a
// reconfirmation sets it to the new height.
ConfirmationHeight fn.Option[int32]

// ConfirmationBlock is the hash of the block currently confirming the
// batch tx. It is an observation attribute only and is NOT part of
// the batch identity. None when the batch is not currently confirmed.
ConfirmationBlock fn.Option[chainhash.Hash]

// CSVExpiryDelta is the batch's CSV-relative expiry timeout, in
// blocks. The effective (absolute) expiry height is derived from this
// plus the current confirmation height, so it tracks reconfirmations
// after a reorg instead of being frozen at first confirmation.
CSVExpiryDelta int32

// PolicyState is the reserved policy classification slot. See
// PolicyState.
PolicyState PolicyState

// ConsumedInputs are the outpoints this batch tx spends. They are
// tracked so the manager can watch each one for a conflicting spend.
ConsumedInputs []wire.OutPoint

// DependentVTXOs are the VTXO outpoints anchored by this batch. Their
// derived availability follows this batch's canonicality.
DependentVTXOs []wire.OutPoint
}

// EffectiveExpiry derives the absolute expiry height from the current
// confirmation observation: ConfirmationHeight + CSVExpiryDelta. It returns
// None when the batch is not currently confirmed.
//
// Deriving expiry on demand (rather than persisting an absolute height) is
// what keeps expiry reorg-safe: a confirmation that is reorged out clears
// ConfirmationHeight and so erases the effective expiry, and a
// reconfirmation at a different height yields a fresh effective expiry.
// Expiry is therefore never a one-way terminal fact at this layer.
func (r *Record) EffectiveExpiry() fn.Option[int32] {
return fn.MapOption(
func(height int32) int32 {
return height + r.CSVExpiryDelta
})(r.ConfirmationHeight)
}
Comment on lines +62 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Defensive Programming: Missing Nil Receiver Check\n\nIf EffectiveExpiry is called on a nil *Record pointer (for example, if a caller fails to check an error or retrieves a nil record), it will trigger a runtime panic. Adding a defensive nil check on the receiver prevents this.

func (r *Record) EffectiveExpiry() fn.Option[int32] {\n\tif r == nil {\n\t	return fn.None[int32]()\n\t}\n\treturn fn.MapOption(\n\t\tfunc(height int32) int32 {\n\t\t	return height + r.CSVExpiryDelta\n\t\t})(r.ConfirmationHeight)\n}


// ProvisionalConsumer records that a (locally relevant) VTXO has been
// provisionally consumed by a not-yet-canonical consumer batch. It is the
// reverse-dependency edge that lets a provisionally consumed VTXO be restored
// if the consumer batch never becomes canonical — for example a round-2
// forfeit whose commitment tx is reorged out, which must restore the round-1
// VTXO it consumed.
type ProvisionalConsumer struct {
// ConsumedVTXO is the outpoint of the VTXO consumed by ConsumerBatch.
ConsumedVTXO wire.OutPoint

// ConsumerBatch is the batch tx that provisionally consumes
// ConsumedVTXO.
ConsumerBatch chainhash.Hash
}
70 changes: 70 additions & 0 deletions batchcanon/record_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package batchcanon

import (
"testing"

"github.com/btcsuite/btcd/chainhash/v2"
fn "github.com/lightningnetwork/lnd/fn/v2"
"github.com/stretchr/testify/require"
)

// TestEffectiveExpiryNoneWhenUnconfirmed verifies that a batch with no
// current confirmation observation has no effective expiry — the structural
// guarantee that expiry is not a standalone terminal fact.
func TestEffectiveExpiryNoneWhenUnconfirmed(t *testing.T) {
t.Parallel()

rec := &Record{
BatchTxID: chainhash.Hash{
0x01,
},
State: StateUnseen,
ConfirmationHeight: fn.None[int32](),
CSVExpiryDelta: 144,
}

require.True(t, rec.EffectiveExpiry().IsNone())
}

// TestEffectiveExpiryDerivesFromConfirmation verifies the effective expiry is
// the confirmation height plus the CSV-relative delta.
func TestEffectiveExpiryDerivesFromConfirmation(t *testing.T) {
t.Parallel()

rec := &Record{
BatchTxID: chainhash.Hash{
0x02,
},
State: StateProvisional,
ConfirmationHeight: fn.Some[int32](100),
CSVExpiryDelta: 144,
}

got := rec.EffectiveExpiry()
require.True(t, got.IsSome())
require.Equal(t, int32(244), got.UnwrapOr(0))
}

// TestEffectiveExpiryRecomputesAfterReconfirm verifies that re-confirming the
// same batch at a different height (as happens after a reorg) yields a fresh
// effective expiry rather than a value frozen at first confirmation.
func TestEffectiveExpiryRecomputesAfterReconfirm(t *testing.T) {
t.Parallel()

rec := &Record{
BatchTxID: chainhash.Hash{
0x03,
},
ConfirmationHeight: fn.Some[int32](100),
CSVExpiryDelta: 144,
}
require.Equal(t, int32(244), rec.EffectiveExpiry().UnwrapOr(0))

// Reorg: the confirmation leaves the best chain.
rec.ConfirmationHeight = fn.None[int32]()
require.True(t, rec.EffectiveExpiry().IsNone())

// Reconfirmation at a higher height on the new best chain.
rec.ConfirmationHeight = fn.Some[int32](103)
require.Equal(t, int32(247), rec.EffectiveExpiry().UnwrapOr(0))
}
Loading
Loading