From 20ba7b6521f7e7585eda3e86b4d5e1a7d1d470db Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 17:33:22 -0700 Subject: [PATCH 01/16] db: Add batch canonicality schema and generated queries Add the durable schema for the batch canonicality authority: the batch record, its consumed inputs, dependent VTXOs, consumer edges, observation facts, and the registration/readiness columns, plus the generated sqlc queries. A business_revision column on the VTXO table records the exact lifecycle revision a forfeiture installs, so the later conditional restore can compare-and-swap against it. No behaviour yet; this is the storage substrate the authority and the VTXO admission gate build on. --- db/sqlc/batch_canonicality.sql.go | 938 ++++++++++++++++++ .../000016_batch_canonicality.down.sql | 8 + .../000016_batch_canonicality.up.sql | 225 +++++ db/sqlc/models.go | 51 + db/sqlc/querier.go | 94 ++ db/sqlc/queries/batch_canonicality.sql | 310 ++++++ db/sqlc/queries/round.sql | 3 +- db/sqlc/queries/vtxo.sql | 10 +- db/sqlc/round.sql.go | 19 +- db/sqlc/schemas/generated_schema.sql | 187 +++- db/sqlc/vtxo.sql.go | 36 +- 11 files changed, 1859 insertions(+), 22 deletions(-) create mode 100644 db/sqlc/batch_canonicality.sql.go create mode 100644 db/sqlc/migrations/000016_batch_canonicality.down.sql create mode 100644 db/sqlc/migrations/000016_batch_canonicality.up.sql create mode 100644 db/sqlc/queries/batch_canonicality.sql diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go new file mode 100644 index 000000000..5f411c8da --- /dev/null +++ b/db/sqlc/batch_canonicality.sql.go @@ -0,0 +1,938 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: batch_canonicality.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const ApplyBatchCanonicalityObservation = `-- name: ApplyBatchCanonicalityObservation :execrows +UPDATE batch_canonicality +SET state = $3, + confirmation_height = $4, + confirmation_block_hash = $5, + registration_stage = CASE + WHEN COALESCE($6, CAST(-1 AS BIGINT)) = CAST(-1 AS BIGINT) + THEN registration_stage + ELSE 2 + END, + ready_generation = $6, + revision = revision + 1, + updated_at = $7 +WHERE batch_txid = $1 AND observation_generation = $2 + AND registration_stage != 3 +` + +type ApplyBatchCanonicalityObservationParams struct { + BatchTxid []byte + ObservationGeneration int64 + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + ReadyGeneration sql.NullInt64 + UpdatedAt int64 +} + +// ApplyBatchCanonicalityObservation atomically installs the batch-level part +// of one complete observation snapshot. The caller updates every input in the +// same SQL transaction before this generation-guarded write. +func (q *Queries) ApplyBatchCanonicalityObservation(ctx context.Context, arg ApplyBatchCanonicalityObservationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, ApplyBatchCanonicalityObservation, + arg.BatchTxid, + arg.ObservationGeneration, + arg.State, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.ReadyGeneration, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const BeginBatchCanonicalityReconcile = `-- name: BeginBatchCanonicalityReconcile :one +UPDATE batch_canonicality +SET registration_stage = 1, + observation_generation = observation_generation + 1, + ready_generation = NULL, + revision = revision + 1, + updated_at = $2 +WHERE batch_txid = $1 +RETURNING batch_txid, batch_tx, batch_output_index, state, + registration_stage, observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +` + +type BeginBatchCanonicalityReconcileParams struct { + BatchTxid []byte + UpdatedAt int64 +} + +// BeginBatchCanonicalityReconcile closes admission and starts a fresh +// observation generation before any watch is armed. +func (q *Queries) BeginBatchCanonicalityReconcile(ctx context.Context, arg BeginBatchCanonicalityReconcileParams) (BatchCanonicality, error) { + row := q.db.QueryRowContext(ctx, BeginBatchCanonicalityReconcile, arg.BatchTxid, arg.UpdatedAt) + var i BatchCanonicality + err := row.Scan( + &i.BatchTxid, + &i.BatchTx, + &i.BatchOutputIndex, + &i.State, + &i.RegistrationStage, + &i.ObservationGeneration, + &i.ReadyGeneration, + &i.Revision, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + &i.ConfirmationPkScript, + ) + return i, err +} + +const ClearBatchConfirmation = `-- name: ClearBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1 +` + +type ClearBatchConfirmationParams struct { + BatchTxid []byte + UpdatedAt int64 +} + +// ClearBatchConfirmation nulls the confirmation observation, reflecting that +// the confirming block left the best chain. It sets no terminal flag. +func (q *Queries) ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, ClearBatchConfirmation, arg.BatchTxid, arg.UpdatedAt) + return err +} + +const DeleteBatchConsumedInputs = `-- name: DeleteBatchConsumedInputs :exec +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1 +` + +// DeleteBatchConsumedInputs removes every consumed-input row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchConsumedInputs, batchTxid) + return err +} + +const DeleteBatchDependentVTXOs = `-- name: DeleteBatchDependentVTXOs :exec +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1 +` + +// DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchDependentVTXOs, batchTxid) + return err +} + +const DeleteProvisionalConsumer = `-- name: DeleteProvisionalConsumer :execrows +DELETE FROM batch_provisional_consumers +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3 + AND expected_vtxo_revision = $4 +` + +type DeleteProvisionalConsumerParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + ExpectedVtxoRevision int64 +} + +// DeleteProvisionalConsumer completes one exact edge. Its normalized creator +// lineage cascades with it. +func (q *Queries) DeleteProvisionalConsumer(ctx context.Context, arg DeleteProvisionalConsumerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, DeleteProvisionalConsumer, + arg.ConsumedVtxoHash, + arg.ConsumedVtxoIndex, + arg.ConsumerBatchTxid, + arg.ExpectedVtxoRevision, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const DeleteProvisionalConsumersForBatch = `-- name: DeleteProvisionalConsumersForBatch :exec +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1 +` + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +// for the given consumer batch. +func (q *Queries) DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteProvisionalConsumersForBatch, consumerBatchTxid) + return err +} + +const FindBatchesByConsumedOutpoint = `-- name: FindBatchesByConsumedOutpoint :many +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2 +` + +type FindBatchesByConsumedOutpointParams struct { + InputHash []byte + InputIndex int32 +} + +// FindBatchesByConsumedOutpoint returns the txids of every batch that +// consumes the given outpoint. +func (q *Queries) FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, FindBatchesByConsumedOutpoint, arg.InputHash, arg.InputIndex) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var batch_txid []byte + if err := rows.Scan(&batch_txid); err != nil { + return nil, err + } + items = append(items, batch_txid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetBatchCanonicality = `-- name: GetBatchCanonicality :one +SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +FROM batch_canonicality +WHERE batch_txid = $1 +` + +// GetBatchCanonicality returns the canonicality row for a batch txid. The +// column order matches the table so sqlc reuses the BatchCanonicality model. +func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) { + row := q.db.QueryRowContext(ctx, GetBatchCanonicality, batchTxid) + var i BatchCanonicality + err := row.Scan( + &i.BatchTxid, + &i.BatchTx, + &i.BatchOutputIndex, + &i.State, + &i.RegistrationStage, + &i.ObservationGeneration, + &i.ReadyGeneration, + &i.Revision, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + &i.ConfirmationPkScript, + ) + return i, err +} + +const GetProvisionalConsumer = `-- name: GetProvisionalConsumer :one +SELECT expected_vtxo_revision +FROM batch_provisional_consumers +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3 +` + +type GetProvisionalConsumerParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte +} + +// GetProvisionalConsumer returns the immutable expected business revision of +// one edge so repeat registration can reject contradictory evidence. +func (q *Queries) GetProvisionalConsumer(ctx context.Context, arg GetProvisionalConsumerParams) (int64, error) { + row := q.db.QueryRowContext(ctx, GetProvisionalConsumer, arg.ConsumedVtxoHash, arg.ConsumedVtxoIndex, arg.ConsumerBatchTxid) + var expected_vtxo_revision int64 + err := row.Scan(&expected_vtxo_revision) + return expected_vtxo_revision, err +} + +const InsertBatchConsumedInput = `-- name: InsertBatchConsumedInput :exec +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_value, input_pk_script +) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING +` + +type InsertBatchConsumedInputParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputValue int64 + InputPkScript []byte +} + +// InsertBatchConsumedInput records one input consumed by a batch, together +// with the pkScript of the spent output (needed to register the spend watch). +func (q *Queries) InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.InputValue, + arg.InputPkScript, + ) + return err +} + +const InsertBatchDependentVTXO = `-- name: InsertBatchDependentVTXO :exec +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING +` + +type InsertBatchDependentVTXOParams struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +func (q *Queries) InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchDependentVTXO, arg.BatchTxid, arg.VtxoOutpointHash, arg.VtxoOutpointIndex) + return err +} + +const InsertConsumerCreatorLineage = `-- name: InsertConsumerCreatorLineage :exec +INSERT INTO batch_consumer_creator_lineage ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid +) DO NOTHING +` + +type InsertConsumerCreatorLineageParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatorBatchTxid []byte +} + +func (q *Queries) InsertConsumerCreatorLineage(ctx context.Context, arg InsertConsumerCreatorLineageParams) error { + _, err := q.db.ExecContext(ctx, InsertConsumerCreatorLineage, + arg.ConsumedVtxoHash, + arg.ConsumedVtxoIndex, + arg.ConsumerBatchTxid, + arg.CreatorBatchTxid, + ) + return err +} + +const InsertProvisionalConsumer = `-- name: InsertProvisionalConsumer :exec +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + expected_vtxo_revision, created_at +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING +` + +type InsertProvisionalConsumerParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + ExpectedVtxoRevision int64 + CreatedAt int64 +} + +// InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +// is provisionally consumed by consumer_batch. Idempotent. +func (q *Queries) InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error { + _, err := q.db.ExecContext(ctx, InsertProvisionalConsumer, + arg.ConsumedVtxoHash, + arg.ConsumedVtxoIndex, + arg.ConsumerBatchTxid, + arg.ExpectedVtxoRevision, + arg.CreatedAt, + ) + return err +} + +const ListBatchCanonicalityByState = `-- name: ListBatchCanonicalityByState :many +SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +FROM batch_canonicality +WHERE state = $1 +` + +// ListBatchCanonicalityByState returns every batch currently in the given +// state. +func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) { + rows, err := q.db.QueryContext(ctx, ListBatchCanonicalityByState, state) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BatchCanonicality + for rows.Next() { + var i BatchCanonicality + if err := rows.Scan( + &i.BatchTxid, + &i.BatchTx, + &i.BatchOutputIndex, + &i.State, + &i.RegistrationStage, + &i.ObservationGeneration, + &i.ReadyGeneration, + &i.Revision, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + &i.ConfirmationPkScript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many +SELECT input_hash, input_index, input_value, input_pk_script, conflicting, + conflict_final +FROM batch_consumed_inputs +WHERE batch_txid = $1 +` + +type ListBatchConsumedInputsRow struct { + InputHash []byte + InputIndex int32 + InputValue int64 + InputPkScript []byte + Conflicting int32 + ConflictFinal int32 +} + +// ListBatchConsumedInputs returns the inputs a batch consumes, with the +// pkScript of each spent output and its persisted conflict observation. +func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchConsumedInputsRow + for rows.Next() { + var i ListBatchConsumedInputsRow + if err := rows.Scan( + &i.InputHash, + &i.InputIndex, + &i.InputValue, + &i.InputPkScript, + &i.Conflicting, + &i.ConflictFinal, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchDependentVTXOs = `-- name: ListBatchDependentVTXOs :many +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1 +` + +type ListBatchDependentVTXOsRow struct { + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +func (q *Queries) ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchDependentVTXOs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchDependentVTXOsRow + for rows.Next() { + var i ListBatchDependentVTXOsRow + if err := rows.Scan(&i.VtxoOutpointHash, &i.VtxoOutpointIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListConsumerCreatorLineage = `-- name: ListConsumerCreatorLineage :many +SELECT creator_batch_txid +FROM batch_consumer_creator_lineage +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3 +ORDER BY creator_batch_txid +` + +type ListConsumerCreatorLineageParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte +} + +func (q *Queries) ListConsumerCreatorLineage(ctx context.Context, arg ListConsumerCreatorLineageParams) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, ListConsumerCreatorLineage, arg.ConsumedVtxoHash, arg.ConsumedVtxoIndex, arg.ConsumerBatchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var creator_batch_txid []byte + if err := rows.Scan(&creator_batch_txid); err != nil { + return nil, err + } + items = append(items, creator_batch_txid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListPendingConsumerBatchesByCreator = `-- name: ListPendingConsumerBatchesByCreator :many +SELECT DISTINCT consumer_batch_txid +FROM batch_consumer_creator_lineage +WHERE creator_batch_txid = $1 +ORDER BY consumer_batch_txid +` + +// ListPendingConsumerBatchesByCreator targets durable restore checkpoints +// whose creator-lineage decision may change when this batch changes state. +func (q *Queries) ListPendingConsumerBatchesByCreator(ctx context.Context, creatorBatchTxid []byte) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, ListPendingConsumerBatchesByCreator, creatorBatchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var consumer_batch_txid []byte + if err := rows.Scan(&consumer_batch_txid); err != nil { + return nil, err + } + items = append(items, consumer_batch_txid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListProvisionalConsumersForBatch = `-- name: ListProvisionalConsumersForBatch :many +SELECT consumed_vtxo_hash, consumed_vtxo_index, expected_vtxo_revision +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1 +` + +type ListProvisionalConsumersForBatchRow struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ExpectedVtxoRevision int64 +} + +// ListProvisionalConsumersForBatch returns every pending edge and expected +// business revision owned by one consumer batch. +func (q *Queries) ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) { + rows, err := q.db.QueryContext(ctx, ListProvisionalConsumersForBatch, consumerBatchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProvisionalConsumersForBatchRow + for rows.Next() { + var i ListProvisionalConsumersForBatchRow + if err := rows.Scan(&i.ConsumedVtxoHash, &i.ConsumedVtxoIndex, &i.ExpectedVtxoRevision); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListVTXOsForCanonicalityBackfill = `-- name: ListVTXOsForCanonicalityBackfill :many +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32 +` + +type ListVTXOsForCanonicalityBackfillRow struct { + OutpointHash []byte + OutpointIndex int32 + CommitmentTxid []byte + BatchExpiry int32 + CreatedHeight int32 +} + +// ListVTXOsForCanonicalityBackfill returns the columns needed to derive +// initial batch canonicality records from already-persisted VTXOs: each +// VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +// height, and the height at which it was created (confirmed). The backfill +// groups these by commitment txid in Go and recomputes the CSV-relative +// expiry delta as batch_expiry - created_height. +func (q *Queries) ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) { + rows, err := q.db.QueryContext(ctx, ListVTXOsForCanonicalityBackfill) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVTXOsForCanonicalityBackfillRow + for rows.Next() { + var i ListVTXOsForCanonicalityBackfillRow + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.CommitmentTxid, + &i.BatchExpiry, + &i.CreatedHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const MarkBatchCanonicalityReady = `-- name: MarkBatchCanonicalityReady :execrows +UPDATE batch_canonicality +SET registration_stage = 2, + ready_generation = $2, + revision = revision + 1, + updated_at = $3 +WHERE batch_txid = $1 AND observation_generation = $2 +` + +type MarkBatchCanonicalityReadyParams struct { + BatchTxid []byte + ReadyGeneration sql.NullInt64 + UpdatedAt int64 +} + +// MarkBatchCanonicalityReady opens admission only for the generation whose +// complete snapshot was installed. A stale generation updates zero rows. +func (q *Queries) MarkBatchCanonicalityReady(ctx context.Context, arg MarkBatchCanonicalityReadyParams) (int64, error) { + result, err := q.db.ExecContext(ctx, MarkBatchCanonicalityReady, arg.BatchTxid, arg.ReadyGeneration, arg.UpdatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const QuarantineBatchCanonicality = `-- name: QuarantineBatchCanonicality :exec +UPDATE batch_canonicality +SET registration_stage = 3, + ready_generation = NULL, + revision = revision + 1, + updated_at = $2 +WHERE batch_txid = $1 +` + +type QuarantineBatchCanonicalityParams struct { + BatchTxid []byte + UpdatedAt int64 +} + +// QuarantineBatchCanonicality fails a record closed after contradictory +// immutable evidence is presented. +func (q *Queries) QuarantineBatchCanonicality(ctx context.Context, arg QuarantineBatchCanonicalityParams) error { + _, err := q.db.ExecContext(ctx, QuarantineBatchCanonicality, arg.BatchTxid, arg.UpdatedAt) + return err +} + +const RecordBatchConfirmation = `-- name: RecordBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1 +` + +type RecordBatchConfirmationParams struct { + BatchTxid []byte + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + UpdatedAt int64 +} + +// RecordBatchConfirmation records the best-chain height and block hash at +// which the batch tx is confirmed. A later call at a different height (after +// a reorg) overwrites the observation so effective expiry tracks the new +// confirmation. +func (q *Queries) RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, RecordBatchConfirmation, + arg.BatchTxid, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.UpdatedAt, + ) + return err +} + +const RecordBatchInputConflict = `-- name: RecordBatchInputConflict :execrows +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3 +` + +type RecordBatchInputConflictParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + Conflicting int32 + ConflictFinal int32 +} + +// RecordBatchInputConflict persists the observed conflict status of one +// consumed input, so restart reconciliation can rebuild the per-input +// conflict view and not transiently downgrade a persisted conflict. +func (q *Queries) RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) (int64, error) { + result, err := q.db.ExecContext(ctx, RecordBatchInputConflict, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.Conflicting, + arg.ConflictFinal, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const RestoreForfeitedVTXOForConsumer = `-- name: RestoreForfeitedVTXOForConsumer :execrows +UPDATE vtxos +SET status = 0, + forfeit_round_id = NULL, + forfeit_tx = NULL, + forfeit_txid = NULL, + forfeit_consumer_txid = NULL, + replaced_by_hash = NULL, + replaced_by_index = NULL, + business_revision = business_revision + 1, + last_update_time = $5 +WHERE vtxos.outpoint_hash = $1 AND vtxos.outpoint_index = $2 + AND vtxos.status = 3 AND vtxos.spent = FALSE + AND vtxos.business_revision = $3 + AND vtxos.forfeit_consumer_txid = $4 + AND EXISTS ( + SELECT 1 + FROM batch_canonicality consumer_batch + WHERE consumer_batch.batch_txid = $4 + AND consumer_batch.state = 5 + AND consumer_batch.registration_stage = 2 + AND consumer_batch.ready_generation = + consumer_batch.observation_generation + ) + AND NOT EXISTS ( + SELECT 1 FROM spending_reservations reservation + WHERE reservation.outpoint_hash = $1 + AND reservation.outpoint_index = $2 + ) + AND NOT EXISTS ( + SELECT 1 + FROM batch_provisional_consumers other_edge + JOIN batch_canonicality other_batch + ON other_batch.batch_txid = other_edge.consumer_batch_txid + WHERE other_edge.consumed_vtxo_hash = $1 + AND other_edge.consumed_vtxo_index = $2 + AND other_edge.consumer_batch_txid != $4 + AND NOT ( + other_batch.state = 5 + AND other_batch.registration_stage = 2 + AND other_batch.ready_generation = + other_batch.observation_generation + ) + ) +` + +type RestoreForfeitedVTXOForConsumerParams struct { + OutpointHash []byte + OutpointIndex int32 + BusinessRevision int64 + ForfeitConsumerTxid []byte + LastUpdateTime int64 +} + +// RestoreForfeitedVTXOForConsumer is the business-state CAS. The caller +// deletes the exact edge in the same transaction only when this updates one +// row. A competing viable consumer, reservation, completed spend, different +// consumer marker, or stale revision makes it update zero rows. +func (q *Queries) RestoreForfeitedVTXOForConsumer(ctx context.Context, arg RestoreForfeitedVTXOForConsumerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, RestoreForfeitedVTXOForConsumer, + arg.OutpointHash, + arg.OutpointIndex, + arg.BusinessRevision, + arg.ForfeitConsumerTxid, + arg.LastUpdateTime, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const UpdateBatchCanonicalityState = `-- name: UpdateBatchCanonicalityState :exec +UPDATE batch_canonicality +SET state = $2, revision = revision + 1, updated_at = $3 +WHERE batch_txid = $1 +` + +type UpdateBatchCanonicalityStateParams struct { + BatchTxid []byte + State int32 + UpdatedAt int64 +} + +// UpdateBatchCanonicalityState transitions a batch to a new state without +// touching its other fields. +func (q *Queries) UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error { + _, err := q.db.ExecContext(ctx, UpdateBatchCanonicalityState, arg.BatchTxid, arg.State, arg.UpdatedAt) + return err +} + +const UpsertBatchCanonicality = `-- name: UpsertBatchCanonicality :exec + +INSERT INTO batch_canonicality ( + batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15 +) +ON CONFLICT (batch_txid) DO UPDATE SET + batch_tx = EXCLUDED.batch_tx, + batch_output_index = EXCLUDED.batch_output_index, + state = EXCLUDED.state, + registration_stage = EXCLUDED.registration_stage, + observation_generation = EXCLUDED.observation_generation, + ready_generation = EXCLUDED.ready_generation, + revision = EXCLUDED.revision, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, + updated_at = EXCLUDED.updated_at +` + +type UpsertBatchCanonicalityParams struct { + BatchTxid []byte + BatchTx []byte + BatchOutputIndex sql.NullInt32 + State int32 + RegistrationStage int32 + ObservationGeneration int64 + ReadyGeneration sql.NullInt64 + Revision int64 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 + ConfirmationPkScript []byte +} + +// Batch canonicality queries. +// These maintain the durable, reorg-aware 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. The queries are behavior-free; all +// interpretation lives in the batch canonicality manager. +// UpsertBatchCanonicality inserts or replaces the canonicality row for a +// batch. created_at is preserved on conflict; everything else is overwritten. +func (q *Queries) UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error { + _, err := q.db.ExecContext(ctx, UpsertBatchCanonicality, + arg.BatchTxid, + arg.BatchTx, + arg.BatchOutputIndex, + arg.State, + arg.RegistrationStage, + arg.ObservationGeneration, + arg.ReadyGeneration, + arg.Revision, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.CsvExpiryDelta, + arg.PolicyState, + arg.CreatedAt, + arg.UpdatedAt, + arg.ConfirmationPkScript, + ) + return err +} diff --git a/db/sqlc/migrations/000016_batch_canonicality.down.sql b/db/sqlc/migrations/000016_batch_canonicality.down.sql new file mode 100644 index 000000000..59bd4b244 --- /dev/null +++ b/db/sqlc/migrations/000016_batch_canonicality.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS batch_consumer_creator_lineage; +DROP TABLE IF EXISTS batch_provisional_consumers; +DROP TABLE IF EXISTS batch_dependent_vtxos; +DROP TABLE IF EXISTS batch_consumed_inputs; +DROP TABLE IF EXISTS batch_canonicality; + +ALTER TABLE vtxos DROP COLUMN forfeit_consumer_txid; +ALTER TABLE vtxos DROP COLUMN business_revision; diff --git a/db/sqlc/migrations/000016_batch_canonicality.up.sql b/db/sqlc/migrations/000016_batch_canonicality.up.sql new file mode 100644 index 000000000..07f9b3caf --- /dev/null +++ b/db/sqlc/migrations/000016_batch_canonicality.up.sql @@ -0,0 +1,225 @@ +-- batch_canonicality is the durable, reorg-aware record of how each batch +-- (commitment) transaction is faring against the best chain. It is keyed by +-- the batch txid: identity is by txid, never by (txid, block hash), so a +-- reorg that re-mines the same batch in a different block is the same row. +-- +-- Effective (absolute) expiry is intentionally NOT stored. The row keeps the +-- CSV-relative delta plus the current confirmation height; the effective +-- expiry is derived as confirmation_height + csv_expiry_delta and is therefore +-- recomputed on every (re)confirmation rather than frozen at first +-- confirmation. Expiry is never persisted as a one-way terminal fact. + +-- business_revision is the compare-and-swap generation of the VTXO lifecycle +-- row. forfeit_consumer_txid binds a Forfeited marker to the exact commitment +-- transaction whose invalidation may conditionally restore it. +ALTER TABLE vtxos ADD COLUMN business_revision BIGINT NOT NULL DEFAULT 0 + CHECK (business_revision >= 0); +ALTER TABLE vtxos ADD COLUMN forfeit_consumer_txid BLOB + CHECK (forfeit_consumer_txid IS NULL + OR length(forfeit_consumer_txid) = 32); + +CREATE TABLE IF NOT EXISTS batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- batch_tx is the serialized commitment transaction. Together with the + -- watched output index it makes the txid, confirmation script, and exact + -- consumed-input set independently checkable. Backfilled legacy rows are + -- NULL and remain reconciling until complete evidence is imported. + batch_tx BLOB, + batch_output_index INTEGER CHECK (batch_output_index IS NULL + OR batch_output_index >= 0), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- registration_stage is the crash-safe evidence/readiness lifecycle: + -- 0 = registering + -- 1 = reconciling + -- 2 = complete + -- 3 = quarantined + -- A semantic state is never admissible unless this is complete and the + -- ready generation matches observation_generation. + registration_stage INTEGER NOT NULL DEFAULT 0, + + -- observation_generation identifies the current watch/snapshot attempt. + -- Restart increments it before arming any watch. ready_generation remains + -- NULL until every registered subject supplies a current observation for + -- that same generation. + observation_generation BIGINT NOT NULL DEFAULT 1 + CHECK (observation_generation >= 1), + ready_generation BIGINT CHECK (ready_generation IS NULL + OR ready_generation >= 1), + + -- revision changes whenever readiness or semantic availability can + -- change. Admission tokens bind to it and must be revalidated before a + -- critical side effect. + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash 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. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + CHECK ((batch_tx IS NULL AND batch_output_index IS NULL) + OR (batch_tx IS NOT NULL AND batch_output_index IS NOT NULL)), + + PRIMARY KEY (batch_txid) +); + +-- Index supporting "find every batch in a given state" (e.g. all provisional +-- batches the manager must re-check for finality). +CREATE INDEX IF NOT EXISTS idx_batch_canonicality_state + ON batch_canonicality(state); + +-- batch_consumed_inputs records the outpoints each batch tx spends, so the +-- canonicality manager can watch every consumed input for a conflicting +-- spend. +CREATE TABLE IF NOT EXISTS batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_value plus input_pk_script is the authenticated previous output + -- evidence for this actual transaction input. + input_value BIGINT NOT NULL CHECK (input_value >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting input-conflict detection: given an outpoint, find every +-- batch that consumes it (two batches consuming the same outpoint conflict). +CREATE INDEX IF NOT EXISTS idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +-- batch_dependent_vtxos records the VTXO outpoints anchored by each batch. +-- Their derived availability follows the batch's canonicality. There is +-- intentionally no FK to vtxos: a batch may anchor VTXOs the local wallet +-- does not own or persist. +CREATE TABLE IF NOT EXISTS batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given a VTXO outpoint, which batch anchors it". +CREATE INDEX IF NOT EXISTS idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +-- batch_provisional_consumers is the reverse-dependency table that lets a +-- provisionally consumed VTXO be restored if its consumer batch never becomes +-- canonical (e.g. a round-2 forfeit whose commitment tx is reorged out must +-- restore the round-1 VTXO it consumed). Each row says "consumed_vtxo is +-- provisionally consumed by consumer_batch". +CREATE TABLE IF NOT EXISTS batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + + -- expected_vtxo_revision is the exact ForfeitedBy transition this edge + -- owns. A later business transition makes the restore CAS stale. + expected_vtxo_revision BIGINT NOT NULL + CHECK (expected_vtxo_revision > 0), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given an invalidated consumer batch, list the VTXOs to +-- restore". +CREATE INDEX IF NOT EXISTS idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); + +-- batch_consumer_creator_lineage stores the complete inherited commitment +-- lineage of each logically consumed VTXO. Restore is impossible unless every +-- row is Ready and usable. +CREATE TABLE IF NOT EXISTS batch_consumer_creator_lineage ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + creator_batch_txid BLOB NOT NULL + CHECK (length(creator_batch_txid) = 32), + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid + ), + FOREIGN KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ) REFERENCES batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ) ON DELETE CASCADE +); + +-- Index supporting targeted restore redrive when one creator batch changes +-- canonicality state. +CREATE INDEX IF NOT EXISTS idx_batch_consumer_lineage_creator + ON batch_consumer_creator_lineage(creator_batch_txid); diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 8588c76aa..ebdd3d0d7 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -61,6 +61,55 @@ type ActivityStatus struct { Name string } +type BatchCanonicality struct { + BatchTxid []byte + BatchTx []byte + BatchOutputIndex sql.NullInt32 + State int32 + RegistrationStage int32 + ObservationGeneration int64 + ReadyGeneration sql.NullInt64 + Revision int64 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 + ConfirmationPkScript []byte +} + +type BatchConsumedInput struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputValue int64 + InputPkScript []byte + Conflicting int32 + ConflictFinal int32 +} + +type BatchConsumerCreatorLineage struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatorBatchTxid []byte +} + +type BatchDependentVtxo struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +type BatchProvisionalConsumer struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + ExpectedVtxoRevision int64 + CreatedAt int64 +} + type BoardingAddress struct { PkScript []byte AddressString string @@ -448,6 +497,8 @@ type Vtxo struct { LastUpdateTime int64 ChainDepth int32 ConstructionVersion int32 + BusinessRevision int64 + ForfeitConsumerTxid []byte } type VtxoAncestryPath struct { diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 926885193..b2f54b558 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -14,12 +14,22 @@ type Querier interface { // returns the event_seq the database assigned (monotonic, not necessarily // contiguous). Callers use it as the resumable-subscribe cursor for the update. AppendActivityEvent(ctx context.Context, arg AppendActivityEventParams) (int64, error) + // ApplyBatchCanonicalityObservation atomically installs the batch-level part + // of one complete observation snapshot. The caller updates every input in the + // same SQL transaction before this generation-guarded write. + ApplyBatchCanonicalityObservation(ctx context.Context, arg ApplyBatchCanonicalityObservationParams) (int64, error) // BackfillLedgerRoundUuid stamps the canonical UUID string form onto every // entry carrying the given raw round_id that does not have one yet. The // round_uuid IS NULL guard makes re-running the backfill (e.g. after a crash // mid-migration) a no-op for already-converted rows. BackfillLedgerRoundUuid(ctx context.Context, arg BackfillLedgerRoundUuidParams) error + // BeginBatchCanonicalityReconcile closes admission and starts a fresh + // observation generation before any watch is armed. + BeginBatchCanonicalityReconcile(ctx context.Context, arg BeginBatchCanonicalityReconcileParams) (BatchCanonicality, error) CancelVHTLCRecoveryJob(ctx context.Context, arg CancelVHTLCRecoveryJobParams) (int64, error) + // ClearBatchConfirmation nulls the confirmation observation, reflecting that + // the confirming block left the best chain. It sets no terminal flag. + ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error ClearPendingIntentAnchorByOutpoint(ctx context.Context, arg ClearPendingIntentAnchorByOutpointParams) error CompleteVHTLCRecoveryJob(ctx context.Context, arg CompleteVHTLCRecoveryJobParams) (int64, error) // CountActivityEntriesByStatus returns the number of current-state rows in the @@ -38,6 +48,12 @@ type Querier interface { // CountVTXOsByStatus returns the count of VTXOs with the specified status. CountVTXOsByStatus(ctx context.Context, status int32) (int64, error) CountWalletUTXOLog(ctx context.Context) (int64, error) + // DeleteBatchConsumedInputs removes every consumed-input row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + // DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error DeleteClientTreeTxids(ctx context.Context, arg DeleteClientTreeTxidsParams) error DeleteOORPackageCheckpoints(ctx context.Context, sessionID []byte) error DeleteOrphanedPendingBoardIntents(ctx context.Context) error @@ -58,6 +74,12 @@ type Querier interface { DeletePendingIntentsByKind(ctx context.Context, kind string) error DeletePendingSendIntentByID(ctx context.Context, intentID []byte) error DeletePendingSendIntentsAll(ctx context.Context) error + // DeleteProvisionalConsumer completes one exact edge. Its normalized creator + // lineage cascades with it. + DeleteProvisionalConsumer(ctx context.Context, arg DeleteProvisionalConsumerParams) (int64, error) + // DeleteProvisionalConsumersForBatch removes every reverse-dependency edge + // for the given consumer batch. + DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error // DeleteSpendingReservation removes the reservation for one outpoint. Called // when the VTXO leaves SpendingState (released or completed). DeleteSpendingReservation(ctx context.Context, arg DeleteSpendingReservationParams) error @@ -71,8 +93,14 @@ type Querier interface { EscalateVHTLCRecoveryJob(ctx context.Context, arg EscalateVHTLCRecoveryJobParams) (int64, error) FailVHTLCRecoveryJob(ctx context.Context, arg FailVHTLCRecoveryJobParams) (int64, error) FinalizeRound(ctx context.Context, arg FinalizeRoundParams) error + // FindBatchesByConsumedOutpoint returns the txids of every batch that + // consumes the given outpoint. + FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) // GetActivityEntry returns one entry by its canonical id. GetActivityEntry(ctx context.Context, canonicalID string) (ActivityEntry, error) + // GetBatchCanonicality returns the canonicality row for a batch txid. The + // column order matches the table so sqlc reuses the BatchCanonicality model. + GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) GetBoardingAddress(ctx context.Context, pkScript []byte) (BoardingAddress, error) GetBoardingIntent(ctx context.Context, arg GetBoardingIntentParams) (BoardingIntent, error) GetBoardingSweep(ctx context.Context, txid []byte) (BoardingSweep, error) @@ -106,6 +134,9 @@ type Querier interface { // Fetch one intent header by id, exposing the terminal-failure columns so // callers (and tests) can assert send-failure state without raw SQL. GetPendingIntentByID(ctx context.Context, intentID []byte) (GetPendingIntentByIDRow, error) + // GetProvisionalConsumer returns the immutable expected business revision of + // one edge so repeat registration can reject contradictory evidence. + GetProvisionalConsumer(ctx context.Context, arg GetProvisionalConsumerParams) (int64, error) GetRound(ctx context.Context, roundID string) (Round, error) GetRoundBoardingIntents(ctx context.Context, roundID string) ([]RoundBoardingIntent, error) GetRoundByCommitmentTxid(ctx context.Context, commitmentTxid []byte) (Round, error) @@ -125,6 +156,11 @@ type Querier interface { // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited // VTXO. Returns NULL if not forfeited or no replacement recorded. GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) + // InsertBatchConsumedInput records one input consumed by a batch, together + // with the pkScript of the spent output (needed to register the spend watch). + InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error + // InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. + InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error // Boarding address queries. InsertBoardingAddress(ctx context.Context, arg InsertBoardingAddressParams) error // Boarding intent queries. @@ -152,9 +188,13 @@ type Querier interface { InsertClientLedgerEntry(ctx context.Context, arg InsertClientLedgerEntryParams) error // Client tree txids queries. InsertClientTreeTxid(ctx context.Context, arg InsertClientTreeTxidParams) error + InsertConsumerCreatorLineage(ctx context.Context, arg InsertConsumerCreatorLineageParams) error InsertExitFundingAddress(ctx context.Context, arg InsertExitFundingAddressParams) error InsertMacaroonRootKey(ctx context.Context, arg InsertMacaroonRootKeyParams) error InsertOORPackageCheckpoint(ctx context.Context, arg InsertOORPackageCheckpointParams) error + // InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo + // is provisionally consumed by consumer_batch. Idempotent. + InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error // Round queries. InsertRound(ctx context.Context, arg InsertRoundParams) error // Round boarding intents queries. @@ -190,6 +230,14 @@ type Querier interface { ListAllCreditOperations(ctx context.Context) ([]CreditOperation, error) ListAllOORSessionRegistry(ctx context.Context) ([]OorSessionRegistry, error) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) + // ListBatchCanonicalityByState returns every batch currently in the given + // state. + ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) + // ListBatchConsumedInputs returns the inputs a batch consumes, with the + // pkScript of each spent output and its persisted conflict observation. + ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) + // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. + ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) ListBoardingIntentOutpoints(ctx context.Context) ([]ListBoardingIntentOutpointsRow, error) ListBoardingIntentsByConfHeight(ctx context.Context, confHeight int32) ([]BoardingIntent, error) ListBoardingIntentsByPkScript(ctx context.Context, pkScript []byte) ([]BoardingIntent, error) @@ -204,6 +252,7 @@ type Querier interface { ListClientLedgerEntries(ctx context.Context, arg ListClientLedgerEntriesParams) ([]LedgerEntry, error) ListClientLedgerEntriesByType(ctx context.Context, arg ListClientLedgerEntriesByTypeParams) ([]LedgerEntry, error) ListClientLedgerEventTotals(ctx context.Context) ([]ListClientLedgerEventTotalsRow, error) + ListConsumerCreatorLineage(ctx context.Context, arg ListConsumerCreatorLineageParams) ([][]byte, error) // ListEntriesByKindStatus returns entries of the given kind and status, paged // by the unique canonical_id ascending. It backs the startup rehydration of // the wallet-local pending map: filtering in SQL keeps that scan O(matching @@ -257,10 +306,16 @@ type Querier interface { ListPendingBoardIntents(ctx context.Context) ([]ListPendingBoardIntentsRow, error) ListPendingBoardingSweepInputs(ctx context.Context) ([]BoardingSweepInput, error) ListPendingBoardingSweeps(ctx context.Context) ([]BoardingSweep, error) + // ListPendingConsumerBatchesByCreator targets durable restore checkpoints + // whose creator-lineage decision may change when this batch changes state. + ListPendingConsumerBatchesByCreator(ctx context.Context, creatorBatchTxid []byte) ([][]byte, error) ListPendingIntentAnchorsByKind(ctx context.Context, kind string) ([]PendingIntentAnchor, error) // Only status = 'pending' rows replay; a 'failed' intent is terminally // retired and must not be re-submitted on restart. ListPendingSendIntents(ctx context.Context) ([]ListPendingSendIntentsRow, error) + // ListProvisionalConsumersForBatch returns every pending edge and expected + // business revision owned by one consumer batch. + ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) // ListRecoverableVTXOs returns every VTXO whose actor must be restored at // startup: the non-terminal set of ListLiveVTXOs plus Expired (8). // @@ -328,6 +383,13 @@ type Querier interface { // a forfeit_round_id, instead of aggregating every fee row in the ledger on // every call. ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVTXOsByStatusRow, error) + // ListVTXOsForCanonicalityBackfill returns the columns needed to derive + // initial batch canonicality records from already-persisted VTXOs: each + // VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry + // height, and the height at which it was created (confirmed). The backfill + // groups these by commitment txid in Go and recomputes the CSV-relative + // expiry delta as batch_expiry - created_height. + ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) ListWalletUTXOLog(ctx context.Context, arg ListWalletUTXOLogParams) ([]WalletUtxoLog, error) ListWalletUTXOLogByBlock(ctx context.Context, blockHeight int32) ([]WalletUtxoLog, error) ListWalletUTXOLogByClassification(ctx context.Context, arg ListWalletUTXOLogByClassificationParams) ([]WalletUtxoLog, error) @@ -340,6 +402,9 @@ type Querier interface { // dedup a keyed retry, so the lookup skips them: only a pending or completed // session answers for an idempotency key. LookupActiveOORSessionRegistryByIdempotencyKey(ctx context.Context, idempotencyKey sql.NullString) (OorSessionRegistry, error) + // MarkBatchCanonicalityReady opens admission only for the generation whose + // complete snapshot was installed. A stale generation updates zero rows. + MarkBatchCanonicalityReady(ctx context.Context, arg MarkBatchCanonicalityReadyParams) (int64, error) MarkBoardingSweepInputSpentByOutpoint(ctx context.Context, arg MarkBoardingSweepInputSpentByOutpointParams) (int64, error) MarkBoardingSweepInputStatus(ctx context.Context, arg MarkBoardingSweepInputStatusParams) error MarkBoardingSweepInputsStatus(ctx context.Context, arg MarkBoardingSweepInputsStatusParams) error @@ -372,8 +437,28 @@ type Querier interface { // PullActivityEvents returns transition rows strictly after the cursor in // event_seq order, the resumable-subscribe replay primitive. PullActivityEvents(ctx context.Context, arg PullActivityEventsParams) ([]ActivityEvent, error) + // QuarantineBatchCanonicality fails a record closed after contradictory + // immutable evidence is presented. + QuarantineBatchCanonicality(ctx context.Context, arg QuarantineBatchCanonicalityParams) error + // RecordBatchConfirmation records the best-chain height and block hash at + // which the batch tx is confirmed. A later call at a different height (after + // a reorg) overwrites the observation so effective expiry tracks the new + // confirmation. + RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error + // RecordBatchInputConflict persists the observed conflict status of one + // consumed input, so restart reconciliation can rebuild the per-input + // conflict view and not transiently downgrade a persisted conflict. + RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) (int64, error) + // RestoreForfeitedVTXOForConsumer is the business-state CAS. The caller + // deletes the exact edge in the same transaction only when this updates one + // row. A competing viable consumer, reservation, completed spend, different + // consumer marker, or stale revision makes it update zero rows. + RestoreForfeitedVTXOForConsumer(ctx context.Context, arg RestoreForfeitedVTXOForConsumerParams) (int64, error) SumBoardingIntentAmountsByStatus(ctx context.Context, status string) (interface{}, error) SumUnspentVTXOAmounts(ctx context.Context) (interface{}, error) + // UpdateBatchCanonicalityState transitions a batch to a new state without + // touching its other fields. + UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error UpdateBoardingIntentStatus(ctx context.Context, arg UpdateBoardingIntentStatusParams) error UpdateRoundBoardingIntentSignature(ctx context.Context, arg UpdateRoundBoardingIntentSignatureParams) error UpdateRoundStatus(ctx context.Context, arg UpdateRoundStatusParams) error @@ -390,6 +475,15 @@ type Querier interface { // correlation handles are COALESCEd so an early projection that does not yet // know a txid never clobbers one a later projection already recorded. UpsertActivityEntry(ctx context.Context, arg UpsertActivityEntryParams) (int64, error) + // Batch canonicality queries. + // These maintain the durable, reorg-aware 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. The queries are behavior-free; all + // interpretation lives in the batch canonicality manager. + // UpsertBatchCanonicality inserts or replaces the canonicality row for a + // batch. created_at is preserved on conflict; everything else is overwritten. + UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error UpsertChainInfo(ctx context.Context, arg UpsertChainInfoParams) error // Credit operations control-plane queries. UpsertCreditOperation(ctx context.Context, arg UpsertCreditOperationParams) error diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql new file mode 100644 index 000000000..a06f96db8 --- /dev/null +++ b/db/sqlc/queries/batch_canonicality.sql @@ -0,0 +1,310 @@ +-- Batch canonicality queries. +-- These maintain the durable, reorg-aware 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. The queries are behavior-free; all +-- interpretation lives in the batch canonicality manager. + +-- name: UpsertBatchCanonicality :exec +-- UpsertBatchCanonicality inserts or replaces the canonicality row for a +-- batch. created_at is preserved on conflict; everything else is overwritten. +INSERT INTO batch_canonicality ( + batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15 +) +ON CONFLICT (batch_txid) DO UPDATE SET + batch_tx = EXCLUDED.batch_tx, + batch_output_index = EXCLUDED.batch_output_index, + state = EXCLUDED.state, + registration_stage = EXCLUDED.registration_stage, + observation_generation = EXCLUDED.observation_generation, + ready_generation = EXCLUDED.ready_generation, + revision = EXCLUDED.revision, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, + updated_at = EXCLUDED.updated_at; + +-- name: GetBatchCanonicality :one +-- GetBatchCanonicality returns the canonicality row for a batch txid. The +-- column order matches the table so sqlc reuses the BatchCanonicality model. +SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +FROM batch_canonicality +WHERE batch_txid = $1; + +-- name: ListBatchCanonicalityByState :many +-- ListBatchCanonicalityByState returns every batch currently in the given +-- state. +SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, + observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script +FROM batch_canonicality +WHERE state = $1; + +-- name: BeginBatchCanonicalityReconcile :one +-- BeginBatchCanonicalityReconcile closes admission and starts a fresh +-- observation generation before any watch is armed. +UPDATE batch_canonicality +SET registration_stage = 1, + observation_generation = observation_generation + 1, + ready_generation = NULL, + revision = revision + 1, + updated_at = $2 +WHERE batch_txid = $1 +RETURNING batch_txid, batch_tx, batch_output_index, state, + registration_stage, observation_generation, ready_generation, revision, + confirmation_height, confirmation_block_hash, csv_expiry_delta, + policy_state, created_at, updated_at, confirmation_pk_script; + +-- name: MarkBatchCanonicalityReady :execrows +-- MarkBatchCanonicalityReady opens admission only for the generation whose +-- complete snapshot was installed. A stale generation updates zero rows. +UPDATE batch_canonicality +SET registration_stage = 2, + ready_generation = $2, + revision = revision + 1, + updated_at = $3 +WHERE batch_txid = $1 AND observation_generation = $2; + +-- name: ApplyBatchCanonicalityObservation :execrows +-- ApplyBatchCanonicalityObservation atomically installs the batch-level part +-- of one complete observation snapshot. The caller updates every input in the +-- same SQL transaction before this generation-guarded write. +UPDATE batch_canonicality +SET state = $3, + confirmation_height = $4, + confirmation_block_hash = $5, + registration_stage = CASE + WHEN COALESCE($6, CAST(-1 AS BIGINT)) = CAST(-1 AS BIGINT) + THEN registration_stage + ELSE 2 + END, + ready_generation = $6, + revision = revision + 1, + updated_at = $7 +WHERE batch_txid = $1 AND observation_generation = $2 + AND registration_stage != 3; + +-- name: QuarantineBatchCanonicality :exec +-- QuarantineBatchCanonicality fails a record closed after contradictory +-- immutable evidence is presented. +UPDATE batch_canonicality +SET registration_stage = 3, + ready_generation = NULL, + revision = revision + 1, + updated_at = $2 +WHERE batch_txid = $1; + +-- name: UpdateBatchCanonicalityState :exec +-- UpdateBatchCanonicalityState transitions a batch to a new state without +-- touching its other fields. +UPDATE batch_canonicality +SET state = $2, revision = revision + 1, updated_at = $3 +WHERE batch_txid = $1; + +-- name: RecordBatchConfirmation :exec +-- RecordBatchConfirmation records the best-chain height and block hash at +-- which the batch tx is confirmed. A later call at a different height (after +-- a reorg) overwrites the observation so effective expiry tracks the new +-- confirmation. +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1; + +-- name: ClearBatchConfirmation :exec +-- ClearBatchConfirmation nulls the confirmation observation, reflecting that +-- the confirming block left the best chain. It sets no terminal flag. +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1; + +-- name: InsertBatchConsumedInput :exec +-- InsertBatchConsumedInput records one input consumed by a batch, together +-- with the pkScript of the spent output (needed to register the spend watch). +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_value, input_pk_script +) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; + +-- name: DeleteBatchConsumedInputs :exec +-- DeleteBatchConsumedInputs removes every consumed-input row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; + +-- name: ListBatchConsumedInputs :many +-- ListBatchConsumedInputs returns the inputs a batch consumes, with the +-- pkScript of each spent output and its persisted conflict observation. +SELECT input_hash, input_index, input_value, input_pk_script, conflicting, + conflict_final +FROM batch_consumed_inputs +WHERE batch_txid = $1; + +-- name: RecordBatchInputConflict :execrows +-- RecordBatchInputConflict persists the observed conflict status of one +-- consumed input, so restart reconciliation can rebuild the per-input +-- conflict view and not transiently downgrade a persisted conflict. +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3; + +-- name: FindBatchesByConsumedOutpoint :many +-- FindBatchesByConsumedOutpoint returns the txids of every batch that +-- consumes the given outpoint. +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2; + +-- name: InsertBatchDependentVTXO :exec +-- InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING; + +-- name: DeleteBatchDependentVTXOs :exec +-- DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1; + +-- name: ListBatchDependentVTXOs :many +-- ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1; + +-- name: InsertProvisionalConsumer :exec +-- InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +-- is provisionally consumed by consumer_batch. Idempotent. +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + expected_vtxo_revision, created_at +) VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING; + +-- name: GetProvisionalConsumer :one +-- GetProvisionalConsumer returns the immutable expected business revision of +-- one edge so repeat registration can reject contradictory evidence. +SELECT expected_vtxo_revision +FROM batch_provisional_consumers +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3; + +-- name: InsertConsumerCreatorLineage :exec +INSERT INTO batch_consumer_creator_lineage ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid +) DO NOTHING; + +-- name: ListConsumerCreatorLineage :many +SELECT creator_batch_txid +FROM batch_consumer_creator_lineage +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3 +ORDER BY creator_batch_txid; + +-- name: ListProvisionalConsumersForBatch :many +-- ListProvisionalConsumersForBatch returns every pending edge and expected +-- business revision owned by one consumer batch. +SELECT consumed_vtxo_hash, consumed_vtxo_index, expected_vtxo_revision +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1; + +-- name: ListPendingConsumerBatchesByCreator :many +-- ListPendingConsumerBatchesByCreator targets durable restore checkpoints +-- whose creator-lineage decision may change when this batch changes state. +SELECT DISTINCT consumer_batch_txid +FROM batch_consumer_creator_lineage +WHERE creator_batch_txid = $1 +ORDER BY consumer_batch_txid; + +-- name: DeleteProvisionalConsumer :execrows +-- DeleteProvisionalConsumer completes one exact edge. Its normalized creator +-- lineage cascades with it. +DELETE FROM batch_provisional_consumers +WHERE consumed_vtxo_hash = $1 AND consumed_vtxo_index = $2 + AND consumer_batch_txid = $3 + AND expected_vtxo_revision = $4; + +-- name: RestoreForfeitedVTXOForConsumer :execrows +-- RestoreForfeitedVTXOForConsumer is the business-state CAS. The caller +-- deletes the exact edge in the same transaction only when this updates one +-- row. A competing viable consumer, reservation, completed spend, different +-- consumer marker, or stale revision makes it update zero rows. +UPDATE vtxos +SET status = 0, + forfeit_round_id = NULL, + forfeit_tx = NULL, + forfeit_txid = NULL, + forfeit_consumer_txid = NULL, + replaced_by_hash = NULL, + replaced_by_index = NULL, + business_revision = business_revision + 1, + last_update_time = $5 +WHERE vtxos.outpoint_hash = $1 AND vtxos.outpoint_index = $2 + AND vtxos.status = 3 AND vtxos.spent = FALSE + AND vtxos.business_revision = $3 + AND vtxos.forfeit_consumer_txid = $4 + AND EXISTS ( + SELECT 1 + FROM batch_canonicality consumer_batch + WHERE consumer_batch.batch_txid = $4 + AND consumer_batch.state = 5 + AND consumer_batch.registration_stage = 2 + AND consumer_batch.ready_generation = + consumer_batch.observation_generation + ) + AND NOT EXISTS ( + SELECT 1 FROM spending_reservations reservation + WHERE reservation.outpoint_hash = $1 + AND reservation.outpoint_index = $2 + ) + AND NOT EXISTS ( + SELECT 1 + FROM batch_provisional_consumers other_edge + JOIN batch_canonicality other_batch + ON other_batch.batch_txid = other_edge.consumer_batch_txid + WHERE other_edge.consumed_vtxo_hash = $1 + AND other_edge.consumed_vtxo_index = $2 + AND other_edge.consumer_batch_txid != $4 + AND NOT ( + other_batch.state = 5 + AND other_batch.registration_stage = 2 + AND other_batch.ready_generation = + other_batch.observation_generation + ) + ); + +-- name: DeleteProvisionalConsumersForBatch :exec +-- DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +-- for the given consumer batch. +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1; + +-- name: ListVTXOsForCanonicalityBackfill :many +-- ListVTXOsForCanonicalityBackfill returns the columns needed to derive +-- initial batch canonicality records from already-persisted VTXOs: each +-- VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +-- height, and the height at which it was created (confirmed). The backfill +-- groups these by commitment txid in Go and recomputes the CSV-relative +-- expiry delta as batch_expiry - created_height. +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32; diff --git a/db/sqlc/queries/round.sql b/db/sqlc/queries/round.sql index c2a0b3661..467df27e5 100644 --- a/db/sqlc/queries/round.sql +++ b/db/sqlc/queries/round.sql @@ -244,7 +244,8 @@ SELECT * FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC; -- name: MarkVTXOSpent :exec -- Also sets status = 4 (Spent) to keep status in sync with spent flag. -UPDATE vtxos SET spent = TRUE, status = 4, last_update_time = $3 +UPDATE vtxos SET spent = TRUE, status = 4, + business_revision = business_revision + 1, last_update_time = $3 WHERE outpoint_hash = $1 AND outpoint_index = $2; -- name: CountUnspentVTXOs :one diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql index 858b2335a..cfa154edc 100644 --- a/db/sqlc/queries/vtxo.sql +++ b/db/sqlc/queries/vtxo.sql @@ -82,6 +82,7 @@ SET status = $3, -- Keep spent flag in sync when status transitions to Spent (4). -- We intentionally do not clear spent once set. spent = CASE WHEN $3 = 4 THEN TRUE ELSE spent END, + business_revision = business_revision + 1, last_update_time = $4 WHERE outpoint_hash = $1 AND outpoint_index = $2; @@ -93,6 +94,7 @@ UPDATE vtxos SET status = 2, -- Forfeiting forfeit_round_id = $3, forfeit_tx = $4, + business_revision = business_revision + 1, last_update_time = $5 WHERE outpoint_hash = $1 AND outpoint_index = $2; @@ -120,9 +122,11 @@ WHERE outpoint_hash = $1 AND outpoint_index = $2; UPDATE vtxos SET status = 3, -- Forfeited forfeit_txid = $3, - replaced_by_hash = $4, - replaced_by_index = $5, - last_update_time = $6 + forfeit_consumer_txid = $4, + replaced_by_hash = $5, + replaced_by_index = $6, + business_revision = business_revision + 1, + last_update_time = $7 WHERE outpoint_hash = $1 AND outpoint_index = $2; -- name: DeleteVTXO :exec diff --git a/db/sqlc/round.sql.go b/db/sqlc/round.sql.go index 1f3713334..21c7d1920 100644 --- a/db/sqlc/round.sql.go +++ b/db/sqlc/round.sql.go @@ -325,7 +325,7 @@ func (q *Queries) GetRoundVtxoRequests(ctx context.Context, roundID string) ([]R } const GetVTXO = `-- name: GetVTXO :one -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos WHERE outpoint_hash = $1 AND outpoint_index = $2 ` @@ -361,6 +361,8 @@ func (q *Queries) GetVTXO(ctx context.Context, arg GetVTXOParams) (Vtxo, error) &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ) return i, err } @@ -702,7 +704,7 @@ func (q *Queries) ListActiveRounds(ctx context.Context) ([]Round, error) { } const ListAllVTXOs = `-- name: ListAllVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos ORDER BY creation_time DESC ` func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { @@ -738,6 +740,8 @@ func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ); err != nil { return nil, err } @@ -948,7 +952,7 @@ func (q *Queries) ListUnspentVTXOAncestryPaths(ctx context.Context) ([]VtxoAnces } const ListUnspentVTXOs = `-- name: ListUnspentVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos WHERE spent = FALSE AND status != 4 ORDER BY creation_time DESC @@ -988,6 +992,8 @@ func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ); err != nil { return nil, err } @@ -1094,7 +1100,7 @@ func (q *Queries) ListVTXOAncestryPathsByStatus(ctx context.Context, status int3 } const ListVTXOsByRound = `-- name: ListVTXOsByRound :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC ` func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, error) { @@ -1130,6 +1136,8 @@ func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ); err != nil { return nil, err } @@ -1145,7 +1153,8 @@ func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, } const MarkVTXOSpent = `-- name: MarkVTXOSpent :exec -UPDATE vtxos SET spent = TRUE, status = 4, last_update_time = $3 +UPDATE vtxos SET spent = TRUE, status = 4, + business_revision = business_revision + 1, last_update_time = $3 WHERE outpoint_hash = $1 AND outpoint_index = $2 ` diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index 735ff3b69..fa4ce74cd 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -98,6 +98,173 @@ CREATE TABLE ask_results ( expires_at BIGINT NOT NULL ); +CREATE TABLE batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- batch_tx is the serialized commitment transaction. Together with the + -- watched output index it makes the txid, confirmation script, and exact + -- consumed-input set independently checkable. Backfilled legacy rows are + -- NULL and remain reconciling until complete evidence is imported. + batch_tx BLOB, + batch_output_index INTEGER CHECK (batch_output_index IS NULL + OR batch_output_index >= 0), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- registration_stage is the crash-safe evidence/readiness lifecycle: + -- 0 = registering + -- 1 = reconciling + -- 2 = complete + -- 3 = quarantined + -- A semantic state is never admissible unless this is complete and the + -- ready generation matches observation_generation. + registration_stage INTEGER NOT NULL DEFAULT 0, + + -- observation_generation identifies the current watch/snapshot attempt. + -- Restart increments it before arming any watch. ready_generation remains + -- NULL until every registered subject supplies a current observation for + -- that same generation. + observation_generation BIGINT NOT NULL DEFAULT 1 + CHECK (observation_generation >= 1), + ready_generation BIGINT CHECK (ready_generation IS NULL + OR ready_generation >= 1), + + -- revision changes whenever readiness or semantic availability can + -- change. Admission tokens bind to it and must be revalidated before a + -- critical side effect. + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash 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. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + CHECK ((batch_tx IS NULL AND batch_output_index IS NULL) + OR (batch_tx IS NOT NULL AND batch_output_index IS NOT NULL)), + + PRIMARY KEY (batch_txid) +); + +CREATE TABLE batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_value plus input_pk_script is the authenticated previous output + -- evidence for this actual transaction input. + input_value BIGINT NOT NULL CHECK (input_value >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_consumer_creator_lineage ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + creator_batch_txid BLOB NOT NULL + CHECK (length(creator_batch_txid) = 32), + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, + creator_batch_txid + ), + FOREIGN KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ) REFERENCES batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ) ON DELETE CASCADE +); + +CREATE TABLE batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + + -- expected_vtxo_revision is the exact ForfeitedBy transition this edge + -- owns. A later business transition makes the restore CAS stale. + expected_vtxo_revision BIGINT NOT NULL + CHECK (expected_vtxo_revision > 0), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + CREATE TABLE boarding_addresses ( -- pk_script is the raw output script (P2TR script) and serves as the -- primary key since it uniquely identifies an address. @@ -429,6 +596,21 @@ CREATE INDEX idx_activity_events_canonical CREATE INDEX idx_ask_results_expires ON ask_results(expires_at); +CREATE INDEX idx_batch_canonicality_state + ON batch_canonicality(state); + +CREATE INDEX idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +CREATE INDEX idx_batch_consumer_lineage_creator + ON batch_consumer_creator_lineage(creator_batch_txid); + +CREATE INDEX idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +CREATE INDEX idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); + CREATE INDEX idx_boarding_addresses_creation_time ON boarding_addresses(creation_time DESC); @@ -1701,7 +1883,10 @@ CREATE TABLE vtxos ( -- zero-indexed, so the only understood value today is 0 (V1); a future, -- genuinely different construction is added additively (V2 == 1, and so -- on). NOT NULL DEFAULT 0 keeps every row a valid V1 object. - construction_version INTEGER NOT NULL DEFAULT 0, + construction_version INTEGER NOT NULL DEFAULT 0, business_revision BIGINT NOT NULL DEFAULT 0 + CHECK (business_revision >= 0), forfeit_consumer_txid BLOB + CHECK (forfeit_consumer_txid IS NULL + OR length(forfeit_consumer_txid) = 32), PRIMARY KEY (outpoint_hash, outpoint_index), FOREIGN KEY (round_id) REFERENCES rounds(round_id) diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index 63f8769a2..1980035da 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -129,7 +129,7 @@ func (q *Queries) ListForfeitingVTXOsByRound(ctx context.Context, forfeitRoundID } const ListLiveVTXOs = `-- name: ListLiveVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos WHERE (status < 3 OR status = 7) AND spent = FALSE ORDER BY creation_time DESC ` @@ -175,6 +175,8 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ); err != nil { return nil, err } @@ -190,7 +192,7 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { } const ListRecoverableVTXOs = `-- name: ListRecoverableVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version, business_revision, forfeit_consumer_txid FROM vtxos WHERE (status < 3 OR status = 7 OR status = 8) AND spent = FALSE ORDER BY creation_time DESC ` @@ -236,6 +238,8 @@ func (q *Queries) ListRecoverableVTXOs(ctx context.Context) ([]Vtxo, error) { &i.LastUpdateTime, &i.ChainDepth, &i.ConstructionVersion, + &i.BusinessRevision, + &i.ForfeitConsumerTxid, ); err != nil { return nil, err } @@ -299,7 +303,7 @@ func (q *Queries) ListVTXOSelectionCandidatesByStatus(ctx context.Context, statu const ListVTXOsByStatus = `-- name: ListVTXOsByStatus :many -SELECT vtxos.outpoint_hash, vtxos.outpoint_index, vtxos.round_id, vtxos.amount, vtxos.pk_script, vtxos.expiry, vtxos.policy_template, vtxos.client_key_id, vtxos.operator_pubkey, vtxos.batch_expiry, vtxos.created_height, vtxos.commitment_txid, vtxos.spent, vtxos.status, vtxos.forfeit_round_id, vtxos.forfeit_tx, vtxos.forfeit_txid, vtxos.replaced_by_hash, vtxos.replaced_by_index, vtxos.creation_time, vtxos.last_update_time, vtxos.chain_depth, vtxos.construction_version, +SELECT vtxos.outpoint_hash, vtxos.outpoint_index, vtxos.round_id, vtxos.amount, vtxos.pk_script, vtxos.expiry, vtxos.policy_template, vtxos.client_key_id, vtxos.operator_pubkey, vtxos.batch_expiry, vtxos.created_height, vtxos.commitment_txid, vtxos.spent, vtxos.status, vtxos.forfeit_round_id, vtxos.forfeit_tx, vtxos.forfeit_txid, vtxos.replaced_by_hash, vtxos.replaced_by_index, vtxos.creation_time, vtxos.last_update_time, vtxos.chain_depth, vtxos.construction_version, vtxos.business_revision, vtxos.forfeit_consumer_txid, rounds.commitment_txid AS settlement_txid, rounds.confirmation_height AS settlement_height, CAST(COALESCE(( @@ -377,6 +381,8 @@ func (q *Queries) ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVT &i.Vtxo.LastUpdateTime, &i.Vtxo.ChainDepth, &i.Vtxo.ConstructionVersion, + &i.Vtxo.BusinessRevision, + &i.Vtxo.ForfeitConsumerTxid, &i.SettlementTxid, &i.SettlementHeight, &i.SettlementFeeSat, @@ -398,19 +404,22 @@ const MarkVTXOForfeited = `-- name: MarkVTXOForfeited :exec UPDATE vtxos SET status = 3, -- Forfeited forfeit_txid = $3, - replaced_by_hash = $4, - replaced_by_index = $5, - last_update_time = $6 + forfeit_consumer_txid = $4, + replaced_by_hash = $5, + replaced_by_index = $6, + business_revision = business_revision + 1, + last_update_time = $7 WHERE outpoint_hash = $1 AND outpoint_index = $2 ` type MarkVTXOForfeitedParams struct { - OutpointHash []byte - OutpointIndex int32 - ForfeitTxid []byte - ReplacedByHash []byte - ReplacedByIndex sql.NullInt32 - LastUpdateTime int64 + OutpointHash []byte + OutpointIndex int32 + ForfeitTxid []byte + ForfeitConsumerTxid []byte + ReplacedByHash []byte + ReplacedByIndex sql.NullInt32 + LastUpdateTime int64 } // MarkVTXOForfeited marks a VTXO as forfeited and records the forfeit @@ -421,6 +430,7 @@ func (q *Queries) MarkVTXOForfeited(ctx context.Context, arg MarkVTXOForfeitedPa arg.OutpointHash, arg.OutpointIndex, arg.ForfeitTxid, + arg.ForfeitConsumerTxid, arg.ReplacedByHash, arg.ReplacedByIndex, arg.LastUpdateTime, @@ -433,6 +443,7 @@ UPDATE vtxos SET status = 2, -- Forfeiting forfeit_round_id = $3, forfeit_tx = $4, + business_revision = business_revision + 1, last_update_time = $5 WHERE outpoint_hash = $1 AND outpoint_index = $2 ` @@ -465,6 +476,7 @@ SET status = $3, -- Keep spent flag in sync when status transitions to Spent (4). -- We intentionally do not clear spent once set. spent = CASE WHEN $3 = 4 THEN TRUE ELSE spent END, + business_revision = business_revision + 1, last_update_time = $4 WHERE outpoint_hash = $1 AND outpoint_index = $2 ` From 607644fb261f166d1745aea7994bf474b57ce1cf Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 17:34:39 -0700 Subject: [PATCH 02/16] batchcanon: Add canonicality model, reducer, and manager Add the client-side batch canonicality authority: the reorg-aware data model (Record keyed by txid, ConsumedInput, and the logical ConsumerEdge graph), the dependency-light reducer that derives fail-closed lineage availability, and the Manager that arms reorg-aware watches and runs the versioned Reconcile(g)/Ready(g) restart barrier. Registration authenticates the serialized commitment transaction (its hash and full TxIn set) before a record can reach Ready, so an omitted or unauthenticated input keeps the lineage unavailable. Missing, incomplete, unarmed, or reconciling lineage is never usable. The package depends only on btcd and lnd/fn so the server can reuse the same canonicality semantics. --- batchcanon/AGENTS.md | 100 ++ batchcanon/CLAUDE.md | 100 ++ batchcanon/admission_test.go | 154 ++ batchcanon/availability.go | 217 +++ batchcanon/availability_test.go | 208 +++ batchcanon/doc.go | 26 + batchcanon/manager.go | 1477 +++++++++++++++++ batchcanon/manager_conflict_shared_test.go | 64 + batchcanon/manager_generation_test.go | 152 ++ batchcanon/manager_ordering_test.go | 290 ++++ .../manager_provisional_consumer_test.go | 481 ++++++ batchcanon/manager_test.go | 1266 ++++++++++++++ batchcanon/messages.go | 319 ++++ batchcanon/record.go | 225 +++ batchcanon/record_test.go | 70 + batchcanon/registration_validation_test.go | 169 ++ batchcanon/state.go | 145 ++ batchcanon/state_test.go | 97 ++ batchcanon/store.go | 133 ++ 19 files changed, 5693 insertions(+) create mode 100644 batchcanon/AGENTS.md create mode 100644 batchcanon/CLAUDE.md create mode 100644 batchcanon/admission_test.go create mode 100644 batchcanon/availability.go create mode 100644 batchcanon/availability_test.go create mode 100644 batchcanon/doc.go create mode 100644 batchcanon/manager.go create mode 100644 batchcanon/manager_conflict_shared_test.go create mode 100644 batchcanon/manager_generation_test.go create mode 100644 batchcanon/manager_ordering_test.go create mode 100644 batchcanon/manager_provisional_consumer_test.go create mode 100644 batchcanon/manager_test.go create mode 100644 batchcanon/messages.go create mode 100644 batchcanon/record.go create mode 100644 batchcanon/record_test.go create mode 100644 batchcanon/registration_validation_test.go create mode 100644 batchcanon/state.go create mode 100644 batchcanon/state_test.go create mode 100644 batchcanon/store.go diff --git a/batchcanon/AGENTS.md b/batchcanon/AGENTS.md new file mode 100644 index 000000000..fc599db0c --- /dev/null +++ b/batchcanon/AGENTS.md @@ -0,0 +1,100 @@ +# 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. +- `Availability` — derived (never persisted) VTXO-lineage spendability: + `AvailableFinal`, `AvailableProvisional`, `AvailabilityUnknown`, + `LimboReorg`, `LimboConflict`, `Invalidated`. `AvailabilityForState` + maps one batch's `State`; `CombineAvailability` takes the worst across a + multi-parent lineage; `Usable()` is true only for confirmed lineage. + `LineageAvailability`/`LineageBlocked` load each parent batch from the + `Store` and produce the combined availability / block decision the VTXO + manager's admission gate (C5 wiring) calls per candidate. The gate is + permissive: unseen / not-yet-registered lineage does not block — only + limbo/invalidated lineage does. +- `Store` — behavior-free durable query/update interface. Implemented by + `db.BatchCanonicalityPersistenceStore` over the `000020`/`000021` schema; + backfilled from existing VTXOs via + `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. +- `Manager` — the actor that interprets chain observation into canonicality + state (the sole client-side interpreter). Registered under + `ManagerServiceKey`. `RegisterBatchRequest` arms one reorg-aware + confirmation watch on the batch tx and one reorg-aware spend watch per + consumed input (deduped per batch, idempotent — repeats merge dependent + VTXOs). It maps chainsource `ConfirmationEvent`/`ConfReorgedEvent`/ + `ConfDoneEvent` and `SpendEvent`/`SpendReorgedEvent`/`SpendDoneEvent` onto + its own mailbox and derives `State` per the priority + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. `Reconcile` re-arms watches for non-final + batches after restart without downgrading persisted state. + `GetBatchStateRequest` reads the persisted record. `NewManager` returns the + behavior; the caller registers it, then calls `SetSelfRef(ref.TellRef())` + and `Reconcile`. + +## 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`. +- `waved/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. + diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md new file mode 100644 index 000000000..667521d9e --- /dev/null +++ b/batchcanon/CLAUDE.md @@ -0,0 +1,100 @@ +# batchcanon + +## Purpose + +Client-side **batch canonicality authority** for the reorg-safety epic +(lumos#454). This package is the sole client interpreter of how each batch +(commitment) transaction is faring against the best chain, and it produces the +**fail-closed lineage availability** the VTXO manager's admission gate and the +round/OOR producers consume. + +It owns three things: the durable, reorg-aware **data model** (`Record`, +`ConsumedInput`, `ConsumerEdge`), the dependency-light **reducer** that derives +canonicality `State` and `Availability` from a complete current-chain +observation, and the **`Manager`** actor that arms reorg-aware watches, drives +the versioned snapshot/readiness restart barrier, and interprets chainsource +observations into state. + +Observation (chainsource) → interpretation (this package) → admission (vtxo) +stays a strict split: chainsource reports raw reversible facts, this package +decides canonicality, and the VTXO manager remains the admission boundary. + +## Key Types + +- `State` — canonicality state: `StateUnseen`, `StateProvisional`, + `StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`, + `StateConflictFinalized`. Every state is reorg-reversible; **no state is a + terminal verdict** except a conflict that reached policy finality. Priority: + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. Persisted as an append-only typed INTEGER — + values must never be renumbered. +- `RegistrationStage` — crash-safe evidence lifecycle: `Registering` → + `Reconciling` → `Complete`. Semantic `State` is **never** admissible unless + the stage is `Complete` and `ReadyGeneration == ObservationGeneration`. +- `Record` — durable per-batch view keyed by **`BatchTxID`** (never + `(txid, block hash)`; a reorg that re-mines the same tx is the same batch). + Carries `BatchTx` (the serialized commitment tx, authenticated to hash to + `BatchTxID`), `ObservationGeneration`/`ReadyGeneration`/`Revision`, + `ConfirmationHeight`/`Block` (observation attributes; cleared on reorg), + `CSVExpiryDelta`, `ConsumedInputs`, and `DependentVTXOs`. `Ready()` is true + only with complete evidence + `Complete` stage + a matching ready generation. + `EffectiveExpiry()` derives absolute expiry on demand + (`ConfirmationHeight + CSVExpiryDelta`), so a reorg-and-reconfirm recomputes + it instead of freezing it. +- `ConsumedInput` — one actual `TxIn` the batch spends, with `Value` + + `PkScript` (required to arm the reorg-aware spend watch) and persisted + `Conflicting`/`ConflictFinal` flags so restart reconciliation cannot + transiently downgrade a persisted conflict. +- `ConsumerEdge` — the **logical value-lineage** edge (a VTXO consumed by a + batch), separate from the on-chain `ConsumedInputs` graph. Carries + `ExpectedRevision` and the full `CreatorLineage`; used by the terminal + conditional-restore compare-and-swap, not mislabeled as a commitment input. +- `Availability` — derived (never persisted) VTXO-lineage spendability: + `AvailableFinal`, `AvailableProvisional`, `AvailabilityUnknown`, + `LineageReconciling`, `LimboReorg`, `LimboConflict`, `Invalidated`. + **Fail-closed**: a missing record, a non-`Ready()` record, or an empty + lineage all map to `LineageReconciling`; `Usable()` is true only for + `AvailableFinal`/`AvailableProvisional`. `CombineAvailability` takes the + worst across a multi-parent lineage. +- `AdmissionToken` — the linearizable guard returned by a successful lineage + query, binding the observation generation + lineage revision; producers + revalidate it before each critical effect. +- `Manager` — the actor interpreter. Arms one reorg-aware confirmation watch + on the batch tx plus one reorg-aware spend watch per consumed input; + registration cross-checks the serialized `BatchTx` (hash == `BatchTxID`, + output/pkScript bound, every `TxIn` registered) before a row can reach + `Ready`. `Reconcile(g)` runs the restart barrier: it opens a new observation + generation, re-arms watches, requires an explicit current fact per subject, + and only installs `Ready(g)` + derived state atomically — admission stays + closed until then, so a persisted conflict can never transiently look usable. + +## Relationships + +- **Depends on**: `btcd/chainhash`, `btcd/wire`, `lnd/fn/v2` only (plus + `baselib/actor` + `chainsource` for the `Manager`). The reducer/model + (`state.go`, `availability.go`, `record.go`) is deliberately dependency-light + so the **server can reuse the same reducer** (lumos#454 Server PR2). +- **Depended on by**: `db` (concrete `Store`), `vtxo` (admission gate), and the + round/OOR producers (registration before exposure). + +## Invariants + +- Identity is by txid / outpoint, never `(txid, block hash)`. +- Fail-closed: missing / incomplete / unarmed / reconciling lineage is never + usable. Registration completeness is part of the safety proof, not a + compatibility hint. +- Registration authenticates the serialized commitment tx (hash + full `TxIn` + set) before a record reaches `Ready`; an omitted or unauthenticated input + keeps the record unavailable. +- Two graphs: the on-chain `ConsumedInputs` (`TxIn`) graph drives conflict + observation; the logical `ConsumerEdge` graph drives inherited lineage and + conditional restore. +- Expiry is never persisted as a standalone/terminal value; always derived. +- State enum integer values are append-only (persisted column). +- Restart increments the observation generation before any watch is armed; + admission is closed until `Ready(g)` installs a complete snapshot. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. +- `REORG_SAFETY_SPEC.md` (workspace root) — normative §3–§9 contracts. diff --git a/batchcanon/admission_test.go b/batchcanon/admission_test.go new file mode 100644 index 000000000..58a9bf7a2 --- /dev/null +++ b/batchcanon/admission_test.go @@ -0,0 +1,154 @@ +package batchcanon + +import ( + "errors" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/stretchr/testify/require" +) + +// TestAdmissionTokenTracksReadyGenerationAndRevision proves that admission +// remains closed until every watched subject contributes to Ready(g), and that +// a later canonicality change invalidates the issued token before a critical +// side effect can use it. +func TestAdmissionTokenTracksReadyGenerationAndRevision(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xa7) + input := testOutpoint(0xa8, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0xa7}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + + query := func() *QueryLineageResponse { + resp, err := h.mgrRef.Ask( + t.Context(), &QueryLineageRequest{ + BatchTxIDs: []chainhash.Hash{txid}, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + lineage, ok := resp.(*QueryLineageResponse) + require.True(t, ok) + + return lineage + } + + // Merely arming every watch is not Ready(g): no subject has supplied a + // current observation yet. + result := query() + require.Equal(t, LineageReconciling, result.Availability) + require.Nil(t, result.Token) + + // The confirmation alone is still incomplete because the actual input + // spend has not been observed for this generation. + h.fireConfirmed(t, txid, 101, testBatchTxid(0xb1)) + result = query() + require.Equal(t, LineageReconciling, result.Availability) + require.Nil(t, result.Token) + + // The batch's own spend supplies the final subject observation. + // Ready(g) is installed and a revision-bound token can now be issued. + h.fireSpend(t, input, txid, 101) + result = query() + require.Equal(t, AvailableProvisional, result.Availability) + require.NotNil(t, result.Token) + require.Len(t, result.Token.Lineage, 1) + token := *result.Token + + validation, err := h.mgrRef.Ask( + t.Context(), &ValidateAdmissionRequest{Token: token}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + valid, ok := validation.(*ValidateAdmissionResponse) + require.True(t, ok) + require.True(t, valid.Valid) + require.Equal(t, AvailableProvisional, valid.Availability) + + // A reorg changes semantic availability and the durable revision. The + // old token is stale and cannot cross a point of no return. + h.fireConfReorged(t, txid) + validation, err = h.mgrRef.Ask( + t.Context(), &ValidateAdmissionRequest{Token: token}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + valid, ok = validation.(*ValidateAdmissionResponse) + require.True(t, ok) + require.False(t, valid.Valid) + require.Equal(t, LimboReorg, valid.Availability) +} + +// TestAdmissionFailsClosedOnObservationPersistenceError proves the manager's +// in-memory overlay cannot issue or validate a token from an old durable +// usable row after a newer chain observation failed to commit. +func TestAdmissionFailsClosedOnObservationPersistenceError(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xb7) + input := testOutpoint(0xb8, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0xb7}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0xc1)) + h.fireSpend(t, input, txid, 101) + + query := func() *QueryLineageResponse { + resp, err := h.mgrRef.Ask( + t.Context(), &QueryLineageRequest{ + BatchTxIDs: []chainhash.Hash{txid}, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + queryResp, ok := resp.(*QueryLineageResponse) + require.True(t, ok) + + return queryResp + } + + admitted := query() + require.Equal(t, AvailableProvisional, admitted.Availability) + require.NotNil(t, admitted.Token) + oldToken := *admitted.Token + + h.store.setApplyError(errors.New("injected durable write failure")) + h.fireConfReorged(t, txid) + + // The SQL-equivalent fake still contains the previously usable row, but + // the serialized manager observed a newer event and must fail closed. + durable, err := h.store.GetBatch(t.Context(), txid) + require.NoError(t, err) + require.Equal(t, StateProvisional, durable.State) + require.True(t, durable.Ready()) + blocked := query() + require.Equal(t, LineageReconciling, blocked.Availability) + require.Nil(t, blocked.Token) + + resp, err := h.mgrRef.Ask( + t.Context(), &ValidateAdmissionRequest{Token: oldToken}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + validation, ok := resp.(*ValidateAdmissionResponse) + require.True(t, ok) + require.False(t, validation.Valid) + require.Equal(t, LineageReconciling, validation.Availability) + + // A later full-snapshot write can safely recover without replaying the + // failed operation. It persists all retained in-memory observations and + // issues a different revision-bound token. + h.store.setApplyError(nil) + h.fireConfirmed(t, txid, 102, testBatchTxid(0xc2)) + recovered := query() + require.Equal(t, AvailableProvisional, recovered.Availability) + require.NotNil(t, recovered.Token) + require.NotEqual( + t, oldToken.Lineage[0].Revision, + recovered.Token.Lineage[0].Revision, + ) +} diff --git a/batchcanon/availability.go b/batchcanon/availability.go new file mode 100644 index 000000000..7ec4a97ab --- /dev/null +++ b/batchcanon/availability.go @@ -0,0 +1,217 @@ +package batchcanon + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" +) + +// Availability is the derived spendability of a VTXO's lineage, computed from +// the canonicality State of the batch(es) the VTXO descends from. It is the +// vocabulary the VTXO manager's admission gate and the producers consume; it +// is never persisted (it is always recomputed from the current batch State). +type Availability int + +const ( + // AvailableFinal means every parent batch reached policy finality. The + // VTXO is usable and its lineage is as settled as policy allows. + AvailableFinal Availability = iota + + // AvailableProvisional means every parent batch is confirmed but not + // yet final. The VTXO is usable at one-confirmation usability depth, + // but the lineage could still reorg. + AvailableProvisional + + // AvailabilityUnknown means at least one parent batch has no + // confirmation observation yet (unseen), and none is in limbo or + // invalidated. The lineage is not yet usable, but nothing is wrong. + AvailabilityUnknown + + // LineageReconciling means at least one lineage record is missing, + // incomplete, quarantined, or lacks a Ready snapshot for its current + // observation generation. It is a retryable fail-closed result. + LineageReconciling + + // LimboReorg means at least one parent batch was reorged out with no + // input conflict. The VTXO is temporarily unusable and may recover if + // the batch reconfirms. + LimboReorg + + // LimboConflict means at least one parent batch has a consumed input + // double-spent by a conflicting transaction that has not yet reached + // finality. The VTXO is unusable and may recover only if the conflict + // reorgs out. + LimboConflict + + // Invalidated means at least one parent batch has a consumed-input + // conflict that reached policy finality. The VTXO is terminally + // unusable within the configured basic-v1 claim. + Invalidated +) + +// availabilityRank orders availabilities from most to least available, so the +// combined availability of a multi-parent lineage is the worst (highest rank) +// of its parents. +func availabilityRank(a Availability) int { + switch a { + case AvailableFinal: + return 0 + + case AvailableProvisional: + return 1 + + case AvailabilityUnknown: + return 2 + + case LineageReconciling: + return 6 + + case LimboReorg: + return 3 + + case LimboConflict: + return 4 + + case Invalidated: + return 5 + + default: + return 2 + } +} + +// String returns a stable lower-snake-case name for the availability. +func (a Availability) String() string { + switch a { + case AvailableFinal: + return "available_final" + + case AvailableProvisional: + return "available_provisional" + + case AvailabilityUnknown: + return "lineage_unseen" + + case LineageReconciling: + return "lineage_reconciling" + + case LimboReorg: + return "limbo_reorg" + + case LimboConflict: + return "limbo_conflict" + + case Invalidated: + return "invalidated" + + default: + return fmt.Sprintf("unknown(%d)", int(a)) + } +} + +// Usable reports whether a VTXO with this lineage availability may be admitted +// for spending or forfeiting. Only confirmed lineage (provisional or final) is +// usable; unseen, limbo, and invalidated lineage is not. +func (a Availability) Usable() bool { + return a == AvailableFinal || a == AvailableProvisional +} + +// AvailabilityForState maps a single batch's canonicality State to the +// availability it confers on its dependent VTXOs. +func AvailabilityForState(s State) Availability { + switch s { + case StateFinalized: + return AvailableFinal + + case StateProvisional: + return AvailableProvisional + + case StateReorgedOut: + return LimboReorg + + case StateConflictProvisional: + return LimboConflict + + case StateConflictFinalized: + return Invalidated + + case StateUnseen: + return AvailabilityUnknown + + default: + return AvailabilityUnknown + } +} + +// CombineAvailability returns the availability of a VTXO that depends on +// several parent batches: a VTXO is only as available as its least-available +// parent (the worst rank). With no parents it returns LineageReconciling. +func CombineAvailability(parents ...Availability) Availability { + if len(parents) == 0 { + return LineageReconciling + } + + worst := parents[0] + for _, p := range parents[1:] { + if availabilityRank(p) > availabilityRank(worst) { + worst = p + } + } + + return worst +} + +// LineageAvailability returns the combined availability of a VTXO that +// descends from the given batch txids, loading each batch's canonicality +// state from the store and taking the worst across them. A batch with no +// record yet maps to AvailabilityUnknown. Missing lineage is never usable: +// registration completeness is part of the safety proof, not a compatibility +// hint. With no txids it returns LineageReconciling. +// +// This is the gate logic the VTXO manager calls per candidate: a VTXO is +// admissible only when LineageAvailability(...).Usable(). +func LineageAvailability(ctx context.Context, store Reader, + batchTxids ...chainhash.Hash) (Availability, error) { + + if len(batchTxids) == 0 { + return LineageReconciling, nil + } + + avails := make([]Availability, 0, len(batchTxids)) + for _, txid := range batchTxids { + record, err := store.GetBatch(ctx, txid) + switch { + case errors.Is(err, ErrBatchNotFound): + avails = append(avails, LineageReconciling) + + case err != nil: + return AvailabilityUnknown, err + + case !record.Ready(): + avails = append(avails, LineageReconciling) + + default: + avails = append( + avails, AvailabilityForState(record.State), + ) + } + } + + return CombineAvailability(avails...), nil +} + +// LineageBlocked reports whether a VTXO descending from the given batches must +// be refused admission. Only fully registered, confirmed lineage is usable; +// unseen, missing, limbo, and invalidated lineage all fail closed. +func LineageBlocked(ctx context.Context, store Reader, + batchTxids ...chainhash.Hash) (bool, Availability, error) { + + avail, err := LineageAvailability(ctx, store, batchTxids...) + if err != nil { + return false, avail, err + } + + return !avail.Usable(), avail, nil +} diff --git a/batchcanon/availability_test.go b/batchcanon/availability_test.go new file mode 100644 index 000000000..7db72bbf3 --- /dev/null +++ b/batchcanon/availability_test.go @@ -0,0 +1,208 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestAvailabilityForState pins the State -> Availability mapping. +func TestAvailabilityForState(t *testing.T) { + t.Parallel() + + cases := []struct { + state State + want Availability + }{ + { + StateFinalized, + AvailableFinal, + }, + { + StateProvisional, + AvailableProvisional, + }, + { + StateUnseen, + AvailabilityUnknown, + }, + { + StateReorgedOut, + LimboReorg, + }, + { + StateConflictProvisional, + LimboConflict, + }, + { + StateConflictFinalized, + Invalidated, + }, + } + + for _, tc := range cases { + require.Equal( + t, tc.want, AvailabilityForState(tc.state), + tc.state.String(), + ) + } +} + +// TestAvailabilityUsable verifies only confirmed lineage is usable. +func TestAvailabilityUsable(t *testing.T) { + t.Parallel() + + require.True(t, AvailableFinal.Usable()) + require.True(t, AvailableProvisional.Usable()) + require.False(t, AvailabilityUnknown.Usable()) + require.False(t, LineageReconciling.Usable()) + require.False(t, LimboReorg.Usable()) + require.False(t, LimboConflict.Usable()) + require.False(t, Invalidated.Usable()) +} + +// TestCombineAvailability verifies a multi-parent lineage takes the worst +// (least-available) parent. +func TestCombineAvailability(t *testing.T) { + t.Parallel() + + require.Equal(t, LineageReconciling, CombineAvailability()) + + // All final -> final. + require.Equal( + t, AvailableFinal, CombineAvailability( + AvailableFinal, AvailableFinal, + ), + ) + + // A provisional parent downgrades a final one. + require.Equal( + t, AvailableProvisional, CombineAvailability( + AvailableFinal, AvailableProvisional, + ), + ) + + // Any limbo dominates available parents. + require.Equal( + t, LimboReorg, CombineAvailability( + AvailableFinal, AvailableProvisional, LimboReorg, + ), + ) + + // Conflict limbo dominates reorg limbo. + require.Equal( + t, LimboConflict, CombineAvailability( + LimboReorg, LimboConflict, + ), + ) + + // Invalidated dominates everything. + require.Equal( + t, Invalidated, CombineAvailability( + AvailableFinal, LimboConflict, Invalidated, + AvailableProvisional, + ), + ) + + // Unknown dominates available but not limbo/invalidated. + require.Equal( + t, AvailabilityUnknown, CombineAvailability( + AvailableProvisional, AvailabilityUnknown, + ), + ) + require.Equal( + t, LimboReorg, CombineAvailability( + AvailabilityUnknown, LimboReorg, + ), + ) + + // Readiness is an outer gate and dominates every semantic state. + require.Equal( + t, LineageReconciling, CombineAvailability( + Invalidated, LineageReconciling, + ), + ) +} + +// TestAvailabilityStringStable pins the string names. +func TestAvailabilityStringStable(t *testing.T) { + t.Parallel() + + require.Equal(t, "available_final", AvailableFinal.String()) + require.Equal(t, "available_provisional", AvailableProvisional.String()) + require.Equal(t, "lineage_unseen", AvailabilityUnknown.String()) + require.Equal(t, "lineage_reconciling", LineageReconciling.String()) + require.Equal(t, "limbo_reorg", LimboReorg.String()) + require.Equal(t, "limbo_conflict", LimboConflict.String()) + require.Equal(t, "invalidated", Invalidated.String()) +} + +// TestLineageAvailabilityFromStore exercises the store-driven lineage gate: +// it combines the worst availability across a VTXO's parent batches, treats a +// missing record as reconciling and fails closed unless every record is ready +// and semantically usable. +func TestLineageAvailabilityFromStore(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newFakeStore() + + finalTx := chainhash.Hash{0x01} + reorgTx := chainhash.Hash{0x02} + conflictTx := chainhash.Hash{0x03} + missingTx := chainhash.Hash{0x04} + + put := func(txid chainhash.Hash, st State) { + record := &Record{ + BatchTxID: txid, + State: st, + } + completeTestRecordEvidence(record) + record.RegistrationStage = RegistrationComplete + record.ObservationGeneration = 1 + record.ReadyGeneration = fn.Some[uint64](1) + require.NoError( + t, store.UpsertBatch(ctx, record), + ) + } + put(finalTx, StateFinalized) + put(reorgTx, StateReorgedOut) + put(conflictTx, StateConflictFinalized) + + // Single finalized parent: available, not blocked. + avail, err := LineageAvailability(ctx, store, finalTx) + require.NoError(t, err) + require.Equal(t, AvailableFinal, avail) + blocked, _, err := LineageBlocked(ctx, store, finalTx) + require.NoError(t, err) + require.False(t, blocked) + + // A reorged parent alongside a final one: limbo, blocked. + avail, err = LineageAvailability(ctx, store, finalTx, reorgTx) + require.NoError(t, err) + require.Equal(t, LimboReorg, avail) + blocked, _, err = LineageBlocked(ctx, store, finalTx, reorgTx) + require.NoError(t, err) + require.True(t, blocked) + + // An invalidated parent dominates: blocked. + blocked, avail, err = LineageBlocked(ctx, store, finalTx, conflictTx) + require.NoError(t, err) + require.True(t, blocked) + require.Equal(t, Invalidated, avail) + + // A missing (unregistered) parent is reconciling and fails closed. + avail, err = LineageAvailability(ctx, store, finalTx, missingTx) + require.NoError(t, err) + require.Equal(t, LineageReconciling, avail) + blocked, _, err = LineageBlocked(ctx, store, finalTx, missingTx) + require.NoError(t, err) + require.True(t, blocked) + + // No parents: unknown and blocked. + blocked, _, err = LineageBlocked(ctx, store) + require.NoError(t, err) + require.True(t, blocked) +} diff --git a/batchcanon/doc.go b/batchcanon/doc.go new file mode 100644 index 000000000..59a5064de --- /dev/null +++ b/batchcanon/doc.go @@ -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 diff --git a/batchcanon/manager.go b/batchcanon/manager.go new file mode 100644 index 000000000..e60a1fa94 --- /dev/null +++ b/batchcanon/manager.go @@ -0,0 +1,1477 @@ +package batchcanon + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/build" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// ManagerServiceKey is the receptionist key the BatchCanonicalityManager +// registers under. +var ManagerServiceKey = actor.NewServiceKey[ManagerMsg, ManagerResp]( + "batch-canonicality", +) + +// usabilityConfs is the confirmation count at which the manager wants the +// first positive confirmation notification. Ark's usability depth is one +// confirmation: a batch is provisionally usable as soon as it confirms, and +// the reorg-aware lifecycle keeps it correct from there. Policy finality is +// signalled separately by chainsource's Done event at its FinalityDepth. +const usabilityConfs uint32 = 1 + +// confState is the manager's in-memory view of a batch tx's confirmation +// observation, distinct from any input-conflict view. +type confState int + +const ( + confUnseen confState = iota + confConfirmed + confFinalized + confReorgedOut +) + +// inputWatch tracks the conflict view of one consumed batch input. +type inputWatch struct { + // pkScript is the previous output script used to arm and later cancel + // this input's spend watch. + pkScript []byte + + // observed reports that this subject supplied a current observation for + // the watch's reconciliation generation. + observed bool + + // doneObserved records that this generation's spend watch reported + // policy finality. SpendDoneEvent intentionally carries no spender + // identity, so an early Done cannot classify the input by itself. + // Keeping the evidence lets a subsequently queued SpendEvent perform + // that classification without losing the terminal signal. + doneObserved bool + + // spenderIsConflict records whether the last observed spend of this + // input was by a transaction other than the batch itself. The batch + // consuming its own input is the expected, non-conflicting case. + spenderIsConflict bool + + // conflicting is true while a conflicting spend is observed and has not + // been reorged out. + conflicting bool + + // conflictFinal is true once a conflicting spend matured past the + // reorg-safety depth. + conflictFinal bool +} + +// batchWatch is the manager's in-memory state for one watched batch. +type batchWatch struct { + txid chainhash.Hash + pkScript []byte + + conf confState + inputs map[wire.OutPoint]*inputWatch + + confHeight fn.Option[int32] + confBlock fn.Option[chainhash.Hash] + + // generation is the durable observation generation this watch set + // serves. confObserved plus every input's observed bit forms Ready(g). + generation uint64 + confObserved bool + ready bool + + // persisted is the State last written to the store, so the manager only + // issues an UpdateBatchState when the derived state actually changes. + persisted State +} + +// ManagerConfig configures the BatchCanonicalityManager. +type ManagerConfig struct { + // Store is the durable canonicality store. + Store Store + + // ChainSource is the chain-observation actor the manager registers + // reorg-aware conf/spend watches with. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // ActivateRestoredVTXO asks the VTXO manager to materialize an actor + // after the store atomically restored the exact business marker and + // completed its edge. A callback failure cannot undo safety: the Live + // row is recovered at startup or lazily by selection. + ActivateRestoredVTXO func(ctx context.Context, vtxo wire.OutPoint) error + + // Log is an optional logger. + Log fn.Option[btclog.Logger] + + // allowIncompleteTestEvidence preserves old unit fixtures while the + // production registration paths are migrated to serialized evidence. It + // is intentionally unexported and therefore cannot be enabled by daemon + // wiring outside this package. + allowIncompleteTestEvidence bool +} + +// Manager is the sole client-side interpreter of batch canonicality. It +// observes (via chainsource) each batch tx confirmation and each consumed +// input, interprets the reorg-aware lifecycle into batchcanon.State, and +// persists the result. It is an actor behavior: chainsource events arrive as +// internal messages re-wrapped onto the manager's own mailbox. +// +// Registration checks the claimed input set against the serialized batch +// transaction before persisting anything. Spend watches use each +// authenticated prevout pkScript, which lnd's notifier requires. Incomplete +// evidence fails the whole registration closed. +type Manager struct { + cfg ManagerConfig + log btclog.Logger + selfRef actor.TellOnlyRef[ManagerMsg] + + watches map[chainhash.Hash]*batchWatch +} + +// NewManager builds a BatchCanonicalityManager behavior. SetSelfRef must be +// called (with the registered actor's TellRef) before any batch is registered +// so the manager can route chainsource events back to itself. +func NewManager(cfg ManagerConfig) *Manager { + return &Manager{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + watches: make(map[chainhash.Hash]*batchWatch), + } +} + +// SetSelfRef wires the manager's own mailbox ref, used to build the mapped +// chainsource notification refs. +func (m *Manager) SetSelfRef(ref actor.TellOnlyRef[ManagerMsg]) { + m.selfRef = ref +} + +// Receive implements actor.ActorBehavior. It serializes all canonicality +// mutations through the single actor mailbox. +func (m *Manager) Receive(ctx context.Context, + msg ManagerMsg) fn.Result[ManagerResp] { + + switch v := msg.(type) { + case *RegisterBatchRequest: + return m.handleRegisterBatch(ctx, v) + + case *GetBatchStateRequest: + return m.handleGetBatchState(ctx, v) + + case *QueryLineageRequest: + return m.handleQueryLineage(ctx, v) + + case *ValidateAdmissionRequest: + return m.handleValidateAdmission(ctx, v) + + case *batchConfirmedMsg: + m.handleBatchConfirmed(ctx, v) + + case *batchReorgedMsg: + m.handleBatchReorged(ctx, v) + + case *batchDoneMsg: + m.handleBatchDone(ctx, v) + + case *inputSpentMsg: + m.handleInputSpent(ctx, v) + + case *inputSpendReorgedMsg: + m.handleInputSpendReorged(ctx, v) + + case *inputSpendDoneMsg: + m.handleInputSpendDone(ctx, v) + + default: + return fn.Err[ManagerResp]( + fmt.Errorf("unknown batchcanon message: %T", msg), + ) + } + + return fn.Ok[ManagerResp](&ackResponse{}) +} + +// logger returns the configured logger, falling back to the context logger. +func (m *Manager) logger(ctx context.Context) btclog.Logger { + return m.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx)) +} + +// handleRegisterBatch persists the batch record and arms its watches. It is +// idempotent: a repeat for the same batch merges the dependent VTXOs into the +// record without re-arming watches. +func (m *Manager) handleRegisterBatch(ctx context.Context, + req *RegisterBatchRequest) fn.Result[ManagerResp] { + + if err := validateRegistration( + req, m.cfg.allowIncompleteTestEvidence, + ); err != nil { + return fn.Err[ManagerResp](err) + } + + // RegisterBatch commits the batch, every actual input, every dependent, + // and every reverse consumer edge in one transaction. A repeated call + // validates immutable evidence and only merges monotonic edges; it + // never replaces conflict observations with an unseen record. + batchTx := req.BatchTx + if len(batchTx) == 0 && m.cfg.allowIncompleteTestEvidence { + // Keep Record.Ready's production invariant honest while + // preserving focused manager fixtures that do not construct + // wire transactions. The bypass is unexported and cannot be + // enabled by daemon wiring. + batchTx = []byte{0x00} + } + record := &Record{ + BatchTxID: req.BatchTxID, + BatchTx: batchTx, + BatchOutputIndex: req.BatchOutputIndex, + RegistrationStage: RegistrationRegistering, + ObservationGeneration: 1, + ReadyGeneration: fn.None[uint64](), + State: StateUnseen, + ConfirmationHeight: fn.None[int32](), + ConfirmationBlock: fn.None[chainhash.Hash](), + CSVExpiryDelta: req.CSVExpiryDelta, + PolicyState: PolicyStateDefault, + ConfirmationPkScript: req.ConfirmationPkScript, + ConsumedInputs: req.ConsumedInputs, + DependentVTXOs: req.DependentVTXOs, + } + if err := m.cfg.Store.RegisterBatch( + ctx, record, req.ConsumedVTXOs, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("register complete batch evidence: %w", err), + ) + } + + // Load the authoritative record after registration. This matters when a + // caller retries after restart before Reconcile: RegisterBatch + // preserves the prior state and input conflict flags, so the in-memory + // watch must be seeded from those durable observations rather than from + // req. + record, err := m.cfg.Store.GetBatch(ctx, req.BatchTxID) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("load registered batch evidence: %w", err), + ) + } + + // A retry may add an edge after this batch already reached a terminal + // state (for example, replay after the consumed VTXO's forfeiture was + // persisted). No chain callback will arrive to drive that new edge, so + // resolve it from the already-ready objective terminal evidence now. + if record.Ready() && terminalState(record.State) { + m.handleConsumerLifecycle(ctx, record.BatchTxID, record.State) + } + + if _, ok := m.watches[req.BatchTxID]; ok { + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) + } + + if record.State == StateFinalized || + record.State == StateConflictFinalized { + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) + } + + w := watchFromRecord(record) + if err := m.armWatches(ctx, w, record.ConsumedInputs); err != nil { + return fn.Err[ManagerResp](err) + } + m.watches[req.BatchTxID] = w + + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) +} + +// validateRegistration rejects incomplete evidence before any durable write or +// chain side effect. Every actual input needs a script because a missing spend +// watch creates an undetectable conflict surface. +func validateRegistration(req *RegisterBatchRequest, + allowIncompleteTestEvidence bool) error { + + if req.BatchTxID == (chainhash.Hash{}) { + return fmt.Errorf("batch txid is required") + } + if len(req.ConfirmationPkScript) == 0 { + return fmt.Errorf("batch confirmation pkScript is required") + } + if len(req.ConsumedInputs) == 0 { + return fmt.Errorf("batch must register every consumed input") + } + if len(req.ForfeitedVTXOs) != 0 { + return fmt.Errorf("legacy forfeited VTXO evidence lacks " + + "creator lineage and business revision") + } + + seen := make(map[wire.OutPoint]struct{}, len(req.ConsumedInputs)) + for i, in := range req.ConsumedInputs { + if in.Value < 0 { + return fmt.Errorf("consumed input %d (%s) has "+ + "negative value", i, in.Outpoint) + } + if len(in.PkScript) == 0 { + return fmt.Errorf("consumed input %d (%s) has no "+ + "pkScript", i, in.Outpoint) + } + if _, ok := seen[in.Outpoint]; ok { + return fmt.Errorf("consumed input %s is duplicated", + in.Outpoint) + } + seen[in.Outpoint] = struct{}{} + } + + if len(req.BatchTx) == 0 && allowIncompleteTestEvidence { + return nil + } + if len(req.BatchTx) == 0 { + return fmt.Errorf("serialized batch transaction is required") + } + + reader := bytes.NewReader(req.BatchTx) + tx := wire.NewMsgTx(2) + if err := tx.Deserialize(reader); err != nil { + return fmt.Errorf("decode serialized batch transaction: %w", + err) + } + if reader.Len() != 0 { + return fmt.Errorf("serialized batch transaction has %d "+ + "trailing bytes", reader.Len()) + } + if tx.TxHash() != req.BatchTxID { + return fmt.Errorf("serialized batch transaction hash does not "+ + "match %s", req.BatchTxID) + } + if uint64(req.BatchOutputIndex) >= uint64(len(tx.TxOut)) { + return fmt.Errorf("batch output index %d is out of range", + req.BatchOutputIndex) + } + if !bytes.Equal( + tx.TxOut[req.BatchOutputIndex].PkScript, + req.ConfirmationPkScript, + ) { + return fmt.Errorf("batch output %d does not match "+ + "confirmation pkScript", req.BatchOutputIndex) + } + if len(tx.TxIn) != len(req.ConsumedInputs) { + return fmt.Errorf("serialized batch transaction has %d "+ + "inputs, registration has %d", len(tx.TxIn), + len(req.ConsumedInputs)) + } + for _, txIn := range tx.TxIn { + if _, ok := seen[txIn.PreviousOutPoint]; !ok { + return fmt.Errorf("serialized batch transaction input "+ + "%s is not registered", txIn.PreviousOutPoint) + } + } + + consumedVTXOs := make( + map[wire.OutPoint]struct{}, len(req.ConsumedVTXOs), + ) + for i, edge := range req.ConsumedVTXOs { + if _, duplicate := consumedVTXOs[edge.ConsumedVTXO]; duplicate { + return fmt.Errorf("consumed VTXO %s is duplicated", + edge.ConsumedVTXO) + } + consumedVTXOs[edge.ConsumedVTXO] = struct{}{} + if edge.ConsumerBatch != (chainhash.Hash{}) && + edge.ConsumerBatch != req.BatchTxID { + return fmt.Errorf("consumed VTXO %d names a different "+ + "consumer batch", i) + } + if edge.ExpectedRevision == 0 { + return fmt.Errorf("consumed VTXO %d has no expected "+ + "business revision", i) + } + if len(edge.CreatorLineage) == 0 { + return fmt.Errorf("consumed VTXO %d has no "+ + "creator lineage", i) + } + lineage := make( + map[chainhash.Hash]struct{}, len(edge.CreatorLineage), + ) + for _, ancestor := range edge.CreatorLineage { + if ancestor == (chainhash.Hash{}) { + return fmt.Errorf("consumed VTXO %d has a "+ + "zero creator lineage txid", i) + } + if _, duplicate := lineage[ancestor]; duplicate { + return fmt.Errorf("consumed VTXO %d "+ + "duplicates creator lineage txid %s", i, + ancestor) + } + lineage[ancestor] = struct{}{} + } + } + + return nil +} + +// armWatches registers the reorg-aware confirmation watch on the batch tx and +// a reorg-aware spend watch on each consumed input. +func (m *Manager) armWatches(ctx context.Context, w *batchWatch, + inputs []ConsumedInput) error { + + if len(w.pkScript) == 0 { + return fmt.Errorf("batch %s has no confirmation pkScript", + w.txid) + } + if len(inputs) == 0 { + return fmt.Errorf("batch %s has no complete consumed-input set", + w.txid) + } + for _, in := range inputs { + if len(in.PkScript) == 0 { + return fmt.Errorf("batch %s input %s has no pkScript", + w.txid, in.Outpoint) + } + } + + heightHint := m.bestHeightHint(ctx) + + confReq := &chainsource.RegisterConfRequest{ + CallerID: confCallerID(w.txid), + Txid: &w.txid, + PkScript: w.pkScript, + TargetConfs: usabilityConfs, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapConfirmationEvent( + m.selfRef, + func( + ce chainsource.ConfirmationEvent, + ) ManagerMsg { + + return &batchConfirmedMsg{ + txid: ce.Txid, + generation: w.generation, + blockHeight: ce.BlockHeight, + blockHash: ce.BlockHash, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapConfReorgedEvent( + m.selfRef, + func( + ev chainsource.ConfReorgedEvent, + ) ManagerMsg { + + return &batchReorgedMsg{ + txid: ev.Txid, + generation: w.generation, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapConfDoneEvent( + m.selfRef, + func(ev chainsource.ConfDoneEvent) ManagerMsg { + return &batchDoneMsg{ + txid: ev.Txid, + generation: w.generation, + } + }, + ), + ), + } + result := m.cfg.ChainSource.Ask(ctx, confReq).Await(ctx) + if _, err := result.Unpack(); err != nil { + return fmt.Errorf("register batch conf watch: %w", err) + } + + armedInputs := make([]ConsumedInput, 0, len(inputs)) + for i := range inputs { + if err := m.armSpendWatch( + ctx, w.txid, w.generation, inputs[i], heightHint, + ); err != nil { + + m.releaseWatchSet(ctx, w, armedInputs, true) + + return err + } + armedInputs = append(armedInputs, inputs[i]) + } + + return nil +} + +// armSpendWatch registers one reorg-aware spend watch on a consumed input. The +// input's pkScript is forwarded to the spend notifier, which filters by output +// script; without it lnd rejects the registration ("an output script must be +// provided") and the conflict-detection watch never arms. +func (m *Manager) armSpendWatch(ctx context.Context, txid chainhash.Hash, + generation uint64, in ConsumedInput, heightHint uint32) error { + + op := in.Outpoint + spendReq := &chainsource.RegisterSpendRequest{ + CallerID: spendCallerID(txid, op), + Outpoint: &op, + PkScript: in.PkScript, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapSpendEvent( + m.selfRef, + func(ev chainsource.SpendEvent) ManagerMsg { + return &inputSpentMsg{ + batchTxid: txid, + generation: generation, + outpoint: ev.Outpoint, + spendingTxid: ev.SpendingTxid, + spendHeight: ev.SpendingHeight, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapSpendReorgedEvent( + m.selfRef, + func( + ev chainsource.SpendReorgedEvent, + ) ManagerMsg { + + return &inputSpendReorgedMsg{ + batchTxid: txid, + generation: generation, + outpoint: ev.Outpoint, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapSpendDoneEvent( + m.selfRef, + func(ev chainsource.SpendDoneEvent) ManagerMsg { + return &inputSpendDoneMsg{ + batchTxid: txid, + generation: generation, + outpoint: ev.Outpoint, + } + }, + ), + ), + } + result := m.cfg.ChainSource.Ask(ctx, spendReq).Await(ctx) + if _, err := result.Unpack(); err != nil { + return fmt.Errorf("register input spend watch %s: %w", op, err) + } + + return nil +} + +// releaseWatchSet synchronously cancels a partially or fully armed watch set. +// The registration fields exactly match the original service keys; omitting a +// pkScript would address a different chainsource child and leak the watch. +func (m *Manager) releaseWatchSet(ctx context.Context, w *batchWatch, + inputs []ConsumedInput, releaseConf bool) { + + cleanupCtx := context.WithoutCancel(ctx) + for _, in := range inputs { + op := in.Outpoint + result := m.cfg.ChainSource.Ask( + cleanupCtx, &chainsource.UnregisterSpendRequest{ + CallerID: spendCallerID(w.txid, op), + Outpoint: &op, + PkScript: in.PkScript, + }, + ).Await(cleanupCtx) + if _, err := result.Unpack(); err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to release batch input "+ + "watch", + err, + slog.String("batch", w.txid.String()), + slog.String("outpoint", op.String()), + ) + } + } + + if !releaseConf { + return + } + + result := m.cfg.ChainSource.Ask( + cleanupCtx, &chainsource.UnregisterConfRequest{ + CallerID: confCallerID(w.txid), + Txid: &w.txid, + PkScript: w.pkScript, + TargetConfs: usabilityConfs, + }, + ).Await(cleanupCtx) + if _, err := result.Unpack(); err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to release batch confirmation "+ + "watch", + err, + slog.String("batch", w.txid.String()), + ) + } +} + +// bestHeightHint asks chainsource for the current best height to use as a +// watch height hint. On error it returns 0 (scan from the backend's default), +// logging the failure rather than aborting registration. +func (m *Manager) bestHeightHint(ctx context.Context) uint32 { + resp, err := m.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + m.logger(ctx).WarnS(ctx, "Batch canonicality best-height "+ + "query failed; using zero height hint", err) + + return 0 + } + + height, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0 + } + if height.Height < 0 { + return 0 + } + + return uint32(height.Height) +} + +// handleGetBatchState serves a read of the persisted canonicality record. +func (m *Manager) handleGetBatchState(ctx context.Context, + req *GetBatchStateRequest) fn.Result[ManagerResp] { + + record, err := m.cfg.Store.GetBatch(ctx, req.BatchTxID) + switch { + case errors.Is(err, ErrBatchNotFound): + return fn.Ok[ManagerResp](&GetBatchStateResponse{Found: false}) + + case err != nil: + return fn.Err[ManagerResp](err) + + default: + return fn.Ok[ManagerResp](&GetBatchStateResponse{ + Record: record, + Found: true, + }) + } +} + +// handleQueryLineage returns a token only when every distinct ancestor is +// complete, ready for its current generation, and semantically usable. +func (m *Manager) handleQueryLineage(ctx context.Context, + req *QueryLineageRequest) fn.Result[ManagerResp] { + + availability, lineage, err := m.loadLineage(ctx, req.BatchTxIDs) + if err != nil { + return fn.Err[ManagerResp](err) + } + + resp := &QueryLineageResponse{Availability: availability} + if availability.Usable() { + resp.Token = &AdmissionToken{Lineage: lineage} + } + + return fn.Ok[ManagerResp](resp) +} + +// handleValidateAdmission checks that every generation and revision in a +// token still exactly matches the manager's durable view. +func (m *Manager) handleValidateAdmission(ctx context.Context, + req *ValidateAdmissionRequest) fn.Result[ManagerResp] { + + if len(req.Token.Lineage) == 0 { + return fn.Ok[ManagerResp](&ValidateAdmissionResponse{ + Availability: LineageReconciling, + }) + } + + txids := make([]chainhash.Hash, 0, len(req.Token.Lineage)) + expected := make( + map[chainhash.Hash]LineageRevision, len(req.Token.Lineage), + ) + for _, entry := range req.Token.Lineage { + if _, duplicate := expected[entry.BatchTxID]; duplicate { + return fn.Ok[ManagerResp](&ValidateAdmissionResponse{ + Availability: LineageReconciling, + }) + } + expected[entry.BatchTxID] = entry + txids = append(txids, entry.BatchTxID) + } + + availability, current, err := m.loadLineage(ctx, txids) + if err != nil { + return fn.Err[ManagerResp](err) + } + + valid := availability.Usable() && len(current) == len(expected) + for _, entry := range current { + want, ok := expected[entry.BatchTxID] + if !ok || want.Generation != entry.Generation || + want.Revision != entry.Revision { + + valid = false + break + } + } + + return fn.Ok[ManagerResp](&ValidateAdmissionResponse{ + Valid: valid, + Availability: availability, + }) +} + +// loadLineage loads each distinct ancestor once and applies the readiness +// barrier before semantic priority. Missing or incomplete records return the +// retryable reconciling result even if another record has a terminal state. +func (m *Manager) loadLineage(ctx context.Context, txids []chainhash.Hash) ( + Availability, []LineageRevision, error) { + + if len(txids) == 0 { + return LineageReconciling, nil, nil + } + + seen := make(map[chainhash.Hash]struct{}, len(txids)) + availability := make([]Availability, 0, len(txids)) + lineage := make([]LineageRevision, 0, len(txids)) + for _, txid := range txids { + if _, duplicate := seen[txid]; duplicate { + continue + } + seen[txid] = struct{}{} + if watch, ok := m.watches[txid]; ok && !watch.ready { + return LineageReconciling, nil, nil + } + + record, err := m.cfg.Store.GetBatch(ctx, txid) + switch { + case errors.Is(err, ErrBatchNotFound): + return LineageReconciling, nil, nil + + case err != nil: + return LineageReconciling, nil, + fmt.Errorf("load lineage batch %s: %w", txid, + err) + + case !record.Ready(): + return LineageReconciling, nil, nil + } + + availability = append( + availability, AvailabilityForState(record.State), + ) + lineage = append(lineage, LineageRevision{ + BatchTxID: txid, + Generation: record.ObservationGeneration, + Revision: record.Revision, + }) + } + + return CombineAvailability(availability...), lineage, nil +} + +// handleBatchConfirmed records the batch tx confirmation observation and +// re-derives the canonicality state. +func (m *Manager) handleBatchConfirmed(ctx context.Context, + msg *batchConfirmedMsg) { + + w, ok := m.currentWatch(msg.txid, msg.generation) + if !ok { + return + } + state := deriveState(w) + if terminalState(state) { + switch { + case state == StateFinalized && w.confHeight.IsNone(): + // Chainsource orders positive observations before Done, + // but keep the durable reducer safe under replay from + // an older transport or mailbox. Finality remains + // authoritative; the late block identity only repairs + // expiry and diagnostics. + w.confHeight = fn.Some(msg.blockHeight) + w.confBlock = fn.Some(msg.blockHash) + w.confObserved = true + m.persistObservation(ctx, w) + + case !w.ready: + w.confObserved = true + m.persistObservation(ctx, w) + } + + return + } + + w.conf = confConfirmed + w.confHeight = fn.Some(msg.blockHeight) + w.confBlock = fn.Some(msg.blockHash) + w.confObserved = true + m.persistObservation(ctx, w) +} + +// handleBatchReorged clears the confirmation observation (the confirming block +// left the best chain) and re-derives state. +func (m *Manager) handleBatchReorged(ctx context.Context, + msg *batchReorgedMsg) { + + w, ok := m.currentWatch(msg.txid, msg.generation) + if !ok { + return + } + if terminalState(deriveState(w)) { + if !w.ready { + w.confObserved = true + m.persistObservation(ctx, w) + } + + return + } + + w.conf = confReorgedOut + w.confHeight = fn.None[int32]() + w.confBlock = fn.None[chainhash.Hash]() + w.confObserved = true + m.persistObservation(ctx, w) +} + +// handleBatchDone marks the batch confirmation as matured past the reorg- +// safety depth (policy finality) and re-derives state. The chainsource conf +// sub-actor releases its own registration on Done; the manager additionally +// releases the per-input spend watches, since a finalized batch's inputs are +// safely consumed and can no longer be double-spent. +func (m *Manager) handleBatchDone(ctx context.Context, msg *batchDoneMsg) { + w, ok := m.currentWatch(msg.txid, msg.generation) + if !ok { + return + } + if terminalState(deriveState(w)) { + if !w.ready { + w.confObserved = true + m.persistObservation(ctx, w) + } + + return + } + + w.conf = confFinalized + w.confObserved = true + m.persistObservation(ctx, w) +} + +// handleInputSpent interprets a spend of a consumed batch input. The SAME +// outpoint can be consumed by more than one registered batch — that is exactly +// the double-spend the manager exists to classify — so every batch watching +// the outpoint is updated, not just one. For each such batch, a spend by that +// batch's own tx is the expected consumption (not a conflict), while a spend by +// any other transaction is a conflicting double-spend of that batch's input. +func (m *Manager) handleInputSpent(ctx context.Context, msg *inputSpentMsg) { + w, iw, ok := m.currentInputWatch( + msg.batchTxid, msg.generation, msg.outpoint, + ) + if !ok { + return + } + if terminalState(deriveState(w)) { + if !w.ready { + iw.observed = true + m.persistObservation(ctx, w) + } + + return + } + + conflict := msg.spendingTxid != w.txid + iw.spenderIsConflict = conflict + iw.conflicting = conflict + iw.conflictFinal = conflict && iw.doneObserved + iw.observed = true + m.persistObservation(ctx, w) +} + +// handleInputSpendReorged clears a previously observed spend that left the +// best chain, for every batch watching the outpoint. +func (m *Manager) handleInputSpendReorged(ctx context.Context, + msg *inputSpendReorgedMsg) { + + w, iw, ok := m.currentInputWatch( + msg.batchTxid, msg.generation, msg.outpoint, + ) + if !ok { + return + } + if terminalState(deriveState(w)) { + if !w.ready { + iw.observed = true + m.persistObservation(ctx, w) + } + + return + } + + iw.spenderIsConflict = false + iw.conflicting = false + iw.conflictFinal = false + iw.doneObserved = false + iw.observed = true + m.persistObservation(ctx, w) +} + +// handleInputSpendDone promotes a conflicting spend to finalized once it has +// matured past the reorg-safety depth, for every batch watching the outpoint. +// A matured spend by a batch's own tx is the normal consumption, so only the +// batches for which the spend was a conflict are promoted. +func (m *Manager) handleInputSpendDone(ctx context.Context, + msg *inputSpendDoneMsg) { + + w, iw, ok := m.currentInputWatch( + msg.batchTxid, msg.generation, msg.outpoint, + ) + if !ok { + return + } + if terminalState(deriveState(w)) { + if !w.ready { + iw.observed = true + m.persistObservation(ctx, w) + } + + return + } + + // Done carries no spender identity. Remember it even when the matching + // SpendEvent is still queued, but do not mark this subject observed + // until that event identifies whether the spender is the batch or a + // conflict. + iw.doneObserved = true + if !iw.observed { + return + } + if iw.spenderIsConflict { + iw.conflictFinal = true + } + m.persistObservation(ctx, w) +} + +// currentWatch rejects messages from a released observation generation. +// Generation tagging prevents queued callbacks from an old watch set from +// mutating the fresh restart snapshot. +func (m *Manager) currentWatch(txid chainhash.Hash, generation uint64) ( + *batchWatch, bool) { + + w, ok := m.watches[txid] + if !ok || w.generation != generation { + return nil, false + } + + return w, true +} + +// currentInputWatch additionally binds an input callback to the batch whose +// dedicated chainsource registration emitted it. +func (m *Manager) currentInputWatch(txid chainhash.Hash, generation uint64, + op wire.OutPoint) (*batchWatch, *inputWatch, bool) { + + w, ok := m.currentWatch(txid, generation) + if !ok { + return nil, nil, false + } + iw, ok := w.inputs[op] + if !ok { + return nil, nil, false + } + + return w, iw, true +} + +// deriveState computes the dominant canonicality state from the batch's +// in-memory confirmation and input-conflict views, applying the priority +// conflict_finalized > conflict_provisional > reorged_out > +// finalized/provisional > unseen. +func deriveState(w *batchWatch) State { + anyConflictFinal := false + anyConflict := false + for _, iw := range w.inputs { + if iw.conflictFinal { + anyConflictFinal = true + } + if iw.conflicting { + anyConflict = true + } + } + + switch { + case anyConflictFinal: + return StateConflictFinalized + + case anyConflict: + return StateConflictProvisional + + case w.conf == confReorgedOut: + return StateReorgedOut + + case w.conf == confFinalized: + return StateFinalized + + case w.conf == confConfirmed: + return StateProvisional + + default: + return StateUnseen + } +} + +// observationComplete reports whether every registered chain subject supplied +// a current observation for this generation. +func observationComplete(w *batchWatch) bool { + if !w.confObserved { + return false + } + for _, iw := range w.inputs { + if !iw.observed { + return false + } + } + + return true +} + +// persistObservation atomically writes the complete in-memory view. On any +// error the manager's overlay closes admission immediately, even if the old +// durable row was usable; restart reconciliation closes it durably before +// re-arming watches. +func (m *Manager) persistObservation(ctx context.Context, w *batchWatch) { + inputs := make([]InputObservation, 0, len(w.inputs)) + for outpoint, input := range w.inputs { + inputs = append(inputs, InputObservation{ + Outpoint: outpoint, + Conflicting: input.conflicting, + ConflictFinal: input.conflictFinal, + }) + } + + next := deriveState(w) + ready := observationComplete(w) + err := m.cfg.Store.ApplyObservation( + ctx, &ObservationSnapshot{ + BatchTxID: w.txid, + Generation: w.generation, + State: next, + ConfirmationHeight: w.confHeight, + ConfirmationBlock: w.confBlock, + Inputs: inputs, + Ready: ready, + }, + ) + if err != nil { + w.ready = false + m.logger(ctx).WarnS(ctx, "Failed to persist atomic batch "+ + "observation", err, + slog.String("batch", w.txid.String()), + slog.Uint64("generation", w.generation), + slog.String("state", next.String())) + + return + } + + w.persisted = next + w.ready = ready + if !ready { + return + } + + // A creator batch becoming ready/usable may unblock a restore edge + // owned by some already-terminal consumer. These edges are safety + // recovery checkpoints, not ordinary operation waiters, so they must + // progress without requiring a daemon restart or replaying a user + // request. + if err := m.redriveConsumersForCreator(ctx, w.txid); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to redrive consumer recovery "+ + "for changed creator lineage", err, + slog.String("creator_batch", w.txid.String())) + } + + if !terminalState(next) { + return + } + + // Terminal actions wait for a complete durable snapshot. This prevents + // asynchronous Done delivery from releasing another subject's watch + // before that subject has contributed to Ready(g). + m.handleConsumerLifecycle(ctx, w.txid, next) + if next == StateFinalized { + m.releaseSpendWatches(ctx, w) + + return + } + m.releaseAllWatches(ctx, w) +} + +// terminalState reports whether policy finality makes a state sticky within +// the configured basic-v1 safety claim. +func terminalState(state State) bool { + return state == StateFinalized || state == StateConflictFinalized +} + +// handleConsumerLifecycle reacts to a batch's canonicality transition for the +// VTXOs it provisionally forfeits (its reverse-dependency edges): +// +// - StateConflictFinalized: the batch is permanently off the canonical chain +// (a conflicting spend matured past the reorg-safety depth), so its forfeit +// is reversed -- restore every consumed VTXO, then drop the edges. +// - StateFinalized: the batch is itself canonical and final, so the forfeit +// is now safe and the restore window closes -- drop the edges without +// restoring. +// +// Transient states (provisional / reorged-out / conflict-provisional) are left +// untouched: a reorged-out batch may still reconfirm, so its forfeit must not +// be reversed until the invalidation is final. +func (m *Manager) handleConsumerLifecycle(ctx context.Context, + txid chainhash.Hash, next State) { + + switch next { + case StateConflictFinalized: + m.restoreProvisionalConsumers(ctx, txid) + + case StateFinalized: + err := m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges for finalized batch", err, + slog.String("batch", txid.String())) + } + + case StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional: + + // Transient/non-final states: the forfeit's fate is not yet + // decided, so the reverse-dependency edges are left untouched. + // A reorged-out batch may still reconfirm, so its forfeit must + // not be reversed until the invalidation (or finalization) is + // final. + } +} + +// restoreProvisionalConsumers resolves every durable edge owned by the given +// terminally invalidated batch. Usable creator lineage may enter the atomic +// restore CAS; invalid creator lineage completes without restoration; all +// retryable/uncertain states leave the edge pending for restart redrive. +func (m *Manager) restoreProvisionalConsumers(ctx context.Context, + txid chainhash.Hash) { + + edges, err := m.cfg.Store.ListPendingConsumerEdges(ctx, txid) + if err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to list pending consumer edges "+ + "for invalidated batch", + err, + slog.String("batch", txid.String()), + ) + + return + } + + for _, edge := range edges { + availability, _, err := m.loadLineage(ctx, edge.CreatorLineage) + if err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to load consumed VTXO "+ + "creator lineage", + err, + slog.String("batch", txid.String()), + slog.String( + "vtxo", + edge.ConsumedVTXO.String(), + ), + ) + + continue + } + + var restore bool + switch { + case availability.Usable(): + restore = true + + case availability == Invalidated: + restore = false + + default: + continue + } + + resolution, err := m.cfg.Store.ResolveConsumerEdge( + ctx, edge, restore, + ) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to resolve terminal "+ + "consumer edge", err, + slog.String("batch", txid.String()), + slog.String("vtxo", edge.ConsumedVTXO.String())) + + continue + } + if resolution != ConsumerEdgeRestored || + m.cfg.ActivateRestoredVTXO == nil { + + continue + } + if err := m.cfg.ActivateRestoredVTXO( + ctx, edge.ConsumedVTXO, + ); err != nil { + + m. + logger(ctx). + WarnS( + ctx, + "Failed to activate atomically "+ + "restored VTXO", + err, + slog.String("batch", txid.String()), + slog.String( + "vtxo", + edge.ConsumedVTXO.String(), + ), + ) + } + } +} + +// redriveTerminalConsumerLifecycles retries durable consumer-edge recovery +// for every ready terminal batch. A terminal consumer can be recorded before +// one of the consumed VTXO's creator batches finishes reconciliation; when +// that creator later becomes objectively usable or invalidated, no further +// event is guaranteed on the terminal consumer itself. Rechecking here makes +// restoration progress on the evidence change that can unblock it. +func (m *Manager) redriveTerminalConsumerLifecycles(ctx context.Context) error { + terminal := []State{StateFinalized, StateConflictFinalized} + for _, state := range terminal { + records, err := m.cfg.Store.ListBatchesByState(ctx, state) + if err != nil { + return fmt.Errorf("list %s terminal batches: %w", state, + err) + } + + for _, record := range records { + if !record.Ready() { + continue + } + m.handleConsumerLifecycle( + ctx, record.BatchTxID, record.State, + ) + } + } + + return nil +} + +// redriveConsumersForCreator retries only the terminal consumer checkpoints +// whose immutable creator lineage names creatorBatch. The normalized reverse +// lookup avoids scanning every historical terminal batch on each observation. +func (m *Manager) redriveConsumersForCreator(ctx context.Context, + creatorBatch chainhash.Hash) error { + + consumers, err := m.cfg.Store.ListPendingConsumerBatchesByCreator( + ctx, creatorBatch, + ) + if err != nil { + return err + } + + for _, consumer := range consumers { + record, err := m.cfg.Store.GetBatch(ctx, consumer) + if err != nil { + return fmt.Errorf("load consumer batch %s: %w", + consumer, err) + } + if !record.Ready() || !terminalState(record.State) { + continue + } + + m.handleConsumerLifecycle(ctx, consumer, record.State) + } + + return nil +} + +// releaseSpendWatches unregisters the per-input spend watches for a batch, +// called once the batch finalizes. +func (m *Manager) releaseSpendWatches(ctx context.Context, w *batchWatch) { + inputs := watchInputs(w) + m.releaseWatchSet(ctx, w, inputs, false) +} + +// releaseAllWatches unregisters every subject after terminal invalidation is +// durably Ready. The input whose Done event triggered invalidation may already +// have released itself; unregister remains idempotent. +func (m *Manager) releaseAllWatches(ctx context.Context, w *batchWatch) { + inputs := watchInputs(w) + m.releaseWatchSet(ctx, w, inputs, true) +} + +// watchInputs reconstructs the registration keys needed for synchronous +// cleanup. +func watchInputs(w *batchWatch) []ConsumedInput { + inputs := make([]ConsumedInput, 0, len(w.inputs)) + for op, iw := range w.inputs { + inputs = append(inputs, ConsumedInput{ + Outpoint: op, + PkScript: iw.pkScript, + }) + } + + return inputs +} + +// Reconcile re-establishes watches for every non-final persisted batch after a +// restart. It seeds each batch's in-memory state from the persisted record so +// live re-observation does not transiently downgrade a persisted conflict or +// finalized state. It must run after SetSelfRef. +func (m *Manager) Reconcile(ctx context.Context) error { + // Non-final states whose watches must be re-armed. Finalized and + // conflict_finalized batches need no further watching. + live := []State{ + StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional, + } + + for _, state := range live { + records, err := m.cfg.Store.ListBatchesByState(ctx, state) + if err != nil { + return fmt.Errorf("list %s batches: %w", state, err) + } + + for _, record := range records { + if _, armed := m.watches[record.BatchTxID]; armed { + continue + } + if !record.EvidenceComplete() { + m. + logger(ctx). + WarnS( + ctx, + "Incomplete batch; fail-closed", + nil, + slog.String( + "batch", + record.BatchTxID. + String(), + ), + ) + + continue + } + reconciling, err := m.cfg.Store.BeginReconcile( + ctx, record.BatchTxID, + ) + if err != nil { + return fmt.Errorf("begin %s reconciliation: %w", + record.BatchTxID, err) + } + + if err := m.reconcileOne(ctx, reconciling); err != nil { + return err + } + } + } + + // Terminal batches have no live watches. Re-drive both interrupted + // restores and finalized-edge cleanup from their durable checkpoints. + if err := m.redriveTerminalConsumerLifecycles(ctx); err != nil { + return err + } + + return nil +} + +// reconcileOne rebuilds the in-memory watch for one persisted batch and +// re-arms its chain watches. +func (m *Manager) reconcileOne(ctx context.Context, record *Record) error { + if _, ok := m.watches[record.BatchTxID]; ok { + return nil + } + + w := watchFromRecord(record) + + // Arm the chain watches BEFORE recording the watch, mirroring the + // initial registration path. If arming fails, leaving m.watches + // untouched lets a later Reconcile retry the full arm from scratch + // rather than treating this batch as permanently armed; re-registering + // the same conf/spend caller IDs is idempotent. + if err := m.armWatches(ctx, w, record.ConsumedInputs); err != nil { + return fmt.Errorf("re-arm batch %s watches: %w", + record.BatchTxID, err) + } + m.watches[record.BatchTxID] = w + + return nil +} + +// watchFromRecord reconstructs conservative in-memory reducer state from +// durable observations. Persisted conflict flags take priority over a later +// confirmation replay, so restart ordering cannot briefly admit a conflict. +func watchFromRecord(record *Record) *batchWatch { + w := &batchWatch{ + txid: record.BatchTxID, + pkScript: record.ConfirmationPkScript, + inputs: make(map[wire.OutPoint]*inputWatch), + confHeight: record.ConfirmationHeight, + confBlock: record.ConfirmationBlock, + generation: record.ObservationGeneration, + ready: record.Ready(), + persisted: record.State, + } + + switch record.State { + case StateProvisional, StateConflictProvisional: + w.conf = confConfirmed + + case StateFinalized: + w.conf = confFinalized + + case StateReorgedOut: + w.conf = confReorgedOut + + default: + w.conf = confUnseen + } + + for _, in := range record.ConsumedInputs { + w.inputs[in.Outpoint] = &inputWatch{ + pkScript: in.PkScript, + spenderIsConflict: in.Conflicting || in.ConflictFinal, + conflicting: in.Conflicting, + conflictFinal: in.ConflictFinal, + } + } + + return w +} + +// confCallerID is the stable chainsource caller id for a batch's confirmation +// watch. +func confCallerID(txid chainhash.Hash) string { + return fmt.Sprintf("batchcanon-conf-%s", txid) +} + +// spendCallerID is the stable chainsource caller id for a batch input's spend +// watch. +func spendCallerID(txid chainhash.Hash, op wire.OutPoint) string { + return fmt.Sprintf("batchcanon-spend-%s-%s", txid, op) +} diff --git a/batchcanon/manager_conflict_shared_test.go b/batchcanon/manager_conflict_shared_test.go new file mode 100644 index 000000000..90fa19aef --- /dev/null +++ b/batchcanon/manager_conflict_shared_test.go @@ -0,0 +1,64 @@ +package batchcanon + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestManagerSharedInputSpendClassifiesPerBatch verifies that when two batches +// consume the SAME input (the double-spend case), a spend by one batch's own tx +// is classified as the expected consumption for that batch and as a conflict +// for the OTHER batch — every watch on the outpoint is updated, not just one +// arbitrary batch. It also checks the finalize promotion is per-batch. +func TestManagerSharedInputSpendClassifiesPerBatch(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + + txA := testBatchTxid(0xa1) + txB := testBatchTxid(0xb2) + shared := testOutpoint(0xcc, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txA, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + ConsumedInputs: []ConsumedInput{ci(shared)}, + }) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txB, + ConfirmationPkScript: []byte{0x51, 0x20, 0x02}, + ConsumedInputs: []ConsumedInput{ci(shared)}, + }) + + // Batch A wins the input and confirms; B never confirms. + h.fireConfirmed(t, txA, 101, testBatchTxid(0x01)) + require.Equal(t, StateProvisional, h.state(t, txA).Record.State) + + // The shared input is spent by A's own tx: not a conflict for A, but a + // conflicting double-spend for B (which wanted the same input). + h.fireSpend(t, shared, txA, 101) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "batch whose own tx spent the input must not be in conflict", + ) + require.Equal( + t, StateConflictProvisional, h.state(t, txB).Record.State, + "batch losing its input to another tx must be "+ + "conflict-provisional", + ) + + // Once the spend matures, only the conflicted batch (B) is promoted to + // conflict-finalized; A's own consumption stays provisional. + h.fireSpendDone(t, shared) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "self-consuming batch must not finalize as a conflict", + ) + require.Equal( + t, StateConflictFinalized, h.state(t, txB).Record.State, + "conflicted batch must promote to conflict-finalized", + ) +} diff --git a/batchcanon/manager_generation_test.go b/batchcanon/manager_generation_test.go new file mode 100644 index 000000000..81884653a --- /dev/null +++ b/batchcanon/manager_generation_test.go @@ -0,0 +1,152 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestReconcileLeavesUpgradePlaceholderFailClosed proves an incomplete +// historical row never aborts daemon startup and is never treated as armed or +// ready. It waits for authenticated producer evidence to complete the row. +func TestReconcileLeavesUpgradePlaceholderFailClosed(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := chainhash.Hash{0xd1} + require.NoError( + t, + h.store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + RegistrationStage: RegistrationReconciling, + ObservationGeneration: 1, + ReadyGeneration: fn.None[uint64](), + State: StateProvisional, + }, + ), + ) + + require.NoError(t, h.mgr.Reconcile(t.Context())) + record, err := h.store.GetBatch(t.Context(), txid) + require.NoError(t, err) + require.False(t, record.EvidenceComplete()) + require.False(t, record.Ready()) + require.Equal(t, uint64(1), record.ObservationGeneration) + require.NotContains(t, h.mgr.watches, txid) +} + +// TestRegistrationCompletesUpgradePlaceholder proves authenticated evidence +// replaces an age-derived historical placeholder in a fresh generation and +// arms every real subject instead of trusting the placeholder's old state. +func TestRegistrationCompletesUpgradePlaceholder(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + req := validRegistrationRequest(t) + historicalDependent := testOutpoint(0xd2, 0) + require.NoError( + t, + h.store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: req.BatchTxID, + RegistrationStage: RegistrationReconciling, + ObservationGeneration: 1, + ReadyGeneration: fn.None[uint64](), + State: StateFinalized, + DependentVTXOs: []wire.OutPoint{ + historicalDependent, + }, + }, + ), + ) + req.DependentVTXOs = []wire.OutPoint{testOutpoint(0xd3, 0)} + + h.registerBatch(t, req) + record, err := h.store.GetBatch(t.Context(), req.BatchTxID) + require.NoError(t, err) + require.True(t, record.EvidenceComplete()) + require.False(t, record.Ready()) + require.Equal(t, StateUnseen, record.State) + require.Equal(t, RegistrationRegistering, record.RegistrationStage) + require.Equal(t, uint64(2), record.ObservationGeneration) + require.ElementsMatch( + t, + []wire.OutPoint{ + historicalDependent, req.DependentVTXOs[0], + }, + record.DependentVTXOs, + ) + + h.mock.getConfRefs(t, req.BatchTxID) + for _, input := range req.ConsumedInputs { + h.mock.getSpendRefs(t, input.Outpoint) + } +} + +// TestManagerIgnoresCallbacksFromPriorGeneration proves queued events from a +// released watch set cannot contaminate the fresh restart snapshot or satisfy +// Ready(g) after reconciliation advances the durable generation. +func TestManagerIgnoresCallbacksFromPriorGeneration(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xd7) + input := testOutpoint(0xd8, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0xd7}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0xe1)) + h.fireSpend(t, input, txid, 101) + require.True(t, h.state(t, txid).Record.Ready()) + + oldConf := h.mock.getConfRefs(t, txid) + oldSpend := h.mock.getSpendRefs(t, input)[0] + + // Model process restart: the old actor callbacks can still be queued, + // while the new manager generation closes admission before re-arming. + delete(h.mgr.watches, txid) + reconciling, err := h.store.BeginReconcile(t.Context(), txid) + require.NoError(t, err) + require.Equal(t, uint64(2), reconciling.ObservationGeneration) + require.NoError(t, h.mgr.reconcileOne(t.Context(), reconciling)) + + require.NoError( + t, + oldConf.reorged.Tell( + t.Context(), chainsource.ConfReorgedEvent{ + Txid: txid, + }, + ), + ) + require.NoError( + t, + oldSpend.reorged.Tell( + t.Context(), chainsource.SpendReorgedEvent{ + Outpoint: input, + }, + ), + ) + + // Drain the stale callbacks. The durable observation remains untouched + // and readiness stays closed for generation 2. + got := h.state(t, txid).Record + require.Equal(t, StateProvisional, got.State) + require.Equal(t, int32(101), got.ConfirmationHeight.UnwrapOr(0)) + require.False(t, got.Ready()) + + // Only callbacks from the freshly armed watch set can complete + // Ready(2). + h.fireConfirmed(t, txid, 102, testBatchTxid(0xe2)) + h.fireSpend(t, input, txid, 102) + got = h.state(t, txid).Record + require.True(t, got.Ready()) + require.Equal(t, uint64(2), got.ObservationGeneration) + require.Equal(t, int32(102), got.ConfirmationHeight.UnwrapOr(0)) +} diff --git a/batchcanon/manager_ordering_test.go b/batchcanon/manager_ordering_test.go new file mode 100644 index 000000000..f252e3f06 --- /dev/null +++ b/batchcanon/manager_ordering_test.go @@ -0,0 +1,290 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +// TestDeriveStatePriority pins the reducer's complete pre-terminal priority +// table. The manager may receive observations for distinct subjects in any +// order, but the complete snapshot must always reduce to the same dominant +// semantic fact. +func TestDeriveStatePriority(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf confState + inputs []*inputWatch + want State + }{ + { + name: "unseen", + conf: confUnseen, + want: StateUnseen, + }, + { + name: "confirmed", + conf: confConfirmed, + want: StateProvisional, + }, + { + name: "finalized", + conf: confFinalized, + want: StateFinalized, + }, + { + name: "reorged out", + conf: confReorgedOut, + want: StateReorgedOut, + }, + { + name: "conflict dominates confirmation", + conf: confConfirmed, + inputs: []*inputWatch{ + { + conflicting: true, + }, + }, + want: StateConflictProvisional, + }, + { + name: "conflict dominates reorg", + conf: confReorgedOut, + inputs: []*inputWatch{ + { + conflicting: true, + }, + }, + want: StateConflictProvisional, + }, + { + name: "final conflict dominates every provisional fact", + conf: confReorgedOut, + inputs: []*inputWatch{ + { + conflicting: true, + }, + { + conflictFinal: true, + }, + }, + want: StateConflictFinalized, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + watch := &batchWatch{ + conf: test.conf, + inputs: make(map[wire.OutPoint]*inputWatch), + } + for i, input := range test.inputs { + watch.inputs[testOutpoint(byte(i+1), 0)] = input + } + + require.Equal(t, test.want, deriveState(watch)) + }) + } +} + +// TestSpendDoneBeforeSpendRetainsTerminalEvidence proves the manager remains +// fail-closed when independently delivered finality and identity callbacks +// reach its mailbox out of order. Done alone cannot classify the spender, but +// the later Spend must still finalize a real conflict rather than strand it in +// the provisional state forever. +func TestSpendDoneBeforeSpendRetainsTerminalEvidence(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x81) + input := testOutpoint(0x82, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x81}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x83)) + + h.fireSpendDone(t, input) + got := h.state(t, txid).Record + require.Equal(t, StateProvisional, got.State) + require.False(t, got.Ready()) + + h.fireSpend(t, input, testBatchTxid(0x84), 102) + got = h.state(t, txid).Record + require.Equal(t, StateConflictFinalized, got.State) + require.True(t, got.Ready()) +} + +// TestSpendDoneBeforeOwnSpendDoesNotInventConflict proves identity still +// controls classification when finality is delivered first. +func TestSpendDoneBeforeOwnSpendDoesNotInventConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x85) + input := testOutpoint(0x86, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x85}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x87)) + + h.fireSpendDone(t, input) + h.fireSpend(t, input, txid, 101) + got := h.state(t, txid).Record + require.Equal(t, StateProvisional, got.State) + require.True(t, got.Ready()) +} + +// TestSpendReorgClearsEarlyDone proves objective cancellation evidence clears +// a pending finality signal before a later replacement spend is classified. +func TestSpendReorgClearsEarlyDone(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x88) + input := testOutpoint(0x89, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x88}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x8a)) + + h.fireSpendDone(t, input) + h.fireSpendReorged(t, input) + h.fireSpend(t, input, testBatchTxid(0x8b), 102) + got := h.state(t, txid).Record + require.Equal(t, StateConflictProvisional, got.State) + require.True(t, got.Ready()) +} + +// TestConflictFinalityIsStickyAcrossLateEvents proves terminal invalidation +// cannot be undone by a late reorg callback, while still allowing untouched +// subjects to complete the current Ready generation. +func TestConflictFinalityIsStickyAcrossLateEvents(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x91) + conflicted := testOutpoint(0x92, 0) + late := testOutpoint(0x93, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x91}, + ConsumedInputs: []ConsumedInput{ + ci(conflicted), ci(late), + }, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x94)) + h.fireSpend(t, conflicted, testBatchTxid(0x95), 102) + h.fireSpendDone(t, conflicted) + + got := h.state(t, txid).Record + require.Equal(t, StateConflictFinalized, got.State) + require.False(t, got.Ready(), "the second input is still unobserved") + + // This ordering is impossible as a coherent best-chain history after + // policy finality, but queued callbacks must still be harmless. The + // late reorg can satisfy subject observation; it cannot erase final + // evidence. + h.fireSpendReorged(t, conflicted) + require.Equal( + t, StateConflictFinalized, h.state(t, txid).Record.State, + ) + + // The remaining subject may arrive after terminal evidence. It + // completes Ready(g) without changing the sticky invalidation. + h.fireSpend(t, late, txid, 102) + got = h.state(t, txid).Record + require.Equal(t, StateConflictFinalized, got.State) + require.True(t, got.Ready()) + + // Even callbacks already queued before synchronous watch cleanup cannot + // turn the batch usable again. + h.fireConfReorged(t, txid) + h.fireConfirmed(t, txid, 103, testBatchTxid(0x96)) + got = h.state(t, txid).Record + require.Equal(t, StateConflictFinalized, got.State) + require.True(t, got.Ready()) +} + +// TestBatchFinalityDominatesContradictoryLateInput proves the symmetric +// terminal ordering: once the batch confirmation is policy-final, a queued +// contradictory input notification cannot flip it to invalidated or limbo. +func TestBatchFinalityDominatesContradictoryLateInput(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xa1) + inputs := []wire.OutPoint{ + testOutpoint(0xa2, 0), + testOutpoint(0xa3, 0), + } + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0xa1}, + ConsumedInputs: []ConsumedInput{ + ci(inputs[0]), ci(inputs[1]), + }, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0xa4)) + h.fireConfDone(t, txid) + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) + + // The first late callback claims a different spender. Policy-final + // batch evidence is sticky, so the callback only contributes to + // readiness. + h.fireSpend(t, inputs[0], testBatchTxid(0xff), 102) + h.fireSpend(t, inputs[1], txid, 101) + got := h.state(t, txid).Record + require.Equal(t, StateFinalized, got.State) + require.True(t, got.Ready()) +} + +// TestBatchDoneBeforeConfirmedRetainsLateBlockIdentity proves independently +// delivered finality cannot strand a finalized record without the confirmation +// metadata needed for expiry derivation. The late callback may enrich the +// terminal record, but must never reopen or reclassify it. +func TestBatchDoneBeforeConfirmedRetainsLateBlockIdentity(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xb1) + input := testOutpoint(0xb2, 0) + block := testBatchTxid(0xb3) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0xb1}, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + + h.fireConfDone(t, txid) + h.fireSpend(t, input, txid, 101) + got := h.state(t, txid).Record + require.Equal(t, StateFinalized, got.State) + require.True(t, got.Ready()) + require.True(t, got.ConfirmationHeight.IsNone()) + require.True(t, got.ConfirmationBlock.IsNone()) + + h.fireConfirmed(t, txid, 101, block) + got = h.state(t, txid).Record + require.Equal(t, StateFinalized, got.State) + require.True(t, got.Ready()) + require.Equal(t, int32(101), got.ConfirmationHeight.UnwrapOr(0)) + require.True(t, got.ConfirmationBlock.IsSome()) + require.Equal( + t, block, + got.ConfirmationBlock.UnwrapOr( + testBatchTxid(0), + ), + ) +} diff --git a/batchcanon/manager_provisional_consumer_test.go b/batchcanon/manager_provisional_consumer_test.go new file mode 100644 index 000000000..6aec81f92 --- /dev/null +++ b/batchcanon/manager_provisional_consumer_test.go @@ -0,0 +1,481 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// restoreRecorder captures VTXO actors activated after an atomic store restore. +type restoreRecorder struct { + mu sync.Mutex + restored []wire.OutPoint +} + +func (r *restoreRecorder) restore(_ context.Context, op wire.OutPoint) error { + r.mu.Lock() + defer r.mu.Unlock() + r.restored = append(r.restored, op) + + return nil +} + +func (r *restoreRecorder) outpoints() []wire.OutPoint { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]wire.OutPoint(nil), r.restored...) +} + +// TestManagerRestoresForfeitedVTXOOnConflictFinalized proves the +// reverse-dependency (provisional-forfeit) restore: when a batch that +// provisionally forfeits a VTXO is invalidated by a finalized conflict, the +// manager restores that VTXO via the RestoreConsumedVTXO callback and drops the +// edge. A transient conflict (not yet finalized) must NOT restore -- the +// forfeit is only reversed once the invalidation is final. +func TestManagerRestoresForfeitedVTXOOnConflictFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + // A round-2 commitment batch that forfeits a round-1 VTXO and spends a + // consumed input we can double-spend. + consumerBatch := testBatchTxid(0xc2) + creatorBatch := testBatchTxid(0xc1) + forfeitedVTXO := testOutpoint(0xa1, 0) + consumedInput := testOutpoint(0x1e, 1) + creatorInput := testOutpoint(0x1d, 1) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: creatorBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc1}, + ConsumedInputs: []ConsumedInput{ci(creatorInput)}, + }) + h.fireConfirmed(t, creatorBatch, 100, testBatchTxid(0xb0)) + h.fireSpend(t, creatorInput, creatorBatch, 100) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc2}, + ConsumedInputs: []ConsumedInput{ci(consumedInput)}, + ConsumedVTXOs: []ConsumerEdge{ + { + ConsumedVTXO: forfeitedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + }, + }) + + // Confirm the batch, then observe a conflicting spend of its input. A + // non-final conflict must not restore yet. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb1)) + h.fireSpend(t, consumedInput, testBatchTxid(0x9e), 102) + require.Equal( + t, StateConflictProvisional, + h.state(t, consumerBatch).Record.State, + ) + require.Empty( + t, rec.outpoints(), + "a provisional (non-final) conflict must not restore the "+ + "forfeited VTXO", + ) + + // Mature the conflicting spend past the reorg-safety depth: the batch + // is now permanently invalidated, so its forfeit is reversed. + h.fireSpendDone(t, consumedInput) + require.Equal( + t, StateConflictFinalized, + h.state(t, consumerBatch).Record.State, + ) + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "a finalized conflict must restore the forfeited VTXO", + ) + + // The edge is dropped after restoring, so the restore fires at most + // once even if the state is re-derived. + remaining, err := h.store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} + +// TestManagerClearsForfeitEdgesOnFinalized proves the other half of the +// lifecycle: when the consumer batch becomes canonical and final, the forfeit +// is permanent, so the reverse-dependency edges are dropped WITHOUT restoring. +func TestManagerClearsForfeitEdgesOnFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + consumerBatch := testBatchTxid(0xf2) + creatorBatch := testBatchTxid(0xf1) + forfeitedVTXO := testOutpoint(0xa2, 0) + input := testOutpoint(0xa3, 0) + creatorInput := testOutpoint(0xa4, 0) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: creatorBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xf1}, + ConsumedInputs: []ConsumedInput{ci(creatorInput)}, + }) + h.fireConfirmed(t, creatorBatch, 100, testBatchTxid(0xb1)) + h.fireSpend(t, creatorInput, creatorBatch, 100) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xf2}, + ConsumedInputs: []ConsumedInput{ci(input)}, + ConsumedVTXOs: []ConsumerEdge{ + { + ConsumedVTXO: forfeitedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + }, + }) + + // Confirm then finalize the batch on the canonical chain. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb2)) + h.fireConfDone(t, consumerBatch) + h.fireSpend(t, input, consumerBatch, 101) + require.Equal( + t, StateFinalized, h.state(t, consumerBatch).Record.State, + ) + + require.Empty( + t, rec.outpoints(), + "a canonically finalized batch must not restore its "+ + "forfeited VTXO", + ) + remaining, err := h.store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty( + t, remaining, + "edges must be cleared once the forfeit is final and safe", + ) +} + +// TestRepeatRegistrationDrivesTerminalConsumerEdge proves replay cannot add +// durable restore work behind a terminal batch after its last chain callback +// and leave that work stranded until restart. +func TestRepeatRegistrationDrivesTerminalConsumerEdge(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + creatorBatch := testBatchTxid(0xb1) + consumerBatch := testBatchTxid(0xb2) + creatorInput := testOutpoint(0xb3, 0) + consumerInput := testOutpoint(0xb4, 0) + forfeitedVTXO := testOutpoint(0xb5, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: creatorBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xb1}, + ConsumedInputs: []ConsumedInput{ci(creatorInput)}, + }) + h.fireConfirmed(t, creatorBatch, 100, testBatchTxid(0xb6)) + h.fireSpend(t, creatorInput, creatorBatch, 100) + + consumerReq := &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{ + 0x51, + 0x20, + 0xb2, + }, + ConsumedInputs: []ConsumedInput{ + ci(consumerInput), + }, + } + h.registerBatch(t, consumerReq) + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb7)) + h.fireSpend(t, consumerInput, testBatchTxid(0xb8), 102) + h.fireSpendDone(t, consumerInput) + require.Equal( + t, StateConflictFinalized, + h.state(t, consumerBatch).Record.State, + ) + + consumerReq.ConsumedVTXOs = []ConsumerEdge{ + { + ConsumedVTXO: forfeitedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + } + h.registerBatch(t, consumerReq) + + require.Equal(t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints()) + remaining, err := h.store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining) +} + +// TestCreatorRecoveryRedrivesTerminalConsumerEdge proves a consumed VTXO is +// restored as soon as its own creator lineage becomes objectively usable. A +// daemon restart or replay of the user's old operation is not required. +func TestCreatorRecoveryRedrivesTerminalConsumerEdge(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + creatorBatch := testBatchTxid(0xc3) + consumerBatch := testBatchTxid(0xc4) + creatorInput := testOutpoint(0xc5, 0) + consumerInput := testOutpoint(0xc6, 0) + forfeitedVTXO := testOutpoint(0xc7, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: creatorBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc3}, + ConsumedInputs: []ConsumedInput{ci(creatorInput)}, + }) + h.fireConfirmed(t, creatorBatch, 100, testBatchTxid(0xc8)) + h.fireSpend(t, creatorInput, creatorBatch, 100) + h.fireConfReorged(t, creatorBatch) + require.Equal(t, StateReorgedOut, h.state(t, creatorBatch).Record.State) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc4}, + ConsumedInputs: []ConsumedInput{ci(consumerInput)}, + ConsumedVTXOs: []ConsumerEdge{ + { + ConsumedVTXO: forfeitedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + }, + }) + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xc9)) + h.fireSpend(t, consumerInput, testBatchTxid(0xca), 102) + h.fireSpendDone(t, consumerInput) + require.Empty(t, rec.outpoints()) + + remaining, err := h.store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Len(t, remaining, 1) + + // The creator reconfirms. Its ready observation is the evidence change + // that unblocks the already-terminal consumer's restore checkpoint. + h.fireConfirmed(t, creatorBatch, 103, testBatchTxid(0xcb)) + require.Equal( + t, StateProvisional, h.state(t, creatorBatch).Record.State, + ) + require.Equal(t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints()) + remaining, err = h.store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining) +} + +// TestManagerReconcileRestoresInterruptedForfeit proves the crash-recovery +// half of the restore lifecycle: if a batch's restore failed partway through +// (RestoreConsumedVTXO errored, so the edges were deliberately kept for a +// retry) and the daemon then restarted, the retained edges must still be +// re-driven. Because conflict_finalized is terminal, its watches are not +// re-armed on Reconcile and the live spend-done event that first triggered the +// restore will never fire again -- so Reconcile itself must sweep terminal +// conflicts and complete any interrupted restore, otherwise the consumed VTXOs +// would stay forfeited forever (a permanent lock). +func TestManagerReconcileRestoresInterruptedForfeit(t *testing.T) { + t.Parallel() + + store := newFakeStore() + consumerBatch := testBatchTxid(0xd1) + creatorBatch := testBatchTxid(0xd0) + forfeitedVTXO := testOutpoint(0xa3, 0) + + // Seed the state a partial-failure restore leaves behind: the batch is + // already conflict_finalized (persisted), yet its reverse-dependency + // edge is still present because RestoreConsumedVTXO errored before the + // edge could be dropped. + creatorRecord := &Record{ + BatchTxID: creatorBatch, + RegistrationStage: RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: StateFinalized, + } + completeTestRecordEvidence(creatorRecord) + require.NoError( + t, store.UpsertBatch(t.Context(), creatorRecord), + ) + consumerRecord := &Record{ + BatchTxID: consumerBatch, + RegistrationStage: RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: StateConflictFinalized, + ConfirmationHeight: fn.Some[int32](101), + CSVExpiryDelta: 50, + } + completeTestRecordEvidence(consumerRecord) + require.NoError( + t, + store.UpsertBatch( + t.Context(), consumerRecord, + ), + ) + require.NoError( + t, + store.RegisterBatch( + t.Context(), consumerRecord, []ConsumerEdge{ + { + ConsumedVTXO: forfeitedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + }, + ), + ) + + rec := &restoreRecorder{} + + mock := newMockChainSource(200) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + ActivateRestoredVTXO: rec.restore, + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // The interrupted restore is completed and the edge dropped. + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "Reconcile must re-drive the interrupted restore for a "+ + "terminal conflict", + ) + remaining, err := store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} + +// TestManagerCompletesEdgeWithoutRestoringInvalidCreator proves a terminal +// consumer cannot revive a VTXO whose own creator lineage is invalidated. The +// edge is objectively finished without activating a Live VTXO actor. +func TestManagerCompletesEdgeWithoutRestoringInvalidCreator(t *testing.T) { + t.Parallel() + + store := newFakeStore() + consumerBatch := testBatchTxid(0xe1) + creatorBatch := testBatchTxid(0xe0) + consumedVTXO := testOutpoint(0xae, 0) + + creatorRecord := &Record{ + BatchTxID: creatorBatch, + RegistrationStage: RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: StateConflictFinalized, + } + completeTestRecordEvidence(creatorRecord) + require.NoError( + t, + store.RegisterBatch( + t.Context(), creatorRecord, nil, + ), + ) + + consumerRecord := &Record{ + BatchTxID: consumerBatch, + RegistrationStage: RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: StateConflictFinalized, + } + completeTestRecordEvidence(consumerRecord) + require.NoError( + t, + store.RegisterBatch( + t.Context(), consumerRecord, []ConsumerEdge{ + { + ConsumedVTXO: consumedVTXO, + ExpectedRevision: 2, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + }, + ), + ) + + rec := &restoreRecorder{} + mock := newMockChainSource(200) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager(ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + ActivateRestoredVTXO: rec.restore, + }) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + require.Empty( + t, rec.outpoints(), + "invalid creator lineage must never activate a restored VTXO", + ) + remaining, err := store.ListPendingConsumerEdges( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty( + t, remaining, + "objectively invalid creator lineage completes the edge", + ) +} diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go new file mode 100644 index 000000000..5782ff023 --- /dev/null +++ b/batchcanon/manager_test.go @@ -0,0 +1,1266 @@ +package batchcanon + +import ( + "bytes" + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +const testTimeout = 5 * time.Second + +// --------------------------------------------------------------------------- +// In-memory fake Store (the real db store is tested separately; this keeps the +// manager unit test free of a batchcanon -> db import cycle). +// --------------------------------------------------------------------------- + +type fakeStore struct { + mu sync.Mutex + records map[chainhash.Hash]*Record + consumers map[chainhash.Hash][]ConsumerEdge + applyErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + records: make(map[chainhash.Hash]*Record), + consumers: make(map[chainhash.Hash][]ConsumerEdge), + } +} + +// ci builds a ConsumedInput from an outpoint with a placeholder non-empty +// pkScript. The mock chainsource ignores the script's value, but it must be +// non-empty so the manager arms the spend watch rather than skipping it. +func ci(op wire.OutPoint) ConsumedInput { + return ConsumedInput{Outpoint: op, PkScript: []byte{0x51}} +} + +// completeTestRecordEvidence installs the minimal immutable watch subjects on +// direct record fixtures. Manager registration tests use the stronger wire +// transaction validation path instead. +func completeTestRecordEvidence(record *Record) { + record.BatchTx = []byte{0x00} + record.ConfirmationPkScript = []byte{0x51} + record.ConsumedInputs = []ConsumedInput{ + ci(wire.OutPoint{Hash: record.BatchTxID}), + } +} + +func cloneRecord(r *Record) *Record { + cp := *r + cp.BatchTx = append([]byte(nil), r.BatchTx...) + cp.ConsumedInputs = append([]ConsumedInput(nil), r.ConsumedInputs...) + for i := range cp.ConsumedInputs { + cp.ConsumedInputs[i].PkScript = append( + []byte(nil), r.ConsumedInputs[i].PkScript..., + ) + } + cp.DependentVTXOs = append([]wire.OutPoint(nil), r.DependentVTXOs...) + cp.ConfirmationPkScript = append( + []byte(nil), r.ConfirmationPkScript..., + ) + + return &cp +} + +func (s *fakeStore) RegisterBatch(_ context.Context, r *Record, + edges []ConsumerEdge) error { + + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.records[r.BatchTxID] + completedPlaceholder := false + if ok && len(existing.BatchTx) == 0 && + existing.RegistrationStage != RegistrationComplete { + + completed := cloneRecord(r) + completed.State = StateUnseen + completed.RegistrationStage = RegistrationRegistering + completed.ObservationGeneration = + existing.ObservationGeneration + 1 + if completed.ObservationGeneration == 0 { + completed.ObservationGeneration = 1 + } + completed.ReadyGeneration = fn.None[uint64]() + completed.Revision = existing.Revision + 1 + completed.ConfirmationHeight = fn.None[int32]() + completed.ConfirmationBlock = fn.None[chainhash.Hash]() + seen := make(map[wire.OutPoint]struct{}) + completed.DependentVTXOs = nil + for _, dependents := range [][]wire.OutPoint{ + existing.DependentVTXOs, r.DependentVTXOs, + } { + for _, dependent := range dependents { + if _, duplicate := seen[dependent]; duplicate { + continue + } + seen[dependent] = struct{}{} + completed.DependentVTXOs = append( + completed.DependentVTXOs, dependent, + ) + } + } + s.records[r.BatchTxID] = completed + completedPlaceholder = true + } + + if ok && !completedPlaceholder { + if !bytes.Equal( + existing.ConfirmationPkScript, r.ConfirmationPkScript, + ) || !bytes.Equal(existing.BatchTx, r.BatchTx) || + existing.BatchOutputIndex != r.BatchOutputIndex || + existing.CSVExpiryDelta != r.CSVExpiryDelta || + len(existing.ConsumedInputs) != len(r.ConsumedInputs) { + + existing.RegistrationStage = RegistrationQuarantined + existing.ReadyGeneration = fn.None[uint64]() + existing.Revision++ + + return ErrRegistrationConflict + } + + type inputEvidence struct { + value int64 + pkScript []byte + } + inputs := make( + map[wire.OutPoint]inputEvidence, + len(existing.ConsumedInputs), + ) + for _, in := range existing.ConsumedInputs { + inputs[in.Outpoint] = inputEvidence{ + value: in.Value, + pkScript: in.PkScript, + } + } + for _, in := range r.ConsumedInputs { + evidence, ok := inputs[in.Outpoint] + if !ok || evidence.value != in.Value || + !bytes.Equal(evidence.pkScript, in.PkScript) { + + existing.RegistrationStage = + RegistrationQuarantined + existing.ReadyGeneration = fn.None[uint64]() + existing.Revision++ + + return ErrRegistrationConflict + } + } + + seen := make( + map[wire.OutPoint]struct{}, + len(existing.DependentVTXOs), + ) + for _, dep := range existing.DependentVTXOs { + seen[dep] = struct{}{} + } + for _, dep := range r.DependentVTXOs { + if _, exists := seen[dep]; exists { + continue + } + existing.DependentVTXOs = append( + existing.DependentVTXOs, dep, + ) + seen[dep] = struct{}{} + } + } else if !ok { + s.records[r.BatchTxID] = cloneRecord(r) + } + + consumerSeen := make( + map[wire.OutPoint]ConsumerEdge, len(s.consumers[r.BatchTxID]), + ) + for _, edge := range s.consumers[r.BatchTxID] { + consumerSeen[edge.ConsumedVTXO] = edge + } + for _, edge := range edges { + edge.ConsumerBatch = r.BatchTxID + if existing, exists := consumerSeen[edge.ConsumedVTXO]; exists { + if existing.ExpectedRevision != edge.ExpectedRevision || + !sameLineage( + existing.CreatorLineage, + edge.CreatorLineage, + ) { + return ErrRegistrationConflict + } + + continue + } + s.consumers[r.BatchTxID] = append( + s.consumers[r.BatchTxID], edge, + ) + consumerSeen[edge.ConsumedVTXO] = edge + } + + return nil +} + +func sameLineage(a, b []chainhash.Hash) bool { + if len(a) != len(b) { + return false + } + want := make(map[chainhash.Hash]struct{}, len(a)) + for _, txid := range a { + want[txid] = struct{}{} + } + for _, txid := range b { + if _, ok := want[txid]; !ok { + return false + } + } + + return true +} + +func (s *fakeStore) BeginReconcile(_ context.Context, txid chainhash.Hash) ( + *Record, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[txid] + if !ok { + return nil, ErrBatchNotFound + } + + record.RegistrationStage = RegistrationReconciling + record.ObservationGeneration++ + if record.ObservationGeneration == 0 { + record.ObservationGeneration = 1 + } + record.ReadyGeneration = fn.None[uint64]() + record.Revision++ + + return cloneRecord(record), nil +} + +func (s *fakeStore) MarkReady(_ context.Context, txid chainhash.Hash, + generation uint64) error { + + s.mu.Lock() + defer s.mu.Unlock() + + record, ok := s.records[txid] + if !ok { + return ErrBatchNotFound + } + if record.ObservationGeneration != generation { + return fmt.Errorf("stale batch readiness generation %d", + generation) + } + + record.RegistrationStage = RegistrationComplete + record.ReadyGeneration = fn.Some(generation) + record.Revision++ + + return nil +} + +func (s *fakeStore) ApplyObservation(_ context.Context, + snapshot *ObservationSnapshot) error { + + s.mu.Lock() + defer s.mu.Unlock() + if s.applyErr != nil { + return s.applyErr + } + + record, ok := s.records[snapshot.BatchTxID] + if !ok { + return ErrBatchNotFound + } + if record.ObservationGeneration != snapshot.Generation || + record.RegistrationStage == RegistrationQuarantined { + return fmt.Errorf("stale or quarantined batch observation") + } + if len(record.ConsumedInputs) != len(snapshot.Inputs) { + return fmt.Errorf("batch observation input count changed") + } + + observations := make( + map[wire.OutPoint]InputObservation, len(snapshot.Inputs), + ) + for _, input := range snapshot.Inputs { + if _, duplicate := observations[input.Outpoint]; duplicate { + return fmt.Errorf("batch observation duplicates "+ + "input %s", input.Outpoint) + } + observations[input.Outpoint] = input + } + for i := range record.ConsumedInputs { + input := &record.ConsumedInputs[i] + observation, ok := observations[input.Outpoint] + if !ok { + return fmt.Errorf("batch observation omits input %s", + input.Outpoint) + } + input.Conflicting = observation.Conflicting + input.ConflictFinal = observation.ConflictFinal + } + + record.State = snapshot.State + record.ConfirmationHeight = snapshot.ConfirmationHeight + record.ConfirmationBlock = snapshot.ConfirmationBlock + if snapshot.Ready { + record.RegistrationStage = RegistrationComplete + record.ReadyGeneration = fn.Some(snapshot.Generation) + } else { + record.ReadyGeneration = fn.None[uint64]() + } + record.Revision++ + + return nil +} + +func (s *fakeStore) setApplyError(err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.applyErr = err +} + +func (s *fakeStore) UpsertBatch(_ context.Context, r *Record) error { + s.mu.Lock() + defer s.mu.Unlock() + s.records[r.BatchTxID] = cloneRecord(r) + + return nil +} + +func (s *fakeStore) GetBatch(_ context.Context, txid chainhash.Hash) (*Record, + error) { + + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.records[txid] + if !ok { + return nil, ErrBatchNotFound + } + + return cloneRecord(r), nil +} + +func (s *fakeStore) ListBatchesByState(_ context.Context, state State) ( + []*Record, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []*Record + for _, r := range s.records { + if r.State == state { + out = append(out, cloneRecord(r)) + } + } + + return out, nil +} + +func (s *fakeStore) UpdateBatchState(_ context.Context, txid chainhash.Hash, + state State) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.State = state + r.Revision++ + } + + return nil +} + +func (s *fakeStore) RecordInputConflict(_ context.Context, + batchTxid chainhash.Hash, op wire.OutPoint, conflicting, + conflictFinal bool) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[batchTxid]; ok { + for i := range r.ConsumedInputs { + in := &r.ConsumedInputs[i] + if in.Outpoint == op { + in.Conflicting = conflicting + in.ConflictFinal = conflictFinal + } + } + } + + return nil +} + +func (s *fakeStore) RecordConfirmation(_ context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.Some(height) + r.ConfirmationBlock = fn.Some(block) + } + + return nil +} + +func (s *fakeStore) ClearConfirmation(_ context.Context, + txid chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.None[int32]() + r.ConfirmationBlock = fn.None[chainhash.Hash]() + } + + return nil +} + +func (s *fakeStore) FindBatchesConsumingOutpoint(_ context.Context, + op wire.OutPoint) ([]chainhash.Hash, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []chainhash.Hash + for txid, r := range s.records { + for _, in := range r.ConsumedInputs { + if in.Outpoint == op { + out = append(out, txid) + } + } + } + + return out, nil +} + +func (s *fakeStore) ListPendingConsumerEdges(_ context.Context, + consumerBatch chainhash.Hash) ([]ConsumerEdge, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + return append([]ConsumerEdge(nil), s.consumers[consumerBatch]...), nil +} + +func (s *fakeStore) ListPendingConsumerBatchesByCreator(_ context.Context, + creatorBatch chainhash.Hash) ([]chainhash.Hash, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + seen := make(map[chainhash.Hash]struct{}) + consumers := make([]chainhash.Hash, 0) + for consumer, edges := range s.consumers { + for _, edge := range edges { + for _, creator := range edge.CreatorLineage { + if creator != creatorBatch { + continue + } + if _, ok := seen[consumer]; !ok { + seen[consumer] = struct{}{} + consumers = append(consumers, consumer) + } + } + } + } + + return consumers, nil +} + +func (s *fakeStore) ResolveConsumerEdge(_ context.Context, edge ConsumerEdge, + restore bool) (ConsumerEdgeResolution, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + edges := s.consumers[edge.ConsumerBatch] + for i, pending := range edges { + if pending.ConsumedVTXO != edge.ConsumedVTXO || + pending.ExpectedRevision != edge.ExpectedRevision { + + continue + } + s.consumers[edge.ConsumerBatch] = append( + edges[:i], edges[i+1:]..., + ) + if restore { + return ConsumerEdgeRestored, nil + } + + return ConsumerEdgeCompleted, nil + } + + return ConsumerEdgeDeferred, nil +} + +func (s *fakeStore) DeleteProvisionalConsumersForBatch(_ context.Context, + consumerBatch chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + delete(s.consumers, consumerBatch) + + return nil +} + +var _ Store = (*fakeStore)(nil) + +// --------------------------------------------------------------------------- +// Mock chainsource actor: captures the reorg-aware notification refs from +// register requests and lets the test fire lifecycle events back at them. +// --------------------------------------------------------------------------- + +type confRefs struct { + confirmed actor.TellOnlyRef[chainsource.ConfirmationEvent] + reorged actor.TellOnlyRef[chainsource.ConfReorgedEvent] + done actor.TellOnlyRef[chainsource.ConfDoneEvent] +} + +type spendRefs struct { + spend actor.TellOnlyRef[chainsource.SpendEvent] + reorged actor.TellOnlyRef[chainsource.SpendReorgedEvent] + done actor.TellOnlyRef[chainsource.SpendDoneEvent] +} + +type mockChainSource struct { + mu sync.Mutex + bestHeight int32 + confByTxid map[chainhash.Hash]confRefs + spendByOp map[wire.OutPoint]map[string]spendRefs + confCancels map[chainhash.Hash]int + spendCancel map[wire.OutPoint]int +} + +func newMockChainSource(bestHeight int32) *mockChainSource { + return &mockChainSource{ + bestHeight: bestHeight, + confByTxid: make(map[chainhash.Hash]confRefs), + spendByOp: make(map[wire.OutPoint]map[string]spendRefs), + confCancels: make(map[chainhash.Hash]int), + spendCancel: make(map[wire.OutPoint]int), + } +} + +func (c *mockChainSource) Receive(_ context.Context, + msg chainsource.ChainSourceMsg) fn.Result[chainsource.ChainSourceResp] { + + switch v := msg.(type) { + case *chainsource.BestHeightRequest: + c.mu.Lock() + h := c.bestHeight + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{ + Height: h, + }, + ) + + case *chainsource.RegisterConfRequest: + c.mu.Lock() + c.confByTxid[*v.Txid] = confRefs{ + confirmed: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterConfResponse{}, + ) + + case *chainsource.RegisterSpendRequest: + c.mu.Lock() + registrations, ok := c.spendByOp[*v.Outpoint] + if !ok { + registrations = make(map[string]spendRefs) + c.spendByOp[*v.Outpoint] = registrations + } + registrations[v.CallerID] = spendRefs{ + spend: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterSpendResponse{}, + ) + + case *chainsource.UnregisterConfRequest: + c.mu.Lock() + c.confCancels[*v.Txid]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterConfResponse{}, + ) + + case *chainsource.UnregisterSpendRequest: + c.mu.Lock() + c.spendCancel[*v.Outpoint]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterSpendResponse{}, + ) + + default: + return fn.Err[chainsource.ChainSourceResp]( + errUnexpected(msg), + ) + } +} + +func errUnexpected(msg chainsource.ChainSourceMsg) error { + return &unexpectedMsgErr{msg: msg.MessageType()} +} + +type unexpectedMsgErr struct{ msg string } + +func (e *unexpectedMsgErr) Error() string { + return "mock chainsource: unexpected message " + e.msg +} + +// getConfRefs waits until the manager has registered a conf watch for txid and +// returns the captured refs. +func (c *mockChainSource) getConfRefs(t *testing.T, + txid chainhash.Hash) confRefs { + + t.Helper() + var refs confRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.confByTxid[txid] + if ok { + refs = r + } + + return ok + }, testTimeout, 5*time.Millisecond, "conf watch never registered") + + return refs +} + +func (c *mockChainSource) getSpendRefs(t *testing.T, + op wire.OutPoint) []spendRefs { + + t.Helper() + var refs []spendRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + registrations, ok := c.spendByOp[op] + if ok && len(registrations) > 0 { + refs = refs[:0] + for _, registration := range registrations { + refs = append(refs, registration) + } + } + + return len(refs) > 0 + }, testTimeout, 5*time.Millisecond, "spend watch never registered") + + return refs +} + +func (c *mockChainSource) spendCancelCount(op wire.OutPoint) int { + c.mu.Lock() + defer c.mu.Unlock() + + return c.spendCancel[op] +} + +// --------------------------------------------------------------------------- +// Harness. +// --------------------------------------------------------------------------- + +type managerHarness struct { + mgrRef actor.ActorRef[ManagerMsg, ManagerResp] + mgr *Manager + mock *mockChainSource + store *fakeStore +} + +func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { + return newManagerHarnessWithRestore(t, bestHeight, nil) +} + +// newManagerHarnessWithRestore is newManagerHarness with a RestoreConsumedVTXO +// callback wired into the manager config, for the reverse-dependency +// (provisional-forfeit restore) tests. +func newManagerHarnessWithRestore(t *testing.T, bestHeight int32, + restore func(ctx context.Context, vtxo wire.OutPoint) error, +) *managerHarness { + + t.Helper() + + mock := newMockChainSource(bestHeight) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ + ID: "mock-chainsource", + Behavior: mock, + MailboxSize: 64, + }) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + store := newFakeStore() + mgr := NewManager(ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + ActivateRestoredVTXO: restore, + allowIncompleteTestEvidence: true, + }) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "batch-canonicality", + Behavior: mgr, + MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + return &managerHarness{ + mgrRef: mgrActor.Ref(), + mgr: mgr, + mock: mock, + store: store, + } +} + +// registerBatch registers a batch and waits for the synchronous response. +func (h *managerHarness) registerBatch(t *testing.T, + req *RegisterBatchRequest) { + + t.Helper() + if len(req.ConfirmationPkScript) == 0 { + req.ConfirmationPkScript = []byte{0x51} + } + if len(req.ConsumedInputs) == 0 { + req.ConsumedInputs = []ConsumedInput{ci(wire.OutPoint{ + Hash: req.BatchTxID, + })} + } + _, err := h.mgrRef.Ask(t.Context(), req).Await(t.Context()).Unpack() + require.NoError(t, err) +} + +// state reads the persisted record for a batch via the manager. Because the +// manager mailbox is FIFO, issuing this Ask after a fired event guarantees the +// event was processed first. +func (h *managerHarness) state(t *testing.T, + txid chainhash.Hash) *GetBatchStateResponse { + + t.Helper() + resp, err := h.mgrRef.Ask( + t.Context(), &GetBatchStateRequest{BatchTxID: txid}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + got, ok := resp.(*GetBatchStateResponse) + require.True(t, ok) + + return got +} + +// fire helpers Tell the captured chainsource refs, synchronously enqueuing the +// re-wrapped event onto the manager mailbox. +func (h *managerHarness) fireConfirmed(t *testing.T, txid chainhash.Hash, + height int32, block chainhash.Hash) { + + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.confirmed.Tell( + t.Context(), chainsource.ConfirmationEvent{ + Txid: txid, + BlockHeight: height, + BlockHash: block, + NumConfs: 1, + }, + ), + ) +} + +func (h *managerHarness) fireConfReorged(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.ConfReorgedEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.ConfDoneEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireSpend(t *testing.T, op wire.OutPoint, + spender chainhash.Hash, height int32) { + + t.Helper() + for _, refs := range h.mock.getSpendRefs(t, op) { + require.NoError( + t, + refs.spend.Tell( + t.Context(), chainsource.SpendEvent{ + Outpoint: op, + SpendingTxid: spender, + SpendingHeight: height, + }, + ), + ) + } +} + +func (h *managerHarness) fireSpendReorged(t *testing.T, op wire.OutPoint) { + t.Helper() + for _, refs := range h.mock.getSpendRefs(t, op) { + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.SpendReorgedEvent{ + Outpoint: op, + }, + ), + ) + } +} + +func (h *managerHarness) fireSpendDone(t *testing.T, op wire.OutPoint) { + t.Helper() + for _, refs := range h.mock.getSpendRefs(t, op) { + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.SpendDoneEvent{ + Outpoint: op, + }, + ), + ) + } +} + +// --------------------------------------------------------------------------- +// Tests. +// --------------------------------------------------------------------------- + +func testBatchTxid(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + + return h +} + +func testOutpoint(b byte, idx uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: idx} +} + +// TestManagerConfirmThenFinalize drives the happy path: a registered batch is +// unseen, becomes provisional on first confirmation (with a derived effective +// expiry), then finalized on the chainsource Done. +func TestManagerConfirmThenFinalize(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xaa) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + CSVExpiryDelta: 144, + }) + + // Unseen before any observation. + got := h.state(t, txid) + require.True(t, got.Found) + require.Equal(t, StateUnseen, got.Record.State) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // First confirmation -> provisional, effective expiry derived. + h.fireConfirmed(t, txid, 101, testBatchTxid(0xb1)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(101), got.Record.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(245), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Policy finality -> finalized. + h.fireConfDone(t, txid) + got = h.state(t, txid) + require.Equal(t, StateFinalized, got.Record.State) +} + +// TestManagerReorgRecovers proves the core reorg-safety property: a confirmed +// batch that is reorged out moves to reorged_out (with expiry erased), then +// recovers to provisional on reconfirmation at a new height (with a fresh +// effective expiry), then finalizes. +func TestManagerReorgRecovers(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xcc) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 100, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0xd1)) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(201), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: state reorged_out, confirmation (and effective expiry) + // cleared. + h.fireConfReorged(t, txid) + got = h.state(t, txid) + require.Equal(t, StateReorgedOut, got.Record.State) + require.True(t, got.Record.ConfirmationHeight.IsNone()) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: provisional again, fresh expiry. + h.fireConfirmed(t, txid, 105, testBatchTxid(0xd2)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(205), got.Record.EffectiveExpiry().UnwrapOr(0)) + + h.fireConfDone(t, txid) + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) +} + +// TestManagerInputConflict proves conflict detection: a consumed input spent +// by a transaction OTHER than the batch is a conflict (conflict_provisional), +// promoted to conflict_finalized once the conflicting spend matures. +func TestManagerInputConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x11) + input := testOutpoint(0x22, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0x33)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A different tx double-spends the consumed input. + conflictTx := testBatchTxid(0x99) + h.fireSpend(t, input, conflictTx, 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflict matures -> conflict_finalized. + h.fireSpendDone(t, input) + require.Equal( + t, StateConflictFinalized, h.state(t, txid).Record.State, + ) +} + +// TestManagerConflictClearsOnSpendReorg proves a conflict is reversible: if the +// conflicting spend is itself reorged out, the batch returns to its +// confirmation-derived state. +func TestManagerConflictClearsOnSpendReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x41) + input := testOutpoint(0x42, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x43)) + + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflicting spend reorgs out -> conflict cleared, back to + // provisional (the batch is still confirmed). + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerBatchSelfSpendNotConflict proves that the batch consuming its own +// input (the expected case) is not treated as a conflict. +func TestManagerBatchSelfSpendNotConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x51) + input := testOutpoint(0x52, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x53)) + + // The spend is by the batch itself: not a conflict. + h.fireSpend(t, input, txid, 101) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // And its maturation is the normal consumption, not a conflict. + h.fireSpendDone(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerConflictDominatesReorg proves the state priority: when a batch is +// both reorged out AND has a conflicting input spend, conflict dominates. +func TestManagerConflictDominatesReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x61) + input := testOutpoint(0x62, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x63)) + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) + + // A conflicting spend appears while the batch is reorged out: conflict + // dominates reorged_out. + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) +} + +// TestManagerFinalizeReleasesSpendWatches proves the manager waits for every +// current-generation subject before releasing watches, even if confirmation +// Done arrives ahead of the input's own-spend observation. +func TestManagerFinalizeReleasesSpendWatches(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x71) + input := testOutpoint(0x72, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x73)) + h.fireConfDone(t, txid) + require.Equal(t, 0, h.mock.spendCancelCount(input)) + h.fireSpend(t, input, txid, 101) + + // Drain via a state read, then assert the complete terminal snapshot + // released the spend watch. + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) + require.Eventually(t, func() bool { + return h.mock.spendCancelCount(input) == 1 + }, testTimeout, 5*time.Millisecond, + "input spend watch not released on finalize") +} + +// TestManagerRegisterIdempotentMergesDependents proves a repeat registration +// merges dependent VTXOs without re-arming or losing state. +func TestManagerRegisterIdempotentMergesDependents(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x81) + depA := testOutpoint(0x8a, 0) + depB := testOutpoint(0x8b, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depA}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x83)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // Repeat with an additional dependent: merged, state preserved. + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depB}, + }) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.ElementsMatch( + t, []wire.OutPoint{depA, depB}, got.Record.DependentVTXOs, + ) +} + +// TestManagerReconcileReArmsWatches proves restart reconciliation: a manager +// started against a store with a persisted provisional batch re-arms its +// watches and does not downgrade the persisted state before re-observation. +func TestManagerReconcileReArmsWatches(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x91) + input := testOutpoint(0x92, 0) + + // Seed a persisted provisional batch as if a prior run had observed it. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + BatchTx: []byte{0x00}, + State: StateProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConfirmationPkScript: []byte{0x51}, + ConsumedInputs: []ConsumedInput{ + ci(input), + }, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // Watches re-armed for the persisted batch. + mock.getConfRefs(t, txid) + mock.getSpendRefs(t, input) + + // State not downgraded by reconcile. + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A reorg after restart is still handled correctly. + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) +} + +// TestManagerReconcileConflictNotDowngradedByConfReplay proves that a persisted +// conflict_provisional batch is NOT transiently downgraded to provisional when, +// after a restart, the confirmation is re-observed before the conflicting spend +// is re-observed. Reconciliation seeds the per-input conflict view from the +// persisted flags, so a bare re-confirmation cannot clear a conflict it did not +// resolve. Without the fix this asserted state 4 (conflict_provisional) but got +// state 1 (provisional) — a window in which the coin would be wrongly admitted. +func TestManagerReconcileConflictNotDowngradedByConfReplay(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x93) + input := testOutpoint(0x94, 0) + + // Seed a persisted conflict_provisional batch whose consumed input was + // observed conflicting by a prior run: confirmed, but a foreign tx + // double-spent its input. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + BatchTx: []byte{0x00}, + State: StateConflictProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConfirmationPkScript: []byte{0x51}, + ConsumedInputs: []ConsumedInput{{ + Outpoint: input, + PkScript: []byte{0x51}, + Conflicting: true, + }}, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + + // Reconcile alone must preserve the persisted conflict. + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The confirmation is re-observed first (the batch is still mined). + // This must NOT clear the conflict: the conflicting spend has not been + // observed to reorg away. + h.fireConfirmed(t, txid, 90, testBatchTxid(0x95)) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + "re-confirmation wrongly downgraded a persisted conflict", + ) + + // The conflict clears only when the conflicting spend is observed to + // reorg out — then, and only then, the batch returns to provisional. + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} diff --git a/batchcanon/messages.go b/batchcanon/messages.go new file mode 100644 index 000000000..b90f8b65d --- /dev/null +++ b/batchcanon/messages.go @@ -0,0 +1,319 @@ +package batchcanon + +import ( + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" +) + +// ManagerMsg is the sealed inbound message interface for the +// BatchCanonicalityManager. It covers both the public register/query API and +// the internal chain-observation messages re-wrapped from chainsource. +type ManagerMsg interface { + actor.Message + + managerMsgSealed() +} + +// ManagerResp is the sealed response interface for the manager. +type ManagerResp interface { + actor.Message + + managerRespSealed() +} + +// RegisterBatchRequest registers (or re-registers, idempotently) a batch with +// the manager: it persists a canonicality record, registers a reorg-aware +// confirmation watch on the batch tx, and a reorg-aware spend watch on every +// consumed input. Calling it again for the same batch txid merges the +// dependent VTXOs into the record without duplicating watches. +type RegisterBatchRequest struct { + actor.BaseMessage + + // BatchTxID is the batch (commitment) transaction id. + BatchTxID chainhash.Hash + + // BatchTx is the serialized batch transaction. Registration derives its + // txid and exact consumed-input set from these bytes instead of + // trusting a caller-supplied subset. + BatchTx []byte + + // BatchOutputIndex selects the batch transaction output watched for + // confirmation. Its script must exactly match ConfirmationPkScript. + BatchOutputIndex uint32 + + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. Required for light-client backends and + // persisted for restart re-registration. + ConfirmationPkScript []byte + + // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. + CSVExpiryDelta int32 + + // ConsumedInputs are the exact inputs the batch tx spends, each + // carrying the authenticated value and pkScript of the spent output. + // Each gets a reorg-aware spend watch so a conflicting double-spend is + // detected; the pkScript is required because lnd's spend notifier + // filters by output script. + ConsumedInputs []ConsumedInput + + // DependentVTXOs are the VTXO outpoints anchored by this batch. + DependentVTXOs []wire.OutPoint + + // ConsumedVTXOs are logical VTXOs from prior batches that this batch + // forfeits. Each edge binds the exact business revision and complete + // creator lineage needed for conditional restoration. This is distinct + // from ConsumedInputs, the batch transaction's actual Bitcoin inputs. + ConsumedVTXOs []ConsumerEdge + + // ForfeitedVTXOs is the legacy outpoint-only producer shape. Production + // registration rejects it because it lacks creator lineage and the + // exact expected business revision. It remains temporarily for + // compile-time compatibility while producer wiring moves to + // ConsumedVTXOs. + ForfeitedVTXOs []wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchRequest) MessageType() string { + return "batchcanon.RegisterBatchRequest" +} + +func (m *RegisterBatchRequest) managerMsgSealed() {} + +// RegisterBatchResponse is the reply to RegisterBatchRequest. +type RegisterBatchResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchResponse) MessageType() string { + return "batchcanon.RegisterBatchResponse" +} + +func (m *RegisterBatchResponse) managerRespSealed() {} + +// GetBatchStateRequest reads the current canonicality record for a batch. +type GetBatchStateRequest struct { + actor.BaseMessage + + // BatchTxID is the batch tx to look up. + BatchTxID chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateRequest) MessageType() string { + return "batchcanon.GetBatchStateRequest" +} + +func (m *GetBatchStateRequest) managerMsgSealed() {} + +// GetBatchStateResponse carries the looked-up record, if present. +type GetBatchStateResponse struct { + actor.BaseMessage + + // Record is the canonicality record. Nil when Found is false. + Record *Record + + // Found reports whether a record existed for the batch. + Found bool +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateResponse) MessageType() string { + return "batchcanon.GetBatchStateResponse" +} + +func (m *GetBatchStateResponse) managerRespSealed() {} + +// LineageRevision binds one batch's ready generation and availability +// revision into an AdmissionToken. +type LineageRevision struct { + BatchTxID chainhash.Hash + Generation uint64 + Revision uint64 +} + +// AdmissionToken is returned only for a ready, usable complete lineage. A +// critical side effect must validate the token through the manager immediately +// before crossing its point of no return. +type AdmissionToken struct { + Lineage []LineageRevision +} + +// QueryLineageRequest asks for the fail-closed availability of a complete +// inherited lineage. +type QueryLineageRequest struct { + actor.BaseMessage + + BatchTxIDs []chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *QueryLineageRequest) MessageType() string { + return "batchcanon.QueryLineageRequest" +} + +func (m *QueryLineageRequest) managerMsgSealed() {} + +// QueryLineageResponse carries availability and, only when admitted, a token +// binding the current ready generation and revision of every ancestor. +type QueryLineageResponse struct { + actor.BaseMessage + + Availability Availability + Token *AdmissionToken +} + +// MessageType returns the message type identifier. +func (m *QueryLineageResponse) MessageType() string { + return "batchcanon.QueryLineageResponse" +} + +func (m *QueryLineageResponse) managerRespSealed() {} + +// ValidateAdmissionRequest revalidates a previously issued token immediately +// before a critical side effect. +type ValidateAdmissionRequest struct { + actor.BaseMessage + + Token AdmissionToken +} + +// MessageType returns the message type identifier. +func (m *ValidateAdmissionRequest) MessageType() string { + return "batchcanon.ValidateAdmissionRequest" +} + +func (m *ValidateAdmissionRequest) managerMsgSealed() {} + +// ValidateAdmissionResponse reports whether the exact token remains current. +// Availability carries the latest fail-closed result when it is stale. +type ValidateAdmissionResponse struct { + actor.BaseMessage + + Valid bool + Availability Availability +} + +// MessageType returns the message type identifier. +func (m *ValidateAdmissionResponse) MessageType() string { + return "batchcanon.ValidateAdmissionResponse" +} + +func (m *ValidateAdmissionResponse) managerRespSealed() {} + +// ackResponse is the no-op reply for internal Tell-delivered messages. +type ackResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *ackResponse) MessageType() string { + return "batchcanon.ackResponse" +} + +func (m *ackResponse) managerRespSealed() {} + +// batchConfirmedMsg is the internal re-wrap of a chainsource ConfirmationEvent +// for a watched batch tx. +type batchConfirmedMsg struct { + actor.BaseMessage + + txid chainhash.Hash + generation uint64 + blockHeight int32 + blockHash chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchConfirmedMsg) MessageType() string { + return "batchcanon.batchConfirmedMsg" +} + +func (m *batchConfirmedMsg) managerMsgSealed() {} + +// batchReorgedMsg is the internal re-wrap of a chainsource ConfReorgedEvent. +type batchReorgedMsg struct { + actor.BaseMessage + + txid chainhash.Hash + generation uint64 +} + +// MessageType returns the message type identifier. +func (m *batchReorgedMsg) MessageType() string { + return "batchcanon.batchReorgedMsg" +} + +func (m *batchReorgedMsg) managerMsgSealed() {} + +// batchDoneMsg is the internal re-wrap of a chainsource ConfDoneEvent: the +// batch confirmation has matured past the reorg-safety depth (policy +// finality). +type batchDoneMsg struct { + actor.BaseMessage + + txid chainhash.Hash + generation uint64 +} + +// MessageType returns the message type identifier. +func (m *batchDoneMsg) MessageType() string { + return "batchcanon.batchDoneMsg" +} + +func (m *batchDoneMsg) managerMsgSealed() {} + +// inputSpentMsg is the internal re-wrap of a chainsource SpendEvent on a +// consumed batch input. +type inputSpentMsg struct { + actor.BaseMessage + + batchTxid chainhash.Hash + generation uint64 + outpoint wire.OutPoint + spendingTxid chainhash.Hash + spendHeight int32 +} + +// MessageType returns the message type identifier. +func (m *inputSpentMsg) MessageType() string { + return "batchcanon.inputSpentMsg" +} + +func (m *inputSpentMsg) managerMsgSealed() {} + +// inputSpendReorgedMsg is the internal re-wrap of a chainsource +// SpendReorgedEvent: a previously observed spend left the best chain. +type inputSpendReorgedMsg struct { + actor.BaseMessage + + batchTxid chainhash.Hash + generation uint64 + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendReorgedMsg) MessageType() string { + return "batchcanon.inputSpendReorgedMsg" +} + +func (m *inputSpendReorgedMsg) managerMsgSealed() {} + +// inputSpendDoneMsg is the internal re-wrap of a chainsource SpendDoneEvent: +// the spend observation matured past the reorg-safety depth. +type inputSpendDoneMsg struct { + actor.BaseMessage + + batchTxid chainhash.Hash + generation uint64 + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendDoneMsg) MessageType() string { + return "batchcanon.inputSpendDoneMsg" +} + +func (m *inputSpendDoneMsg) managerMsgSealed() {} diff --git a/batchcanon/record.go b/batchcanon/record.go new file mode 100644 index 000000000..e4ca57523 --- /dev/null +++ b/batchcanon/record.go @@ -0,0 +1,225 @@ +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 + + // BatchTx is the serialized commitment transaction whose hash is + // BatchTxID. It makes the registered consumed-input set independently + // checkable rather than a caller assertion. + BatchTx []byte + + // BatchOutputIndex selects the transaction output whose script is used + // for the confirmation watch. + BatchOutputIndex uint32 + + // RegistrationStage is the crash-safe evidence/readiness lifecycle. + // Only RegistrationComplete can be admitted. + RegistrationStage RegistrationStage + + // ObservationGeneration identifies the current reconciliation attempt. + // Restart increments it before any watch is armed. + ObservationGeneration uint64 + + // ReadyGeneration is set only after every registered subject has + // supplied a current observation for ObservationGeneration. + ReadyGeneration fn.Option[uint64] + + // Revision increments whenever readiness or semantic availability can + // change. Admission tokens bind to this value. + Revision uint64 + + // 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 + + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. It is persisted so the manager can + // re-register the watch after a restart, since light-client backends + // (neutrino, Esplora) filter confirmation notifications by pkScript. + // May be empty for records seeded by descriptor backfill, which has no + // batch-output pkScript to derive. + ConfirmationPkScript []byte + + // PolicyState is the reserved policy classification slot. See + // PolicyState. + PolicyState PolicyState + + // ConsumedInputs are the inputs this batch tx spends. They are tracked + // so the manager can watch each one for a conflicting spend. + ConsumedInputs []ConsumedInput + + // DependentVTXOs are the VTXO outpoints anchored by this batch. Their + // derived availability follows this batch's canonicality. + DependentVTXOs []wire.OutPoint +} + +// Ready reports whether the record has complete evidence and a fully installed +// snapshot for its current observation generation. +func (r *Record) Ready() bool { + return r.EvidenceComplete() && + r.RegistrationStage == RegistrationComplete && + r.ReadyGeneration.IsSome() && + r.ReadyGeneration.UnwrapOr(0) == r.ObservationGeneration +} + +// EvidenceComplete reports whether the durable row carries the minimum +// immutable subjects required to reproduce every chain watch. Registration +// performs the stronger serialized-transaction cross-check before a row can +// reach Ready; this cheap predicate also keeps upgrade placeholders and +// corrupt partial rows fail-closed on every read. +func (r *Record) EvidenceComplete() bool { + if len(r.BatchTx) == 0 || len(r.ConfirmationPkScript) == 0 || + len(r.ConsumedInputs) == 0 { + return false + } + + for _, input := range r.ConsumedInputs { + if input.Value < 0 || len(input.PkScript) == 0 { + return false + } + } + + return true +} + +// ConsumedInput is one input a batch (commitment) tx spends, paired with the +// pkScript of the output being spent. The pkScript is required to register the +// reorg-aware spend watch: lnd's spend notifier filters by the output's script, +// so a bare outpoint is rejected ("an output script must be provided"). It is +// persisted alongside the outpoint so the watch can be re-armed after a +// restart. +type ConsumedInput struct { + // Outpoint is the spent output. + Outpoint wire.OutPoint + + // Value is the authenticated previous-output value in satoshis. It is + // stored with PkScript so later lineage-proof validation binds the full + // prevout rather than only an outpoint label. + Value int64 + + // PkScript is the scriptPubKey of the spent output, used to register + // the spend watch. May be empty only for legacy/backfilled rows that + // predate script tracking; such inputs cannot be watched on + // light-client backends. + PkScript []byte + + // Conflicting is true while a conflicting spend of this input (a spend + // by a transaction other than the batch itself) is observed and has not + // been reorged out. Persisting it lets restart reconciliation rebuild + // the per-input conflict view so live re-observation cannot transiently + // downgrade a persisted conflict before the conflicting spend + // re-arrives. + Conflicting bool + + // ConflictFinal is true once a conflicting spend of this input has + // matured past the reorg-safety depth. Persisted for the same + // restart-reconciliation reason as Conflicting. + ConflictFinal bool +} + +// InputObservation is the mutable conflict view of one immutable consumed +// input. ObservationSnapshot writes every input together so chain evidence +// and its derived availability cannot tear across transactions. +type InputObservation struct { + Outpoint wire.OutPoint + Conflicting bool + ConflictFinal bool +} + +// ObservationSnapshot is one generation-tagged, fully derived chain view. +// The store applies the confirmation, every input conflict flag, State, +// readiness, and revision atomically. +type ObservationSnapshot struct { + BatchTxID chainhash.Hash + Generation uint64 + State State + ConfirmationHeight fn.Option[int32] + ConfirmationBlock fn.Option[chainhash.Hash] + Inputs []InputObservation + Ready bool +} + +// 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) +} + +// ConsumerEdge records that a locally relevant VTXO was consumed by a batch. +// Its creator lineage and expected business revision are immutable evidence +// used by the terminal restore compare-and-swap. +type ConsumerEdge 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 + + // ExpectedRevision is the VTXO business revision installed by the exact + // ForfeitedBy(ConsumerBatch) transition. + ExpectedRevision uint64 + + // CreatorLineage is the complete distinct commitment lineage that makes + // ConsumedVTXO exist. Restore requires this lineage to be ready and + // usable. + CreatorLineage []chainhash.Hash +} + +// ConsumerEdgeResolution is the durable outcome of resolving one terminal +// reverse edge. +type ConsumerEdgeResolution int + +const ( + // ConsumerEdgeDeferred leaves the edge pending because a + // compare-and-swap predicate is not currently satisfied. + ConsumerEdgeDeferred ConsumerEdgeResolution = iota + + // ConsumerEdgeRestored means the exact forfeiture marker was changed to + // Live and the edge completed in the same transaction. + ConsumerEdgeRestored + + // ConsumerEdgeCompleted means the edge completed without restoring, for + // example because the candidate's own creator lineage is invalidated. + ConsumerEdgeCompleted +) diff --git a/batchcanon/record_test.go b/batchcanon/record_test.go new file mode 100644 index 000000000..bf217f87c --- /dev/null +++ b/batchcanon/record_test.go @@ -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)) +} diff --git a/batchcanon/registration_validation_test.go b/batchcanon/registration_validation_test.go new file mode 100644 index 000000000..c99ba4577 --- /dev/null +++ b/batchcanon/registration_validation_test.go @@ -0,0 +1,169 @@ +package batchcanon + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +// TestValidateRegistrationBindsSerializedEvidence proves that registration +// cannot omit or substitute an actual transaction input and that the watched +// confirmation script is an output of the same transaction identity. +func TestValidateRegistrationBindsSerializedEvidence(t *testing.T) { + t.Parallel() + + valid := validRegistrationRequest(t) + require.NoError(t, validateRegistration(valid, false)) + + tests := []struct { + name string + mutate func(*RegisterBatchRequest) + want string + }{ + { + name: "missing serialized transaction", + mutate: func(req *RegisterBatchRequest) { + req.BatchTx = nil + }, + want: "serialized batch transaction is required", + }, + { + name: "wrong transaction identity", + mutate: func(req *RegisterBatchRequest) { + req.BatchTxID[0] ^= 1 + }, + want: "hash does not match", + }, + { + name: "trailing transaction bytes", + mutate: func(req *RegisterBatchRequest) { + req.BatchTx = append(req.BatchTx, 0) + }, + want: "trailing bytes", + }, + { + name: "output index out of range", + mutate: func(req *RegisterBatchRequest) { + req.BatchOutputIndex = 2 + }, + want: "output index 2 is out of range", + }, + { + name: "watch script is not selected output", + mutate: func(req *RegisterBatchRequest) { + req.ConfirmationPkScript = []byte{ + 0x51, + } + }, + want: "does not match confirmation pkScript", + }, + { + name: "input subset", + mutate: func(req *RegisterBatchRequest) { + req.ConsumedInputs = req.ConsumedInputs[:1] + }, + want: "transaction has 2 inputs, registration has 1", + }, + { + name: "input substitution", + mutate: func(req *RegisterBatchRequest) { + req.ConsumedInputs[1].Outpoint = testOutpoint( + 0xef, 9, + ) + }, + want: "is not registered", + }, + { + name: "duplicate input", + mutate: func(req *RegisterBatchRequest) { + req.ConsumedInputs[1].Outpoint = + req.ConsumedInputs[0].Outpoint + }, + want: "is duplicated", + }, + { + name: "negative previous output value", + mutate: func(req *RegisterBatchRequest) { + req.ConsumedInputs[0].Value = -1 + }, + want: "has negative value", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + req := cloneRegistrationRequest(valid) + test.mutate(req) + require.ErrorContains( + t, validateRegistration(req, false), test.want, + ) + }) + } +} + +// validRegistrationRequest creates a two-input transaction whose second +// output is the confirmation-watch subject. +func validRegistrationRequest(t *testing.T) *RegisterBatchRequest { + t.Helper() + + inputA := testOutpoint(0xa1, 1) + inputB := testOutpoint(0xb2, 2) + watchScript := []byte{0x51, 0x20, 0xc3} + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&inputA, nil, nil)) + tx.AddTxIn(wire.NewTxIn(&inputB, nil, nil)) + tx.AddTxOut(wire.NewTxOut(500, []byte{0x51})) + tx.AddTxOut(wire.NewTxOut(1_000, watchScript)) + + var raw bytes.Buffer + require.NoError(t, tx.Serialize(&raw)) + + return &RegisterBatchRequest{ + BatchTxID: tx.TxHash(), + BatchTx: raw.Bytes(), + BatchOutputIndex: 1, + ConfirmationPkScript: watchScript, + ConsumedInputs: []ConsumedInput{ + { + Outpoint: inputA, + Value: 700, + PkScript: []byte{ + 0x51, + }, + }, + { + Outpoint: inputB, + Value: 900, + PkScript: []byte{ + 0x00, + 0x14, + 0x01, + }, + }, + }, + } +} + +// cloneRegistrationRequest makes each parallel table case independent. +func cloneRegistrationRequest(req *RegisterBatchRequest) *RegisterBatchRequest { + clone := *req + clone.BatchTx = append([]byte(nil), req.BatchTx...) + clone.ConfirmationPkScript = append( + []byte(nil), req.ConfirmationPkScript..., + ) + clone.ConsumedInputs = append( + []ConsumedInput(nil), req.ConsumedInputs..., + ) + for i := range clone.ConsumedInputs { + clone.ConsumedInputs[i].PkScript = append( + []byte(nil), req.ConsumedInputs[i].PkScript..., + ) + } + + return &clone +} diff --git a/batchcanon/state.go b/batchcanon/state.go new file mode 100644 index 000000000..41eef4828 --- /dev/null +++ b/batchcanon/state.go @@ -0,0 +1,145 @@ +package batchcanon + +import "fmt" + +// RegistrationStage describes whether complete durable evidence and a +// generation-consistent chain snapshot are installed for a batch. Semantic +// State is never admissible unless the stage is RegistrationComplete and the +// ready generation matches the observation generation. +type RegistrationStage int + +const ( + // RegistrationRegistering means evidence is durably staged but the + // complete chain watch/snapshot generation has not been installed. + RegistrationRegistering RegistrationStage = iota + + // RegistrationReconciling means restart reconciliation is rebuilding a + // fresh observation generation. Admission remains closed. + RegistrationReconciling + + // RegistrationComplete means complete immutable evidence is present and + // ReadyGeneration identifies the installed observation generation. + RegistrationComplete + + // RegistrationQuarantined means a repeated registration contradicted + // immutable durable evidence. Admission remains closed until an + // explicit repair proves which evidence is authoritative. + RegistrationQuarantined +) + +// String returns a stable lower-snake-case registration stage. +func (s RegistrationStage) String() string { + switch s { + case RegistrationRegistering: + return "registering" + + case RegistrationReconciling: + return "reconciling" + + case RegistrationComplete: + return "complete" + + case RegistrationQuarantined: + return "quarantined" + + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// State is the canonicality state of a batch (commitment) transaction as +// interpreted from raw chain observation. Provisional states are reversible +// until the configured policy-finality boundary. Finalized states are terminal +// within the basic-v1 safety claim because their watches are released; +// post-finality deep-reorg detection is explicitly outside that claim. +// +// State is persisted as a typed INTEGER column. Values are append-only and +// MUST NOT be renumbered, because persisted rows reference them directly. +type State int + +const ( + // StateUnseen indicates the batch tx has not been observed confirmed + // on the best chain. This is the zero value: a freshly recorded + // batch with no confirmation observation is unseen. + StateUnseen State = iota + + // StateProvisional indicates the batch tx is confirmed but has not + // yet matured past the configured finality depth, so its + // confirmation may still be reorged out. + StateProvisional + + // StateFinalized indicates the batch tx confirmation has matured past + // the configured finality depth. This is policy finality at the + // configured depth, not a claim of absolute Bitcoin finality. + StateFinalized + + // StateReorgedOut indicates a previously observed confirmation left + // the best chain and no consumed input has been seen double-spent. + // The batch may reconfirm, so dependent VTXOs enter limbo rather than + // being invalidated. + StateReorgedOut + + // StateConflictProvisional indicates a consumed batch input was + // double-spent by a conflicting transaction on the best chain, and + // that conflicting spend has not yet matured past the finality depth. + StateConflictProvisional + + // StateConflictFinalized indicates a consumed-input conflict has + // matured past the finality depth. It is a terminal invalidation within + // the configured policy claim and may trigger conditional restoration + // of a logically consumed ancestor. + StateConflictFinalized +) + +// String returns a stable lower-snake-case name for the state, matching the +// vocabulary used in darepo#454 and the persisted-column documentation. +func (s State) String() string { + switch s { + case StateUnseen: + return "unseen" + + case StateProvisional: + return "provisional" + + case StateFinalized: + return "finalized" + + case StateReorgedOut: + return "reorged_out" + + case StateConflictProvisional: + return "conflict_provisional" + + case StateConflictFinalized: + return "conflict_finalized" + + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// PolicyState is a durable, reorg-independent policy classification slot for a +// batch. darepo#454 reserves this field in the data model; this layer +// persists and round-trips it but assigns no business meaning yet. The +// BatchCanonicalityManager and the admission gates in later tasks own its +// interpretation. +// +// Like State, it is persisted as an append-only typed INTEGER column. +type PolicyState int + +const ( + // PolicyStateDefault is the zero value and the only policy state + // defined at the data-model layer. + PolicyStateDefault PolicyState = iota +) + +// String returns a stable lower-snake-case name for the policy state. +func (p PolicyState) String() string { + switch p { + case PolicyStateDefault: + return "default" + + default: + return fmt.Sprintf("unknown(%d)", int(p)) + } +} diff --git a/batchcanon/state_test.go b/batchcanon/state_test.go new file mode 100644 index 000000000..8e35a6a72 --- /dev/null +++ b/batchcanon/state_test.go @@ -0,0 +1,97 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestReadyRequiresCompleteEvidence proves a matching generation marker cannot +// accidentally admit an upgrade placeholder or partially corrupt row. +func TestReadyRequiresCompleteEvidence(t *testing.T) { + t.Parallel() + + record := &Record{ + BatchTxID: chainhash.Hash{ + 0x01, + }, + RegistrationStage: RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: StateProvisional, + } + require.False(t, record.Ready()) + + completeTestRecordEvidence(record) + require.True(t, record.Ready()) +} + +// TestStateValuesStable pins the integer value and string name of every +// canonicality state. These values are persisted as a typed INTEGER column, +// so a change here would silently re-interpret existing rows — the test +// exists to make any renumbering a deliberate, visible edit. +func TestStateValuesStable(t *testing.T) { + t.Parallel() + + cases := []struct { + state State + value int + name string + }{ + { + StateUnseen, + 0, + "unseen", + }, + { + StateProvisional, + 1, + "provisional", + }, + { + StateFinalized, + 2, + "finalized", + }, + { + StateReorgedOut, + 3, + "reorged_out", + }, + { + StateConflictProvisional, + 4, + "conflict_provisional", + }, + { + StateConflictFinalized, + 5, + "conflict_finalized", + }, + } + + for _, tc := range cases { + require.Equal(t, tc.value, int(tc.state), tc.name) + require.Equal(t, tc.name, tc.state.String()) + } +} + +// TestStateStringUnknown verifies an out-of-range state stringifies to a +// diagnosable unknown form rather than an empty string. +func TestStateStringUnknown(t *testing.T) { + t.Parallel() + + require.Equal(t, "unknown(99)", State(99).String()) +} + +// TestPolicyStateStable pins the policy-state value and name. PolicyState is +// also persisted as an append-only typed INTEGER column. +func TestPolicyStateStable(t *testing.T) { + t.Parallel() + + require.Equal(t, 0, int(PolicyStateDefault)) + require.Equal(t, "default", PolicyStateDefault.String()) + require.Equal(t, "unknown(7)", PolicyState(7).String()) +} diff --git a/batchcanon/store.go b/batchcanon/store.go new file mode 100644 index 000000000..92b97d937 --- /dev/null +++ b/batchcanon/store.go @@ -0,0 +1,133 @@ +package batchcanon + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +// ErrBatchNotFound is returned by Store.GetBatch when no canonicality record +// exists for the requested batch txid. +var ( + // ErrBatchNotFound is returned when a canonicality record does not + // exist for the requested batch txid. + ErrBatchNotFound = errors.New("batch canonicality record not found") + + // ErrRegistrationConflict is returned when a repeated registration + // changes immutable batch evidence. Existing evidence is never + // replaced because doing so could erase an observed conflict or remove + // an input from the watched set. + ErrRegistrationConflict = errors.New("batch registration conflicts " + + "with durable evidence") +) + +// Reader is the fail-closed availability query surface used by admission +// consumers. Keeping it narrow prevents operation code from depending on +// canonicality mutation methods. +type Reader interface { + // GetBatch returns the canonicality record for a batch txid. It returns + // ErrBatchNotFound when no record exists. + GetBatch(ctx context.Context, txid chainhash.Hash) (*Record, error) +} + +// Store is the durable query/update surface for batch canonicality records. +// It is intentionally behavior-free: it persists and retrieves observations +// and reverse-dependency edges, leaving all interpretation — state +// transitions, chain watching, and admission — to the BatchCanonicalityManager +// and the later tasks of the reorg-safety epic. +// +//nolint:interfacebloat +type Store interface { + Reader + + // RegisterBatch atomically persists a complete batch record and all + // reverse consumer edges. Repeated calls must match the immutable batch + // evidence and may only add dependent VTXOs and consumer edges. + RegisterBatch(ctx context.Context, record *Record, + consumerEdges []ConsumerEdge) error + + // BeginReconcile durably closes admission and advances the observation + // generation before any restart watch is armed. + BeginReconcile(ctx context.Context, + txid chainhash.Hash) (*Record, error) + + // MarkReady installs Ready(g) after every registered subject supplied a + // current observation for generation g. A stale generation fails. + MarkReady(ctx context.Context, txid chainhash.Hash, + generation uint64) error + + // ApplyObservation atomically installs a complete generation-tagged + // observation snapshot. A stale generation or missing immutable input + // fails without changing any part of the durable view. + ApplyObservation(ctx context.Context, + snapshot *ObservationSnapshot) error + + // UpsertBatch inserts or replaces the canonicality record for a + // batch, including its consumed inputs and dependent VTXOs. It is the + // single entry point for first-seeing a batch and for wholesale + // rewrites; targeted mutations use the methods below. + UpsertBatch(ctx context.Context, record *Record) error + + // ListBatchesByState returns every batch currently in the given + // state. Used by the manager to find batches needing a particular + // follow-up (e.g. all provisional batches to re-check for finality). + ListBatchesByState(ctx context.Context, state State) ([]*Record, error) + + // UpdateBatchState transitions a batch to a new canonicality state + // without touching its other fields. + UpdateBatchState(ctx context.Context, txid chainhash.Hash, + state State) error + + // RecordInputConflict persists the observed conflict status of one of a + // batch's consumed inputs (a spend by a transaction other than the + // batch itself). It exists so restart reconciliation can rebuild the + // per-input conflict view and not transiently downgrade a persisted + // conflict before the conflicting spend is re-observed. + RecordInputConflict(ctx context.Context, batchTxid chainhash.Hash, + outpoint wire.OutPoint, conflicting, conflictFinal bool) error + + // RecordConfirmation records that the batch tx is confirmed at the + // given best-chain height and block hash. A later RecordConfirmation + // at a different height (after a reorg) overwrites the observation so + // the effective expiry tracks the new confirmation. + RecordConfirmation(ctx context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error + + // ClearConfirmation clears the confirmation observation for a batch, + // reflecting that its confirming block left the best chain. It does + // not set any terminal flag: the batch may reconfirm. + ClearConfirmation(ctx context.Context, txid chainhash.Hash) error + + // FindBatchesConsumingOutpoint returns the txids of every recorded + // batch that consumes the given outpoint. Used to detect input + // conflicts: two batches consuming the same outpoint are in conflict. + FindBatchesConsumingOutpoint(ctx context.Context, + outpoint wire.OutPoint) ([]chainhash.Hash, error) + + // ListPendingConsumerEdges returns the durable restore work owned by + // one terminally invalidated consumer batch. + ListPendingConsumerEdges(ctx context.Context, + consumerBatch chainhash.Hash) ([]ConsumerEdge, error) + + // ListPendingConsumerBatchesByCreator returns the distinct consumer + // batches whose pending restore evidence includes creatorBatch. It lets + // a creator-lineage state change redrive only the recovery checkpoints + // that change can unblock. + ListPendingConsumerBatchesByCreator(ctx context.Context, + creatorBatch chainhash.Hash) ([]chainhash.Hash, error) + + // ResolveConsumerEdge either performs the full conditional restore CAS + // or completes the edge without restoring. The VTXO business transition + // and edge completion are one transaction. + ResolveConsumerEdge(ctx context.Context, edge ConsumerEdge, + restore bool) (ConsumerEdgeResolution, error) + + // DeleteProvisionalConsumersForBatch removes every reverse-dependency + // edge for the given consumer batch, used once the batch is canonical + // (the consumption is no longer provisional) or fully invalidated and + // reconciled. + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatch chainhash.Hash) error +} From aaa54b4c9f8704875de48dcc089d2dccf32cabde Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 17:35:10 -0700 Subject: [PATCH 03/16] multi: Wire batch canonicality into the store and VTXO admission Implement the durable canonicality Store over the schema and wire the VTXO manager's admission path onto the authority: coin selection and forfeit admission load a candidate's complete inherited lineage and refuse anything whose worst-parent availability is not usable. Track each VTXO's business revision and forfeit-consumer batch so a terminally invalidated consumer can restore a consumed VTXO only through a conditional compare-and-swap on its exact expected forfeiture marker. Round and OOR lineage registration before exposure, the remaining operation gates, and the reorg/restart scenario tests land in the following PRs. --- db/batch_canonicality_store.go | 1442 +++++++++++++++++++++++++ db/batch_canonicality_store_test.go | 1383 ++++++++++++++++++++++++ db/store.go | 23 + db/vtxo_store.go | 55 +- db/vtxo_store_test.go | 10 +- oor/local_persistence_handler_test.go | 2 +- unroll/actor_test.go | 2 +- vtxo/actor.go | 5 + vtxo/harness_test.go | 5 +- vtxo/interfaces.go | 11 +- vtxo/outbox_messages.go | 9 + vtxo/transitions.go | 19 +- vtxo/transitions_test.go | 6 +- waved/wallet_ops_test.go | 2 +- 14 files changed, 2938 insertions(+), 36 deletions(-) create mode 100644 db/batch_canonicality_store.go create mode 100644 db/batch_canonicality_store_test.go diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go new file mode 100644 index 000000000..845222406 --- /dev/null +++ b/db/batch_canonicality_store.go @@ -0,0 +1,1442 @@ +package db + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db/sqlc" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// BatchCanonicalityStore groups the generated SQL methods needed to persist +// the batch canonicality data model. +// +//nolint:interfacebloat // One handle keeps all canonicality ExecTx closures. +type BatchCanonicalityStore interface { + ApplyBatchCanonicalityObservation(ctx context.Context, + arg sqlc.ApplyBatchCanonicalityObservationParams) (int64, error) + + UpsertBatchCanonicality(ctx context.Context, + arg sqlc.UpsertBatchCanonicalityParams) error + + GetBatchCanonicality(ctx context.Context, + batchTxid []byte) (sqlc.BatchCanonicality, error) + + ListBatchCanonicalityByState(ctx context.Context, + state int32) ([]sqlc.BatchCanonicality, error) + + BeginBatchCanonicalityReconcile(ctx context.Context, + arg sqlc.BeginBatchCanonicalityReconcileParams) ( + sqlc.BatchCanonicality, error) + + MarkBatchCanonicalityReady(ctx context.Context, + arg sqlc.MarkBatchCanonicalityReadyParams) (int64, error) + + QuarantineBatchCanonicality(ctx context.Context, + arg sqlc.QuarantineBatchCanonicalityParams) error + + UpdateBatchCanonicalityState(ctx context.Context, + arg sqlc.UpdateBatchCanonicalityStateParams) error + + RecordBatchConfirmation(ctx context.Context, + arg sqlc.RecordBatchConfirmationParams) error + + ClearBatchConfirmation(ctx context.Context, + arg sqlc.ClearBatchConfirmationParams) error + + InsertBatchConsumedInput(ctx context.Context, + arg sqlc.InsertBatchConsumedInputParams) error + + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + + ListBatchConsumedInputs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchConsumedInputsRow, error) + + RecordBatchInputConflict(ctx context.Context, + arg sqlc.RecordBatchInputConflictParams) (int64, error) + + FindBatchesByConsumedOutpoint(ctx context.Context, + arg sqlc.FindBatchesByConsumedOutpointParams) ([][]byte, error) + + InsertBatchDependentVTXO(ctx context.Context, + arg sqlc.InsertBatchDependentVTXOParams) error + + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error + + ListBatchDependentVTXOs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchDependentVTXOsRow, error) + + InsertProvisionalConsumer(ctx context.Context, + arg sqlc.InsertProvisionalConsumerParams) error + + GetProvisionalConsumer(ctx context.Context, + arg sqlc.GetProvisionalConsumerParams) (int64, error) + + InsertConsumerCreatorLineage(ctx context.Context, + arg sqlc.InsertConsumerCreatorLineageParams) error + + ListConsumerCreatorLineage(ctx context.Context, + arg sqlc.ListConsumerCreatorLineageParams) ([][]byte, error) + + ListProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) ( + []sqlc.ListProvisionalConsumersForBatchRow, error) + + ListPendingConsumerBatchesByCreator(ctx context.Context, + creatorBatchTxid []byte) ([][]byte, error) + + DeleteProvisionalConsumer(ctx context.Context, + arg sqlc.DeleteProvisionalConsumerParams) (int64, error) + + RestoreForfeitedVTXOForConsumer(ctx context.Context, + arg sqlc.RestoreForfeitedVTXOForConsumerParams) (int64, error) + + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) error + + ListVTXOsForCanonicalityBackfill(ctx context.Context) ( + []sqlc.ListVTXOsForCanonicalityBackfillRow, error) +} + +// BatchedBatchCanonicalityStore combines the query surface with batched +// transaction execution. +type BatchedBatchCanonicalityStore interface { + BatchCanonicalityStore + BatchedTx[BatchCanonicalityStore] +} + +// BatchCanonicalityPersistenceStore persists the durable batch canonicality +// data model: per-batch canonicality records, the inputs each batch consumes, +// the VTXOs it anchors, and the reverse-dependency edges used to restore a +// provisionally consumed VTXO. It is behavior-free; interpretation lives in +// the batch canonicality manager. +type BatchCanonicalityPersistenceStore struct { + db BatchedBatchCanonicalityStore + clock clock.Clock +} + +// NewBatchCanonicalityPersistenceStore creates a batch canonicality store +// using the transaction executor pattern. +func NewBatchCanonicalityPersistenceStore(db BatchedBatchCanonicalityStore, + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + return &BatchCanonicalityPersistenceStore{ + db: db, + clock: clk, + } +} + +// RegisterBatch atomically persists a complete batch registration and its +// provisional-consumer edges. A repeated registration may add dependents or +// consumer edges, but it cannot change the batch output, expiry policy, or +// actual consumed-input set. Preserving that immutable evidence prevents a +// retry from deleting a watched input or resetting its durable conflict flags. +func (s *BatchCanonicalityPersistenceStore) RegisterBatch(ctx context.Context, + record *batchcanon.Record, + consumerEdges []batchcanon.ConsumerEdge) error { + + now := s.clock.Now().Unix() + txid := record.BatchTxID + + registrationConflict := false + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + row, err := q.GetBatchCanonicality(ctx, txid[:]) + switch { + case errors.Is(err, sql.ErrNoRows): + if err := replaceBatchRecord( + ctx, q, record, now, + ); err != nil { + return err + } + + case err != nil: + return err + + default: + existing, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + + // Upgrade-created placeholders intentionally carry no + // serialized transaction or input evidence and are + // never Ready. The first authenticated producer + // registration may complete such a row exactly once. + // Start a fresh generation from Unseen and retain the + // union of historical dependents; no age-derived + // confirmation assumption survives the completion + // boundary. + if len(existing.BatchTx) == 0 && + existing.RegistrationStage != + batchcanon.RegistrationComplete { + + completed := *record + completed.State = batchcanon.StateUnseen + completed.RegistrationStage = + batchcanon.RegistrationRegistering + completed.ReadyGeneration = fn.None[uint64]() + completed.ConfirmationHeight = fn.None[int32]() + completed.ConfirmationBlock = + fn.None[chainhash.Hash]() + completed.ObservationGeneration = + existing.ObservationGeneration + 1 + if completed.ObservationGeneration == 0 { + completed.ObservationGeneration = 1 + } + completed.Revision = existing.Revision + 1 + completed.DependentVTXOs = mergeOutpoints( + existing.DependentVTXOs, + record.DependentVTXOs, + ) + if err := replaceBatchRecord( + ctx, q, &completed, now, + ); err != nil { + return err + } + + break + } + + if err := registrationMatches( + existing, record, + ); err != nil { + + registrationConflict = true + params := + sqlc.QuarantineBatchCanonicalityParams{ + BatchTxid: txid[:], + UpdatedAt: now, + } + + return q.QuarantineBatchCanonicality( + ctx, params, + ) + } + + // Registration evidence is immutable, but dependents + // are a monotonic set: another locally-owned output can + // later be proven to descend from the same + // already-watched batch. + if err := insertDependentVTXOs( + ctx, q, txid, record.DependentVTXOs, + ); err != nil { + return err + } + } + + err = insertConsumerEdges(ctx, q, txid, consumerEdges, now) + if errors.Is(err, batchcanon.ErrRegistrationConflict) { + registrationConflict = true + + return q.QuarantineBatchCanonicality( + ctx, sqlc.QuarantineBatchCanonicalityParams{ + BatchTxid: txid[:], + UpdatedAt: now, + }, + ) + } + + return err + }) + if err != nil { + return err + } + if registrationConflict { + return batchcanon.ErrRegistrationConflict + } + + return nil +} + +// mergeOutpoints returns the stable set union of two outpoint slices. +func mergeOutpoints(a, b []wire.OutPoint) []wire.OutPoint { + seen := make(map[wire.OutPoint]struct{}, len(a)+len(b)) + merged := make([]wire.OutPoint, 0, len(a)+len(b)) + for _, outpoints := range [][]wire.OutPoint{a, b} { + for _, outpoint := range outpoints { + if _, ok := seen[outpoint]; ok { + continue + } + seen[outpoint] = struct{}{} + merged = append(merged, outpoint) + } + } + + return merged +} + +// BeginReconcile closes admission and advances the observation generation in +// one transaction before the caller arms any chain watch. +func (s *BatchCanonicalityPersistenceStore) BeginReconcile(ctx context.Context, + txid chainhash.Hash) (*batchcanon.Record, error) { + + var record *batchcanon.Record + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + row, err := q.BeginBatchCanonicalityReconcile( + ctx, sqlc.BeginBatchCanonicalityReconcileParams{ + BatchTxid: txid[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return batchcanon.ErrBatchNotFound + } + if err != nil { + return err + } + + record, err = s.hydrateRecord(ctx, q, row) + + return err + }) + + return record, err +} + +// MarkReady opens admission for a generation-consistent snapshot. +func (s *BatchCanonicalityPersistenceStore) MarkReady(ctx context.Context, + txid chainhash.Hash, generation uint64) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.MarkBatchCanonicalityReady( + ctx, sqlc.MarkBatchCanonicalityReadyParams{ + BatchTxid: txid[:], + ReadyGeneration: sql.NullInt64{ + Int64: int64(generation), + Valid: true, + }, + UpdatedAt: s.clock.Now().Unix(), + }, + ) + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("stale batch readiness generation %d", + generation) + } + + return nil + }) +} + +// ApplyObservation atomically writes one full chain-observation snapshot. It +// first verifies that the snapshot names exactly the immutable input set, then +// updates every input and the generation-guarded batch row in one transaction. +func (s *BatchCanonicalityPersistenceStore) ApplyObservation( + ctx context.Context, snapshot *batchcanon.ObservationSnapshot) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + persistedInputs, err := q.ListBatchConsumedInputs( + ctx, snapshot.BatchTxID[:], + ) + if err != nil { + return err + } + if len(persistedInputs) != len(snapshot.Inputs) { + return fmt.Errorf("batch observation input count " + + "changed") + } + + expected := make( + map[wire.OutPoint]struct{}, len(persistedInputs), + ) + for _, input := range persistedInputs { + hash, err := chainhash.NewHash(input.InputHash) + if err != nil { + return err + } + expected[wire.OutPoint{ + Hash: *hash, + Index: uint32(input.InputIndex), + }] = struct{}{} + } + + seen := make(map[wire.OutPoint]struct{}, len(snapshot.Inputs)) + for _, input := range snapshot.Inputs { + if _, ok := expected[input.Outpoint]; !ok { + return fmt.Errorf("batch observation contains "+ + "unknown input %s", input.Outpoint) + } + if _, duplicate := seen[input.Outpoint]; duplicate { + return fmt.Errorf("batch observation "+ + "duplicates input %s", input.Outpoint) + } + seen[input.Outpoint] = struct{}{} + + rows, err := q.RecordBatchInputConflict( + ctx, sqlc.RecordBatchInputConflictParams{ + BatchTxid: snapshot.BatchTxID[:], + InputHash: input.Outpoint.Hash[:], + InputIndex: int32( + input.Outpoint.Index, + ), + Conflicting: boolToInt32( + input.Conflicting, + ), + ConflictFinal: boolToInt32( + input.ConflictFinal, + ), + }, + ) + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("batch observation input "+ + "%s missing", input.Outpoint) + } + } + + readyGeneration := sql.NullInt64{} + if snapshot.Ready { + readyGeneration = sql.NullInt64{ + Int64: int64(snapshot.Generation), + Valid: true, + } + } + rows, err := q.ApplyBatchCanonicalityObservation( + ctx, sqlc.ApplyBatchCanonicalityObservationParams{ + BatchTxid: snapshot.BatchTxID[:], + ObservationGeneration: int64( + snapshot.Generation, + ), + State: int32(snapshot.State), + ConfirmationHeight: optionToNullInt32( + snapshot.ConfirmationHeight, + ), + ConfirmationBlockHash: optionHashToBytes( + snapshot.ConfirmationBlock, + ), + ReadyGeneration: readyGeneration, + UpdatedAt: s.clock.Now().Unix(), + }, + ) + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("stale or quarantined batch "+ + "observation generation %d", + snapshot.Generation) + } + + return nil + }) +} + +// UpsertBatch inserts or replaces the canonicality record for a batch, +// including its consumed inputs and dependent VTXOs. The input and dependent +// sets are replaced wholesale (delete-then-insert) so the persisted edges +// always match the supplied record. +func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, + record *batchcanon.Record) error { + + now := s.clock.Now().Unix() + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return replaceBatchRecord(ctx, q, record, now) + }) +} + +// replaceBatchRecord writes a whole record inside the caller's transaction. +// It is used for explicit migration/backfill rewrites and for the first insert +// of RegisterBatch; ordinary repeat registration never calls it. +func replaceBatchRecord(ctx context.Context, q BatchCanonicalityStore, + record *batchcanon.Record, now int64) error { + + txid := record.BatchTxID + observationGeneration := record.ObservationGeneration + if observationGeneration == 0 { + observationGeneration = 1 + } + err := q.UpsertBatchCanonicality( + ctx, sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + BatchTx: record.BatchTx, + BatchOutputIndex: batchOutputIndexToNullInt32( + record.BatchTx, record.BatchOutputIndex, + ), + State: int32(record.State), + RegistrationStage: int32(record.RegistrationStage), + ObservationGeneration: int64(observationGeneration), + ReadyGeneration: optionUint64ToNullInt64( + record.ReadyGeneration, + ), + Revision: int64(record.Revision), + ConfirmationHeight: optionToNullInt32( + record.ConfirmationHeight, + ), + ConfirmationBlockHash: optionHashToBytes( + record.ConfirmationBlock, + ), + CsvExpiryDelta: record.CSVExpiryDelta, + PolicyState: int32(record.PolicyState), + ConfirmationPkScript: record.ConfirmationPkScript, + CreatedAt: now, + UpdatedAt: now, + }, + ) + if err != nil { + return err + } + + if err := q.DeleteBatchConsumedInputs(ctx, txid[:]); err != nil { + return err + } + for _, in := range record.ConsumedInputs { + err := q.InsertBatchConsumedInput( + ctx, sqlc.InsertBatchConsumedInputParams{ + BatchTxid: txid[:], + InputHash: in.Outpoint.Hash[:], + InputIndex: int32(in.Outpoint.Index), + InputValue: in.Value, + InputPkScript: in.PkScript, + }, + ) + if err != nil { + return err + } + } + + if err := q.DeleteBatchDependentVTXOs(ctx, txid[:]); err != nil { + return err + } + + return insertDependentVTXOs(ctx, q, txid, record.DependentVTXOs) +} + +// registrationMatches verifies that a repeated registration carries exactly +// the immutable evidence already stored for the batch. +func registrationMatches(existing, next *batchcanon.Record) error { + switch { + case existing.BatchTxID != next.BatchTxID: + return fmt.Errorf("%w: txid changed", + batchcanon.ErrRegistrationConflict) + + case !bytes.Equal(existing.BatchTx, next.BatchTx): + return fmt.Errorf("%w: serialized transaction changed", + batchcanon.ErrRegistrationConflict) + + case existing.BatchOutputIndex != next.BatchOutputIndex: + return fmt.Errorf("%w: batch output index changed", + batchcanon.ErrRegistrationConflict) + + case !bytes.Equal( + existing.ConfirmationPkScript, next.ConfirmationPkScript, + ): + return fmt.Errorf("%w: confirmation script changed", + batchcanon.ErrRegistrationConflict) + + case existing.CSVExpiryDelta != next.CSVExpiryDelta: + return fmt.Errorf("%w: csv expiry changed", + batchcanon.ErrRegistrationConflict) + + case existing.PolicyState != next.PolicyState: + return fmt.Errorf("%w: policy state changed", + batchcanon.ErrRegistrationConflict) + } + + if len(existing.ConsumedInputs) != len(next.ConsumedInputs) { + return fmt.Errorf("%w: consumed-input count changed", + batchcanon.ErrRegistrationConflict) + } + + type inputEvidence struct { + value int64 + pkScript []byte + } + expected := make( + map[wire.OutPoint]inputEvidence, len(existing.ConsumedInputs), + ) + for _, in := range existing.ConsumedInputs { + expected[in.Outpoint] = inputEvidence{ + value: in.Value, + pkScript: in.PkScript, + } + } + for _, in := range next.ConsumedInputs { + evidence, ok := expected[in.Outpoint] + if !ok || evidence.value != in.Value || + !bytes.Equal(evidence.pkScript, in.PkScript) { + return fmt.Errorf("%w: consumed input %s changed", + batchcanon.ErrRegistrationConflict, in.Outpoint) + } + } + + return nil +} + +// insertConsumerEdges adds immutable logical-consumer evidence inside the same +// transaction as registration. A repeat must match the expected business +// revision and complete creator lineage exactly. +func insertConsumerEdges(ctx context.Context, q BatchCanonicalityStore, + consumerBatch chainhash.Hash, edges []batchcanon.ConsumerEdge, + now int64) error { + + seenEdges := make(map[wire.OutPoint]struct{}, len(edges)) + for _, edge := range edges { + if edge.ConsumerBatch != (chainhash.Hash{}) && + edge.ConsumerBatch != consumerBatch { + return fmt.Errorf("%w: consumer batch changed", + batchcanon.ErrRegistrationConflict) + } + if edge.ExpectedRevision == 0 || len(edge.CreatorLineage) == 0 { + return fmt.Errorf("%w: incomplete consumer edge %s", + batchcanon.ErrRegistrationConflict, + edge.ConsumedVTXO) + } + if _, duplicate := seenEdges[edge.ConsumedVTXO]; duplicate { + return fmt.Errorf("%w: duplicate consumer edge %s", + batchcanon.ErrRegistrationConflict, + edge.ConsumedVTXO) + } + seenEdges[edge.ConsumedVTXO] = struct{}{} + + consumedHash := edge.ConsumedVTXO.Hash[:] + key := sqlc.GetProvisionalConsumerParams{ + ConsumedVtxoHash: consumedHash, + ConsumedVtxoIndex: int32(edge.ConsumedVTXO.Index), + ConsumerBatchTxid: consumerBatch[:], + } + existingRevision, err := q.GetProvisionalConsumer(ctx, key) + switch { + case errors.Is(err, sql.ErrNoRows): + err = q.InsertProvisionalConsumer( + ctx, sqlc.InsertProvisionalConsumerParams{ + ConsumedVtxoHash: consumedHash, + ConsumedVtxoIndex: int32( + edge.ConsumedVTXO.Index, + ), + ConsumerBatchTxid: consumerBatch[:], + ExpectedVtxoRevision: int64( + edge.ExpectedRevision, + ), + CreatedAt: now, + }, + ) + if err != nil { + return err + } + if err := insertCreatorLineage( + ctx, q, consumerBatch, edge, + ); err != nil { + return err + } + + case err != nil: + return err + + case uint64(existingRevision) != edge.ExpectedRevision: + return fmt.Errorf("%w: consumer edge %s "+ + "revision changed", + batchcanon.ErrRegistrationConflict, + edge.ConsumedVTXO) + + default: + if err := creatorLineageMatches( + ctx, q, consumerBatch, edge, + ); err != nil { + return err + } + } + } + + return nil +} + +// insertCreatorLineage persists the normalized complete lineage for a new +// edge, rejecting duplicates before SQL's idempotent constraint can hide them. +func insertCreatorLineage(ctx context.Context, q BatchCanonicalityStore, + consumerBatch chainhash.Hash, edge batchcanon.ConsumerEdge) error { + + seen := make(map[chainhash.Hash]struct{}, len(edge.CreatorLineage)) + for _, creator := range edge.CreatorLineage { + if creator == (chainhash.Hash{}) { + return fmt.Errorf("%w: zero creator lineage txid", + batchcanon.ErrRegistrationConflict) + } + if _, duplicate := seen[creator]; duplicate { + return fmt.Errorf("%w: duplicate creator "+ + "lineage txid %s", + batchcanon.ErrRegistrationConflict, creator) + } + seen[creator] = struct{}{} + if err := q.InsertConsumerCreatorLineage( + ctx, sqlc.InsertConsumerCreatorLineageParams{ + ConsumedVtxoHash: edge.ConsumedVTXO.Hash[:], + ConsumedVtxoIndex: int32( + edge.ConsumedVTXO.Index, + ), + ConsumerBatchTxid: consumerBatch[:], + CreatorBatchTxid: creator[:], + }, + ); err != nil { + return err + } + } + + return nil +} + +// creatorLineageMatches checks repeat registration against the exact durable +// set; neither omission nor additive ancestry is allowed. +func creatorLineageMatches(ctx context.Context, q BatchCanonicalityStore, + consumerBatch chainhash.Hash, edge batchcanon.ConsumerEdge) error { + + rows, err := q.ListConsumerCreatorLineage( + ctx, sqlc.ListConsumerCreatorLineageParams{ + ConsumedVtxoHash: edge.ConsumedVTXO.Hash[:], + ConsumedVtxoIndex: int32(edge.ConsumedVTXO.Index), + ConsumerBatchTxid: consumerBatch[:], + }, + ) + if err != nil { + return err + } + if len(rows) != len(edge.CreatorLineage) { + return fmt.Errorf("%w: consumer edge %s creator "+ + "lineage changed", batchcanon.ErrRegistrationConflict, + edge.ConsumedVTXO) + } + + want := make(map[chainhash.Hash]struct{}, len(edge.CreatorLineage)) + for _, creator := range edge.CreatorLineage { + want[creator] = struct{}{} + } + for _, raw := range rows { + creator, err := chainhash.NewHash(raw) + if err != nil { + return err + } + if _, ok := want[*creator]; !ok { + return fmt.Errorf("%w: consumer edge %s creator "+ + "lineage changed", + batchcanon.ErrRegistrationConflict, + edge.ConsumedVTXO) + } + } + + return nil +} + +// insertDependentVTXOs links each dependent VTXO outpoint to a batch txid. +// Shared by UpsertBatch and BackfillFromVTXOs so neither nests the insert +// loop deeply enough to overflow the line-length budget. +func insertDependentVTXOs(ctx context.Context, q BatchCanonicalityStore, + txid chainhash.Hash, deps []wire.OutPoint) error { + + for _, dep := range deps { + err := q.InsertBatchDependentVTXO( + ctx, sqlc.InsertBatchDependentVTXOParams{ + BatchTxid: txid[:], + VtxoOutpointHash: dep.Hash[:], + VtxoOutpointIndex: int32(dep.Index), + }, + ) + if err != nil { + return err + } + } + + return nil +} + +// GetBatch returns the canonicality record for a batch txid, hydrating its +// consumed inputs and dependent VTXOs. It returns batchcanon.ErrBatchNotFound +// when no record exists. +func (s *BatchCanonicalityPersistenceStore) GetBatch(ctx context.Context, + txid chainhash.Hash) (*batchcanon.Record, error) { + + var record *batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + row, err := q.GetBatchCanonicality(ctx, txid[:]) + if errors.Is(err, sql.ErrNoRows) { + return batchcanon.ErrBatchNotFound + } + if err != nil { + return err + } + + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + record = rec + + return nil + }) + + return record, err +} + +// ListBatchesByState returns every batch currently in the given state, +// hydrating each record's consumed inputs and dependent VTXOs. +func (s *BatchCanonicalityPersistenceStore) ListBatchesByState( + ctx context.Context, state batchcanon.State) ([]*batchcanon.Record, + error) { + + var records []*batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListBatchCanonicalityByState(ctx, int32(state)) + if err != nil { + return err + } + + records = make([]*batchcanon.Record, 0, len(rows)) + for _, row := range rows { + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + records = append(records, rec) + } + + return nil + }) + + return records, err +} + +// UpdateBatchState transitions a batch to a new canonicality state. +func (s *BatchCanonicalityPersistenceStore) UpdateBatchState( + ctx context.Context, txid chainhash.Hash, + state batchcanon.State) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.UpdateBatchCanonicalityState( + ctx, sqlc.UpdateBatchCanonicalityStateParams{ + BatchTxid: txid[:], + State: int32(state), + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// RecordConfirmation records the best-chain height and block hash at which +// the batch tx is confirmed. +func (s *BatchCanonicalityPersistenceStore) RecordConfirmation( + ctx context.Context, txid chainhash.Hash, height int32, + block chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.RecordBatchConfirmation( + ctx, sqlc.RecordBatchConfirmationParams{ + BatchTxid: txid[:], + ConfirmationHeight: sql.NullInt32{ + Int32: height, + Valid: true, + }, + ConfirmationBlockHash: block[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// ClearConfirmation clears the confirmation observation for a batch. +func (s *BatchCanonicalityPersistenceStore) ClearConfirmation( + ctx context.Context, txid chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.ClearBatchConfirmation( + ctx, sqlc.ClearBatchConfirmationParams{ + BatchTxid: txid[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// RecordInputConflict persists the observed conflict status of one consumed +// input, so restart reconciliation can rebuild the per-input conflict view. +func (s *BatchCanonicalityPersistenceStore) RecordInputConflict( + ctx context.Context, batchTxid chainhash.Hash, outpoint wire.OutPoint, + conflicting, conflictFinal bool) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.RecordBatchInputConflict( + ctx, sqlc.RecordBatchInputConflictParams{ + BatchTxid: batchTxid[:], + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + Conflicting: boolToInt32(conflicting), + ConflictFinal: boolToInt32(conflictFinal), + }, + ) + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("batch input %s not found", outpoint) + } + + return nil + }) +} + +// FindBatchesConsumingOutpoint returns the txids of every recorded batch that +// consumes the given outpoint. +func (s *BatchCanonicalityPersistenceStore) FindBatchesConsumingOutpoint( + ctx context.Context, outpoint wire.OutPoint) ([]chainhash.Hash, error) { + + var txids []chainhash.Hash + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.FindBatchesByConsumedOutpoint( + ctx, sqlc.FindBatchesByConsumedOutpointParams{ + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + }, + ) + if err != nil { + return err + } + + txids = make([]chainhash.Hash, 0, len(rows)) + for _, raw := range rows { + hash, err := chainhash.NewHash(raw) + if err != nil { + return err + } + txids = append(txids, *hash) + } + + return nil + }) + + return txids, err +} + +// ListPendingConsumerEdges returns the complete durable restore evidence owned +// by one consumer batch. +func (s *BatchCanonicalityPersistenceStore) ListPendingConsumerEdges( + ctx context.Context, consumerBatch chainhash.Hash) ( + []batchcanon.ConsumerEdge, error) { + + var edges []batchcanon.ConsumerEdge + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + if err != nil { + return err + } + + edges = make([]batchcanon.ConsumerEdge, 0, len(rows)) + for _, row := range rows { + hash, err := chainhash.NewHash(row.ConsumedVtxoHash) + if err != nil { + return err + } + outpoint := wire.OutPoint{ + Hash: *hash, + Index: uint32(row.ConsumedVtxoIndex), + } + consumedIndex := row.ConsumedVtxoIndex + lineageRows, err := q.ListConsumerCreatorLineage( + ctx, sqlc.ListConsumerCreatorLineageParams{ + ConsumedVtxoHash: row.ConsumedVtxoHash, + ConsumedVtxoIndex: consumedIndex, + ConsumerBatchTxid: consumerBatch[:], + }, + ) + if err != nil { + return err + } + lineage := make([]chainhash.Hash, 0, len(lineageRows)) + for _, raw := range lineageRows { + ancestor, err := chainhash.NewHash(raw) + if err != nil { + return err + } + lineage = append(lineage, *ancestor) + } + edges = append(edges, batchcanon.ConsumerEdge{ + ConsumedVTXO: outpoint, + ConsumerBatch: consumerBatch, + ExpectedRevision: uint64( + row.ExpectedVtxoRevision, + ), + CreatorLineage: lineage, + }) + } + + return nil + }) + + return edges, err +} + +// ListPendingConsumerBatchesByCreator returns the distinct consumer batches +// whose pending restore evidence names creatorBatch in its complete lineage. +func (s *BatchCanonicalityPersistenceStore) ListPendingConsumerBatchesByCreator( + ctx context.Context, creatorBatch chainhash.Hash) ([]chainhash.Hash, + error) { + + var consumers []chainhash.Hash + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListPendingConsumerBatchesByCreator( + ctx, creatorBatch[:], + ) + if err != nil { + return err + } + + consumers = make([]chainhash.Hash, 0, len(rows)) + for _, raw := range rows { + consumer, err := chainhash.NewHash(raw) + if err != nil { + return err + } + consumers = append(consumers, *consumer) + } + + return nil + }) + + return consumers, err +} + +// ResolveConsumerEdge completes an invalid candidate without restoring, or +// atomically performs the exact ForfeitedBy CAS and edge completion. +func (s *BatchCanonicalityPersistenceStore) ResolveConsumerEdge( + ctx context.Context, edge batchcanon.ConsumerEdge, restore bool) ( + batchcanon.ConsumerEdgeResolution, error) { + + resolution := batchcanon.ConsumerEdgeDeferred + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + if restore { + consumerBatch := edge.ConsumerBatch[:] + rows, err := q.RestoreForfeitedVTXOForConsumer( + ctx, sqlc.RestoreForfeitedVTXOForConsumerParams{ + OutpointHash: edge.ConsumedVTXO.Hash[:], + OutpointIndex: int32( + edge.ConsumedVTXO.Index, + ), + BusinessRevision: int64( + edge.ExpectedRevision, + ), + ForfeitConsumerTxid: consumerBatch, + LastUpdateTime: s.clock. + Now(). + Unix(), + }, + ) + if err != nil { + return err + } + if rows == 0 { + return nil + } + if rows != 1 { + return fmt.Errorf("restore CAS changed "+ + "%d VTXOs", rows) + } + } + + rows, err := q.DeleteProvisionalConsumer( + ctx, sqlc.DeleteProvisionalConsumerParams{ + ConsumedVtxoHash: edge.ConsumedVTXO.Hash[:], + ConsumedVtxoIndex: int32( + edge.ConsumedVTXO.Index, + ), + ConsumerBatchTxid: edge.ConsumerBatch[:], + ExpectedVtxoRevision: int64( + edge.ExpectedRevision, + ), + }, + ) + if err != nil { + return err + } + if rows == 0 { + if restore { + return fmt.Errorf("restored VTXO has no " + + "exact consumer edge") + } + + return nil + } + if rows != 1 { + return fmt.Errorf("completed %d consumer edges", rows) + } + + if restore { + resolution = batchcanon.ConsumerEdgeRestored + } else { + resolution = batchcanon.ConsumerEdgeCompleted + } + + return nil + }) + + return resolution, err +} + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge for +// the given consumer batch. +func (s *BatchCanonicalityPersistenceStore) DeleteProvisionalConsumersForBatch( + ctx context.Context, consumerBatch chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + }) +} + +// backfillGroup accumulates the per-batch data derived from VTXO rows during +// backfill: the batch-level expiry and creation height (shared by every VTXO +// in the batch) plus the set of dependent VTXO outpoints. +type backfillGroup struct { + batchExpiry int32 + createdHeight int32 + dependents []wire.OutPoint +} + +// BackfillFromVTXOs derives a fail-closed placeholder for every distinct batch +// (commitment) txid present in the VTXO store that does not already have a +// record, so an upgrading node knows which historical evidence is missing. It +// is idempotent: batches that already have a record are left untouched, so a +// re-run never clobbers state the manager has since advanced. It returns the +// number of placeholder records created. +// +// The provisional/finalized State is an informational migration hint derived +// from stored height and the supplied policy depth. It never opens admission: +// the placeholder remains incomplete and not Ready until authenticated +// serialized transaction, output, and full prevout evidence replaces it in a +// fresh generation. The CSV-relative expiry delta is recovered as +// batch_expiry - created_height so the derived effective expiry matches the +// original absolute batch_expiry while remaining reorg-recomputable. Batches +// whose VTXOs carry no positive creation height are skipped: without a +// confirmation observation the manager will create them on first sight. +func (s *BatchCanonicalityPersistenceStore) BackfillFromVTXOs( + ctx context.Context, bestHeight int32, finalityDepth uint32) (int, + error) { + + now := s.clock.Now().Unix() + created := 0 + + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListVTXOsForCanonicalityBackfill(ctx) + if err != nil { + return err + } + + // Group the VTXO rows by commitment (batch) txid. + groups := make(map[chainhash.Hash]*backfillGroup) + for _, row := range rows { + txid, err := chainhash.NewHash(row.CommitmentTxid) + if err != nil { + return err + } + vtxoHash, err := chainhash.NewHash(row.OutpointHash) + if err != nil { + return err + } + + g, ok := groups[*txid] + if !ok { + g = &backfillGroup{ + batchExpiry: row.BatchExpiry, + createdHeight: row.CreatedHeight, + } + groups[*txid] = g + } + g.dependents = append(g.dependents, wire.OutPoint{ + Hash: *vtxoHash, + Index: uint32(row.OutpointIndex), + }) + } + + for txid, g := range groups { + // Skip batches with no positive confirmation height: + // the manager will create them once it observes a + // confirmation. + if g.createdHeight <= 0 { + continue + } + + // Skip batches that already have a record so a re-run + // never overwrites advanced state. + _, err := q.GetBatchCanonicality(ctx, txid[:]) + if err == nil { + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + csvDelta := g.batchExpiry - g.createdHeight + if csvDelta < 0 { + csvDelta = 0 + } + + state := batchcanon.StateProvisional + depth := bestHeight - g.createdHeight + 1 + if depth >= int32(finalityDepth) { + state = batchcanon.StateFinalized + } + + registrationStage := int32( + batchcanon.RegistrationReconciling, + ) + params := sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + State: int32(state), + ObservationGeneration: 1, + ReadyGeneration: sql.NullInt64{}, + Revision: 0, + ConfirmationHeight: sql.NullInt32{ + Int32: g.createdHeight, + Valid: true, + }, + // The confirming block hash is not + // recorded on VTXO rows; it is an + // observation attribute the manager + // fills on its next confirmation + // sighting. + ConfirmationBlockHash: nil, + CsvExpiryDelta: csvDelta, + PolicyState: int32( + batchcanon.PolicyStateDefault, + ), + CreatedAt: now, + UpdatedAt: now, + } + params.RegistrationStage = registrationStage + err = q.UpsertBatchCanonicality(ctx, params) + if err != nil { + return err + } + + err = insertDependentVTXOs(ctx, q, txid, g.dependents) + if err != nil { + return err + } + + created++ + } + + return nil + }) + + return created, err +} + +// hydrateRecord builds a batchcanon.Record from a canonicality row, loading +// its consumed inputs and dependent VTXOs through the same query handle (and +// therefore the same transaction). +func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, + q BatchCanonicalityStore, row sqlc.BatchCanonicality) ( + *batchcanon.Record, error) { + + txid, err := chainhash.NewHash(row.BatchTxid) + if err != nil { + return nil, err + } + + confBlock, err := bytesToOptionHash(row.ConfirmationBlockHash) + if err != nil { + return nil, err + } + + inputRows, err := q.ListBatchConsumedInputs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + inputs := make([]batchcanon.ConsumedInput, 0, len(inputRows)) + for _, in := range inputRows { + hash, err := chainhash.NewHash(in.InputHash) + if err != nil { + return nil, err + } + inputs = append(inputs, batchcanon.ConsumedInput{ + Outpoint: wire.OutPoint{ + Hash: *hash, + Index: uint32(in.InputIndex), + }, + Value: in.InputValue, + PkScript: in.InputPkScript, + Conflicting: in.Conflicting != 0, + ConflictFinal: in.ConflictFinal != 0, + }) + } + + depRows, err := q.ListBatchDependentVTXOs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + deps := make([]wire.OutPoint, 0, len(depRows)) + for _, dep := range depRows { + hash, err := chainhash.NewHash(dep.VtxoOutpointHash) + if err != nil { + return nil, err + } + deps = append(deps, wire.OutPoint{ + Hash: *hash, + Index: uint32(dep.VtxoOutpointIndex), + }) + } + + return &batchcanon.Record{ + BatchTxID: *txid, + BatchTx: row.BatchTx, + BatchOutputIndex: uint32(row.BatchOutputIndex.Int32), + RegistrationStage: batchcanon.RegistrationStage( + row.RegistrationStage, + ), + ObservationGeneration: uint64(row.ObservationGeneration), + ReadyGeneration: nullInt64ToOptionUint64( + row.ReadyGeneration, + ), + Revision: uint64(row.Revision), + State: batchcanon.State(row.State), + ConfirmationHeight: nullInt32ToOption(row.ConfirmationHeight), + ConfirmationBlock: confBlock, + CSVExpiryDelta: row.CsvExpiryDelta, + PolicyState: batchcanon.PolicyState(row.PolicyState), + ConfirmationPkScript: row.ConfirmationPkScript, + ConsumedInputs: inputs, + DependentVTXOs: deps, + }, nil +} + +// batchOutputIndexToNullInt32 keeps legacy/backfilled records without a +// serialized transaction explicitly incomplete. Complete registrations bind +// an index, including zero, to their transaction bytes. +func batchOutputIndexToNullInt32(batchTx []byte, index uint32) sql.NullInt32 { + if len(batchTx) == 0 { + return sql.NullInt32{} + } + + return sql.NullInt32{ + Int32: int32(index), + Valid: true, + } +} + +// optionUint64ToNullInt64 maps a generation to its SQL representation. +func optionUint64ToNullInt64(o fn.Option[uint64]) sql.NullInt64 { + if o.IsNone() { + return sql.NullInt64{} + } + + return sql.NullInt64{ + Int64: int64(o.UnwrapOr(0)), + Valid: true, + } +} + +// nullInt64ToOptionUint64 maps a nullable generation back to an option. +func nullInt64ToOptionUint64(n sql.NullInt64) fn.Option[uint64] { + if !n.Valid { + return fn.None[uint64]() + } + + return fn.Some(uint64(n.Int64)) +} + +// boolToInt32 maps a Go bool to the 0/1 INTEGER encoding used for the +// consumed-input conflict flags. +func boolToInt32(b bool) int32 { + if b { + return 1 + } + + return 0 +} + +// optionToNullInt32 maps an optional int32 to a sql.NullInt32. +func optionToNullInt32(o fn.Option[int32]) sql.NullInt32 { + if o.IsNone() { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: o.UnwrapOr(0), Valid: true} +} + +// nullInt32ToOption maps a sql.NullInt32 back to an optional int32. +func nullInt32ToOption(n sql.NullInt32) fn.Option[int32] { + if !n.Valid { + return fn.None[int32]() + } + + return fn.Some(n.Int32) +} + +// optionHashToBytes maps an optional hash to its raw bytes, or nil when None. +func optionHashToBytes(o fn.Option[chainhash.Hash]) []byte { + if o.IsNone() { + return nil + } + + h := o.UnwrapOr(chainhash.Hash{}) + + return h[:] +} + +// bytesToOptionHash maps a (possibly nil) raw hash to an optional hash. A nil +// or empty slice yields None; any other length is validated to 32 bytes. +func bytesToOptionHash(raw []byte) (fn.Option[chainhash.Hash], error) { + if len(raw) == 0 { + return fn.None[chainhash.Hash](), nil + } + + hash, err := chainhash.NewHash(raw) + if err != nil { + return fn.None[chainhash.Hash](), err + } + + return fn.Some(*hash), nil +} + +// Compile-time check that the persistence store satisfies the domain Store +// interface. +var _ batchcanon.Store = (*BatchCanonicalityPersistenceStore)(nil) diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go new file mode 100644 index 000000000..da750990c --- /dev/null +++ b/db/batch_canonicality_store_test.go @@ -0,0 +1,1383 @@ +package db + +import ( + "bytes" + "database/sql" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db/sqlc" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// newBatchCanonicalityStoreForTest creates a batch canonicality store backed +// by a fresh test database. +func newBatchCanonicalityStoreForTest( + t *testing.T) *BatchCanonicalityPersistenceStore { + + t.Helper() + + db := NewTestDB(t) + + canonDB := NewTransactionExecutor( + db.BaseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return db.WithTx(tx) + }, + btclog.Disabled, + ) + + return NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) +} + +// outpoint is a small test helper building a deterministic outpoint. +func outpoint(b byte, index uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: index} +} + +// consumedInput builds a batchcanon.ConsumedInput from an outpoint with a +// deterministic non-empty pkScript so persistence round-trips can assert the +// script is stored and reloaded alongside the outpoint. +func consumedInput(op wire.OutPoint) batchcanon.ConsumedInput { + return batchcanon.ConsumedInput{ + Outpoint: op, + Value: 1_000 + int64(op.Index), + PkScript: []byte{ + 0x51, + 0x20, + op.Hash[0], + }, + } +} + +func consumerEdge(op wire.OutPoint, revision uint64, + lineage ...chainhash.Hash) batchcanon.ConsumerEdge { + + return batchcanon.ConsumerEdge{ + ConsumedVTXO: op, + ExpectedRevision: revision, + CreatorLineage: lineage, + } +} + +// readyBatchRecord builds a complete generation-one record for restore tests. +func readyBatchRecord(txid chainhash.Hash, + state batchcanon.State) *batchcanon.Record { + + return &batchcanon.Record{ + BatchTxID: txid, + BatchTx: []byte{ + 0x00, + }, + ConfirmationPkScript: []byte{ + 0x51, + }, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(wire.OutPoint{Hash: txid}), + }, + RegistrationStage: batchcanon.RegistrationComplete, + ObservationGeneration: 1, + ReadyGeneration: fn.Some[uint64](1), + State: state, + } +} + +// consumerRestoreHarness owns stores and evidence for one forfeited VTXO. +type consumerRestoreHarness struct { + canon *BatchCanonicalityPersistenceStore + vtxos *VTXOPersistenceStore + db *BaseDB + edge batchcanon.ConsumerEdge + consumer chainhash.Hash + forfeitTxID chainhash.Hash + creatorBatch chainhash.Hash +} + +// newConsumerRestoreHarness creates one VTXO with an exact durable +// ForfeitedBy marker and a ready, usable creator lineage. The caller chooses +// when and how to register the consumer edge. +func newConsumerRestoreHarness(t *testing.T) *consumerRestoreHarness { + t.Helper() + + ctx := t.Context() + vtxos, rounds, baseDB := newVTXOStoreForTest(t) + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return baseDB.WithTx(tx) + }, + btclog.Disabled, + ) + canon := NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) + + roundID := testRoundIDDB("consumer-restore-round") + r := createTestRound(t, roundID) + sigState := &round.InputSigSentState{ + RoundID: r.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, rounds.CommitState(ctx, r, sigState)) + + desc := createTestVTXODescriptor(t, roundID, 7) + require.NoError(t, vtxos.SaveVTXO(ctx, desc)) + require.NoError( + t, + vtxos.MarkForfeiting( + ctx, desc.Outpoint, roundID.String(), nil, + ), + ) + + consumer := chainhash.Hash{0xb7} + forfeitTxID := chainhash.Hash{0xf7} + require.NoError( + t, vtxos.MarkForfeited( + ctx, desc.Outpoint, forfeitTxID, consumer, + ), + ) + + forfeited, err := vtxos.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, forfeited.Status) + require.Equal(t, uint64(2), forfeited.BusinessRevision) + require.Equal( + t, consumer, + forfeited.ForfeitConsumerBatch.UnwrapOr( + chainhash.Hash{}, + ), + ) + + creatorBatch := desc.CommitmentTxID + require.NoError( + t, + canon.RegisterBatch( + ctx, readyBatchRecord( + creatorBatch, batchcanon.StateProvisional, + ), + nil, + ), + ) + + return &consumerRestoreHarness{ + canon: canon, + vtxos: vtxos, + db: baseDB, + consumer: consumer, + forfeitTxID: forfeitTxID, + creatorBatch: creatorBatch, + edge: batchcanon.ConsumerEdge{ + ConsumedVTXO: desc.Outpoint, + ConsumerBatch: consumer, + ExpectedRevision: forfeited.BusinessRevision, + CreatorLineage: []chainhash.Hash{ + creatorBatch, + }, + }, + } +} + +// registerConsumer registers the harness consumer batch and exact edge. +func (h *consumerRestoreHarness) registerConsumer(t *testing.T, + state batchcanon.State) { + + t.Helper() + + require.NoError( + t, + h.canon.RegisterBatch( + t.Context(), + readyBatchRecord(h.edge.ConsumerBatch, state), + []batchcanon.ConsumerEdge{h.edge}, + ), + ) +} + +// TestListPendingConsumerBatchesByCreator proves a creator-state change can +// target exactly the durable terminal-consumer checkpoints it may unblock. +func TestListPendingConsumerBatchesByCreator(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + consumers, err := h.canon.ListPendingConsumerBatchesByCreator( + t.Context(), h.creatorBatch, + ) + require.NoError(t, err) + require.Equal(t, []chainhash.Hash{h.consumer}, consumers) + + consumers, err = h.canon.ListPendingConsumerBatchesByCreator( + t.Context(), chainhash.Hash{0xee}, + ) + require.NoError(t, err) + require.Empty(t, consumers) +} + +// TestBatchCanonicalityUpsertRoundTrip verifies a record survives an upsert +// and read with all of its fields, consumed inputs, and dependent VTXOs. +func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xaa} + rec := &batchcanon.Record{ + BatchTxID: txid, + BatchTx: []byte{ + 0x02, + 0xaa, + 0xbb, + }, + BatchOutputIndex: 1, + State: batchcanon.StateProvisional, + ConfirmationHeight: fn.Some[int32](100), + ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), + CSVExpiryDelta: 144, + PolicyState: batchcanon.PolicyStateDefault, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + consumedInput(outpoint(0x02, 3)), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x03, 1), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, txid, got.BatchTxID) + require.Equal(t, rec.BatchTx, got.BatchTx) + require.Equal(t, rec.BatchOutputIndex, got.BatchOutputIndex) + require.Equal(t, batchcanon.StateProvisional, got.State) + require.Equal(t, int32(100), got.ConfirmationHeight.UnwrapOr(0)) + require.True(t, got.ConfirmationBlock.IsSome()) + require.Equal(t, int32(144), got.CSVExpiryDelta) + require.Equal(t, batchcanon.PolicyStateDefault, got.PolicyState) + require.ElementsMatch(t, rec.ConsumedInputs, got.ConsumedInputs) + require.ElementsMatch(t, rec.DependentVTXOs, got.DependentVTXOs) + + // Effective expiry derives from the stored confirmation. + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityRecordInputConflict verifies that per-input conflict +// flags round-trip through the store, so restart reconciliation can rebuild +// the per-input conflict view (darepo#454 reconciliation-ordering fix). +func TestBatchCanonicalityRecordInputConflict(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xac} + inA := outpoint(0x01, 0) + inB := outpoint(0x02, 1) + rec := &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateConflictProvisional, + CSVExpiryDelta: 144, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(inA), + consumedInput(inB), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + // Freshly inserted inputs carry no conflict. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + for _, in := range got.ConsumedInputs { + require.False(t, in.Conflicting) + require.False(t, in.ConflictFinal) + } + + // Mark input A conflicting (provisional), leave B untouched. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) + require.False(t, inputFlags(t, got, inB).Conflicting) + + // Promote input A to a finalized conflict; the flag persists. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, true), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.True(t, inputFlags(t, got, inA).ConflictFinal) + + // Clearing the conflict (spend reorged away) resets both flags. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, false, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.False(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) +} + +// inputFlags returns the consumed input matching op from a record, failing the +// test if it is absent. +func inputFlags(t *testing.T, rec *batchcanon.Record, + op wire.OutPoint) batchcanon.ConsumedInput { + + t.Helper() + for _, in := range rec.ConsumedInputs { + if in.Outpoint == op { + return in + } + } + t.Fatalf("consumed input %v not found", op) + + return batchcanon.ConsumedInput{} +} + +// TestBatchCanonicalityGetNotFound verifies the not-found sentinel. +func TestBatchCanonicalityGetNotFound(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + _, err := store.GetBatch(ctx, chainhash.Hash{0xff}) + require.ErrorIs(t, err, batchcanon.ErrBatchNotFound) +} + +// TestBatchCanonicalityUpsertReplacesEdges verifies a re-upsert replaces the +// consumed-input and dependent-VTXO sets rather than appending. +func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xa1} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 10, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x02, 0), + }, + }, + ), + ) + + // Re-upsert with a different edge set. + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 10, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x09, 2)), + }, + DependentVTXOs: nil, + }, + ), + ) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal( + t, []batchcanon.ConsumedInput{consumedInput(outpoint(0x09, 2))}, + got.ConsumedInputs, + ) + require.Empty(t, got.DependentVTXOs) +} + +// TestBatchCanonicalityReorgRecomputesExpiry verifies the reorg-aware expiry +// contract end to end through the store: a confirmation yields an effective +// expiry, a reorg (ClearConfirmation) erases it, and a reconfirmation at a new +// height yields a fresh effective expiry. Expiry is never frozen. +func TestBatchCanonicalityReorgRecomputesExpiry(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xc0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 144, + }, + ), + ) + + // Unconfirmed: no effective expiry. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Confirm at height 100. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 100, chainhash.Hash{0xc1}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: confirmation cleared, effective expiry erased. + require.NoError(t, store.ClearConfirmation(ctx, txid)) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.ConfirmationHeight.IsNone()) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: fresh effective expiry. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 105, chainhash.Hash{0xc2}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(249), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityStateNotTerminal verifies state can move freely in any +// direction (finalized -> reorged_out -> provisional), proving no state is +// persisted as an irreversible terminal verdict. +func TestBatchCanonicalityStateNotTerminal(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xd0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 10, + }, + ), + ) + + for _, want := range []batchcanon.State{ + batchcanon.StateReorgedOut, + batchcanon.StateConflictFinalized, + batchcanon.StateProvisional, + } { + require.NoError(t, store.UpdateBatchState(ctx, txid, want)) + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, want, got.State) + } +} + +// TestBatchCanonicalityListByState verifies state-filtered listing. +func TestBatchCanonicalityListByState(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe0}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe1}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe2}, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 1, + }, + ), + ) + + prov, err := store.ListBatchesByState(ctx, batchcanon.StateProvisional) + require.NoError(t, err) + require.Len(t, prov, 2) + + final, err := store.ListBatchesByState(ctx, batchcanon.StateFinalized) + require.NoError(t, err) + require.Len(t, final, 1) +} + +// TestBatchCanonicalityFindByConsumedOutpoint verifies input-conflict +// detection: two batches consuming the same outpoint are both found. +func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + shared := outpoint(0x55, 1) + batchA := chainhash.Hash{0xa0} + batchB := chainhash.Hash{0xb0} + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchA, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchB, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, + }, + ), + ) + + found, err := store.FindBatchesConsumingOutpoint(ctx, shared) + require.NoError(t, err) + require.ElementsMatch(t, []chainhash.Hash{batchA, batchB}, found) + + none, err := store.FindBatchesConsumingOutpoint(ctx, outpoint(0x99, 0)) + require.NoError(t, err) + require.Empty(t, none) +} + +// TestBatchCanonicalityProvisionalConsumerRestore verifies the reverse- +// dependency lifecycle: a provisionally consumed VTXO is listed for its +// consumer batch (so it can be restored if the batch is invalidated), survives +// a batch state change, and is removed on delete. +func TestBatchCanonicalityProvisionalConsumerRestore(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + consumerBatch := chainhash.Hash{0xf0} + consumed := outpoint(0x44, 2) + record := &batchcanon.Record{ + BatchTxID: consumerBatch, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + } + edge := consumerEdge( + consumed, 3, chainhash.Hash{0xe1}, chainhash.Hash{0xe2}, + ) + require.NoError( + t, + store.RegisterBatch( + ctx, record, []batchcanon.ConsumerEdge{edge}, + ), + ) + + // Listed for the consumer batch. + got, err := store.ListPendingConsumerEdges(ctx, consumerBatch) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, consumed, got[0].ConsumedVTXO) + require.Equal(t, uint64(3), got[0].ExpectedRevision) + require.ElementsMatch(t, edge.CreatorLineage, got[0].CreatorLineage) + + // Idempotent re-add. + require.NoError( + t, + store.RegisterBatch( + ctx, record, []batchcanon.ConsumerEdge{edge}, + ), + ) + got, err = store.ListPendingConsumerEdges(ctx, consumerBatch) + require.NoError(t, err) + require.Len(t, got, 1) + + // The edge survives the batch being marked reorged/invalidated — that + // is exactly when the restore caller needs to read it. + require.NoError( + t, store.UpdateBatchState( + ctx, consumerBatch, batchcanon.StateReorgedOut, + ), + ) + got, err = store.ListPendingConsumerEdges(ctx, consumerBatch) + require.NoError(t, err) + require.Len(t, got, 1) + + // Deleting clears the edges (e.g. once the consumption is canonical or + // fully reconciled). + require.NoError( + t, store.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch, + ), + ) + got, err = store.ListPendingConsumerEdges(ctx, consumerBatch) + require.NoError(t, err) + require.Empty(t, got) +} + +// TestBatchRegistrationIsAtomicAndImmutable proves repeat registration can +// only merge monotonic edges. It cannot replace actual inputs or clear a +// persisted conflict, and contradictory evidence durably quarantines the +// record. +func TestBatchRegistrationIsAtomicAndImmutable(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + txid := chainhash.Hash{0xf4} + input := consumedInput(outpoint(0x11, 0)) + firstDependent := outpoint(0x12, 0) + secondDependent := outpoint(0x13, 0) + firstConsumer := outpoint(0x14, 0) + secondConsumer := outpoint(0x15, 0) + creator := chainhash.Hash{0xe3} + + record := &batchcanon.Record{ + BatchTxID: txid, + BatchTx: []byte{ + 0x02, + 0xf4, + }, + BatchOutputIndex: 1, + RegistrationStage: batchcanon.RegistrationRegistering, + ObservationGeneration: 1, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 144, + ConfirmationPkScript: []byte{ + 0x51, + 0x20, + 0xf4, + }, + ConsumedInputs: []batchcanon.ConsumedInput{ + input, + }, + DependentVTXOs: []wire.OutPoint{ + firstDependent, + }, + } + require.NoError( + t, + store.RegisterBatch( + ctx, record, []batchcanon.ConsumerEdge{ + consumerEdge(firstConsumer, 2, creator), + }, + ), + ) + require.NoError( + t, store.RecordInputConflict( + ctx, txid, input.Outpoint, true, false, + ), + ) + + // An idempotent retry may add dependents and consumer edges, but it + // carries the same immutable output/input evidence. + retry := *record + retry.DependentVTXOs = []wire.OutPoint{secondDependent} + require.NoError( + t, + store.RegisterBatch( + ctx, &retry, []batchcanon.ConsumerEdge{ + consumerEdge(secondConsumer, 4, creator), + }, + ), + ) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, input.Outpoint).Conflicting) + require.ElementsMatch( + t, []wire.OutPoint{firstDependent, secondDependent}, + got.DependentVTXOs, + ) + consumers, err := store.ListPendingConsumerEdges(ctx, txid) + require.NoError(t, err) + require.ElementsMatch( + t, []wire.OutPoint{firstConsumer, secondConsumer}, + []wire.OutPoint{ + consumers[0].ConsumedVTXO, + consumers[1].ConsumedVTXO, + }, + ) + + // Removing/replacing an actual input is contradictory evidence. The + // original set and conflict survive, while readiness is quarantined. + conflict := retry + conflict.ConsumedInputs = []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x99, 0)), + } + err = store.RegisterBatch(ctx, &conflict, nil) + require.ErrorIs(t, err, batchcanon.ErrRegistrationConflict) + + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal( + t, batchcanon.RegistrationQuarantined, got.RegistrationStage, + ) + require.False(t, got.Ready()) + require.Equal(t, []batchcanon.ConsumedInput{ + { + Outpoint: input.Outpoint, + Value: input.Value, + PkScript: input.PkScript, + Conflicting: true, + }, + }, got.ConsumedInputs) +} + +// TestBatchReadinessGeneration proves restart closes admission before watch +// arming and that a stale Ready cannot reopen a newer generation. +func TestBatchReadinessGeneration(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + txid := chainhash.Hash{0xf5} + record := &batchcanon.Record{ + BatchTxID: txid, + BatchTx: []byte{ + 0x00, + }, + RegistrationStage: batchcanon.RegistrationRegistering, + ObservationGeneration: 1, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 144, + ConfirmationPkScript: []byte{ + 0x51, + 0x20, + 0xf5, + }, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x21, 0)), + }, + } + require.NoError(t, store.RegisterBatch(ctx, record, nil)) + require.NoError(t, store.MarkReady(ctx, txid, 1)) + + ready, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, ready.Ready()) + + reconciling, err := store.BeginReconcile(ctx, txid) + require.NoError(t, err) + require.Equal(t, uint64(2), reconciling.ObservationGeneration) + require.Equal( + t, batchcanon.RegistrationReconciling, + reconciling.RegistrationStage, + ) + require.False(t, reconciling.Ready()) + + require.Error(t, store.MarkReady(ctx, txid, 1)) + stillClosed, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.False(t, stillClosed.Ready()) + + require.NoError(t, store.MarkReady(ctx, txid, 2)) + ready, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, ready.Ready()) +} + +// TestBatchObservationIsAtomicAndGenerationGuarded proves an incomplete or +// stale snapshot cannot update even one input ahead of the batch state, while +// a complete current snapshot installs readiness with one revision change. +func TestBatchObservationIsAtomicAndGenerationGuarded(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + txid := chainhash.Hash{0xf6} + inputA := consumedInput(outpoint(0x31, 0)) + inputB := consumedInput(outpoint(0x32, 1)) + record := &batchcanon.Record{ + BatchTxID: txid, + BatchTx: []byte{ + 0x00, + }, + RegistrationStage: batchcanon.RegistrationReconciling, + ObservationGeneration: 2, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 144, + ConfirmationPkScript: []byte{ + 0x51, + }, + ConsumedInputs: []batchcanon.ConsumedInput{ + inputA, inputB, + }, + } + require.NoError(t, store.UpsertBatch(ctx, record)) + + snapshot := &batchcanon.ObservationSnapshot{ + BatchTxID: txid, + Generation: 1, + State: batchcanon.StateConflictProvisional, + ConfirmationHeight: fn.Some[int32](200), + ConfirmationBlock: fn.Some(chainhash.Hash{0x44}), + Inputs: []batchcanon.InputObservation{ + { + Outpoint: inputA.Outpoint, + Conflicting: true, + }, + { + Outpoint: inputB.Outpoint, + }, + }, + Ready: true, + } + + // Input rows are updated before the generation-guarded batch row inside + // the SQL transaction. A stale generation must roll all of them back. + require.Error(t, store.ApplyObservation(ctx, snapshot)) + unchanged, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, batchcanon.StateUnseen, unchanged.State) + require.False(t, unchanged.Ready()) + require.False(t, inputFlags(t, unchanged, inputA.Outpoint).Conflicting) + require.Equal(t, uint64(0), unchanged.Revision) + + // A caller cannot omit one immutable input from its supposedly complete + // snapshot either. + snapshot.Generation = 2 + snapshot.Inputs = snapshot.Inputs[:1] + require.Error(t, store.ApplyObservation(ctx, snapshot)) + unchanged, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.False(t, inputFlags(t, unchanged, inputA.Outpoint).Conflicting) + + snapshot.Inputs = append(snapshot.Inputs, batchcanon.InputObservation{ + Outpoint: inputB.Outpoint, + }) + require.NoError(t, store.ApplyObservation(ctx, snapshot)) + installed, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal( + t, batchcanon.StateConflictProvisional, installed.State, + ) + require.Equal(t, int32(200), installed.ConfirmationHeight.UnwrapOr(0)) + require.True(t, inputFlags(t, installed, inputA.Outpoint).Conflicting) + require.True(t, installed.Ready()) + require.Equal(t, uint64(1), installed.Revision) +} + +// TestResolveConsumerEdgeRestoresExactOwner proves the happy-path restore is +// atomic, increments the business revision, clears the ownership marker, and +// is idempotent after the exact edge is complete. +func TestResolveConsumerEdgeRestoresExactOwner(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeRestored, resolution) + + restored, err := h.vtxos.GetVTXO(t.Context(), h.edge.ConsumedVTXO) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusLive, restored.Status) + require.Equal(t, h.edge.ExpectedRevision+1, restored.BusinessRevision) + require.True(t, restored.ForfeitConsumerBatch.IsNone()) + + pending, err := h.canon.ListPendingConsumerEdges( + t.Context(), h.consumer, + ) + require.NoError(t, err) + require.Empty(t, pending) + + resolution, err = h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) +} + +// TestResolveConsumerEdgeRequiresTerminalConsumer proves the storage-layer +// transaction cannot restore before the consumer is durably ready and +// conflict-finalized, even if a caller invokes it out of order. +func TestResolveConsumerEdgeRequiresTerminalConsumer(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateProvisional) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + unchanged, err := h.vtxos.GetVTXO(t.Context(), h.edge.ConsumedVTXO) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, unchanged.Status) + require.Equal(t, h.edge.ExpectedRevision, unchanged.BusinessRevision) + + require.NoError( + t, + h.canon.UpdateBatchState( + t.Context(), h.consumer, + batchcanon.StateConflictFinalized, + ), + ) + resolution, err = h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeRestored, resolution) +} + +// TestResolveConsumerEdgeRejectsStaleOwnership exercises the business-state +// predicates that keep a stale or competing edge from reviving value. +func TestResolveConsumerEdgeRejectsStaleOwnership(t *testing.T) { + t.Parallel() + + t.Run("business revision", func(t *testing.T) { + h := newConsumerRestoreHarness(t) + h.edge.ExpectedRevision++ + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + unchanged, err := h.vtxos.GetVTXO( + t.Context(), h.edge.ConsumedVTXO, + ) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, unchanged.Status) + require.Equal( + t, h.edge.ExpectedRevision-1, + unchanged.BusinessRevision, + ) + }) + + t.Run("consumer marker", func(t *testing.T) { + h := newConsumerRestoreHarness(t) + h.edge.ConsumerBatch = chainhash.Hash{0xc7} + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + unchanged, err := h.vtxos.GetVTXO( + t.Context(), h.edge.ConsumedVTXO, + ) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, unchanged.Status) + require.Equal( + t, h.consumer, + unchanged.ForfeitConsumerBatch.UnwrapOr( + chainhash.Hash{}, + ), + ) + }) + + t.Run("completed spend", func(t *testing.T) { + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + require.NoError( + t, + h.vtxos.UpdateVTXOStatus( + t.Context(), h.edge.ConsumedVTXO, + vtxo.VTXOStatusSpent, + ), + ) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + spent, err := h.vtxos.GetVTXO( + t.Context(), h.edge.ConsumedVTXO, + ) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusSpent, spent.Status) + }) +} + +// TestResolveConsumerEdgeBlocksReservation proves a durable reservation owns +// the candidate until it is objectively released. +func TestResolveConsumerEdgeBlocksReservation(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + require.NoError( + t, + h.db.UpsertSpendingReservation( + t.Context(), sqlc.UpsertSpendingReservationParams{ + OutpointHash: h.edge.ConsumedVTXO.Hash[:], + OutpointIndex: int32(h.edge.ConsumedVTXO.Index), + OwnerKind: 1, + OwnerID: bytes.Repeat([]byte{0x77}, 32), + CreatedAt: 1, + }, + ), + ) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + require.NoError( + t, + h.db.DeleteSpendingReservation( + t.Context(), sqlc.DeleteSpendingReservationParams{ + OutpointHash: h.edge.ConsumedVTXO.Hash[:], + OutpointIndex: int32(h.edge.ConsumedVTXO.Index), + }, + ), + ) + resolution, err = h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeRestored, resolution) +} + +// TestResolveConsumerEdgeBlocksOtherViableConsumer proves a second +// provisional/final owner prevents restore, while an objectively invalidated +// competing edge no longer claims the value. +func TestResolveConsumerEdgeBlocksOtherViableConsumer(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + other := h.edge + other.ConsumerBatch = chainhash.Hash{0xc7} + require.NoError( + t, + h.canon.RegisterBatch( + t.Context(), readyBatchRecord( + other.ConsumerBatch, + batchcanon.StateProvisional, + ), + []batchcanon.ConsumerEdge{other}, + ), + ) + + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + require.NoError( + t, + h.canon.UpdateBatchState( + t.Context(), other.ConsumerBatch, + batchcanon.StateConflictFinalized, + ), + ) + resolution, err = h.canon.ResolveConsumerEdge( + t.Context(), h.edge, true, + ) + require.NoError(t, err) + require.Equal(t, batchcanon.ConsumerEdgeRestored, resolution) + + otherPending, err := h.canon.ListPendingConsumerEdges( + t.Context(), other.ConsumerBatch, + ) + require.NoError(t, err) + require.Len(t, otherPending, 1) +} + +// TestResolveConsumerEdgeRollsBackWithoutExactEdge proves the VTXO update and +// edge completion are one transaction. If exact edge deletion fails after a +// successful CAS, the VTXO marker remains forfeited. +func TestResolveConsumerEdgeRollsBackWithoutExactEdge(t *testing.T) { + t.Parallel() + + h := newConsumerRestoreHarness(t) + h.edge.ExpectedRevision++ + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + attempt := h.edge + attempt.ExpectedRevision-- + resolution, err := h.canon.ResolveConsumerEdge( + t.Context(), attempt, true, + ) + require.Error(t, err) + require.Equal(t, batchcanon.ConsumerEdgeDeferred, resolution) + + unchanged, err := h.vtxos.GetVTXO(t.Context(), attempt.ConsumedVTXO) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, unchanged.Status) + require.Equal(t, attempt.ExpectedRevision, unchanged.BusinessRevision) + require.Equal( + t, h.consumer, + unchanged.ForfeitConsumerBatch.UnwrapOr( + chainhash.Hash{}, + ), + ) + + pending, err := h.canon.ListPendingConsumerEdges( + t.Context(), h.consumer, + ) + require.NoError(t, err) + require.Len(t, pending, 1) + require.Equal(t, h.edge.ExpectedRevision, pending[0].ExpectedRevision) +} + +// TestConsumerEdgeEvidenceIsImmutable proves a repeat cannot change the exact +// revision or creator lineage used by terminal restoration. +func TestConsumerEdgeEvidenceIsImmutable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*batchcanon.ConsumerEdge) + }{ + { + name: "business revision", + mutate: func(edge *batchcanon.ConsumerEdge) { + edge.ExpectedRevision++ + }, + }, + { + name: "creator lineage", + mutate: func(edge *batchcanon.ConsumerEdge) { + edge.CreatorLineage = []chainhash.Hash{ + { + 0xd7, + }, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h := newConsumerRestoreHarness(t) + h.registerConsumer(t, batchcanon.StateConflictFinalized) + + changed := h.edge + test.mutate(&changed) + err := h.canon.RegisterBatch( + t.Context(), readyBatchRecord( + h.consumer, + batchcanon.StateConflictFinalized, + ), + []batchcanon.ConsumerEdge{changed}, + ) + require.ErrorIs( + t, err, batchcanon.ErrRegistrationConflict, + ) + + record, err := h.canon.GetBatch(t.Context(), h.consumer) + require.NoError(t, err) + require.Equal( + t, batchcanon.RegistrationQuarantined, + record.RegistrationStage, + ) + require.False(t, record.Ready()) + + pending, err := h.canon.ListPendingConsumerEdges( + t.Context(), h.consumer, + ) + require.NoError(t, err) + require.Equal( + t, []batchcanon.ConsumerEdge{h.edge}, pending, + ) + }) + } +} + +// TestBatchCanonicalityBackfillFromVTXOs verifies that backfill derives one +// canonicality record per distinct batch present in the VTXO store, with the +// CSV-relative expiry delta recovered from the stored absolute batch_expiry, +// the right provisional/finalized classification, and the dependent VTXO +// linked. It also verifies idempotency: a re-run creates nothing and does not +// clobber state the manager has since advanced. +func TestBatchCanonicalityBackfillFromVTXOs(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + vtxoStore, roundStore, baseDB := newVTXOStoreForTest(t) + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return baseDB.WithTx(tx) + }, + btclog.Disabled, + ) + canon := NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) + + // A round must exist to satisfy the VTXO foreign key. + roundID := testRoundIDDB("backfill-round") + testRound := createTestRound(t, roundID) + sigState := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, sigState)) + + // Two VTXOs in two distinct batches: + // idx 0: batch_expiry 1000, created_height 500 + // idx 1: batch_expiry 1100, created_height 510 + desc0 := createTestVTXODescriptor(t, roundID, 0) + desc1 := createTestVTXODescriptor(t, roundID, 1) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc0)) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc1)) + + // best height 505, finality depth 6: + // batch 0: depth = 505-500+1 = 6 >= 6 -> finalized + // batch 1: depth = 505-510+1 < 6 -> provisional + n, err := canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 2, n) + + rec0, err := canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateFinalized, rec0.State) + require.False(t, rec0.Ready()) + require.False(t, rec0.EvidenceComplete()) + require.Equal( + t, batchcanon.RegistrationReconciling, rec0.RegistrationStage, + ) + require.Equal(t, int32(500), rec0.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(500), rec0.CSVExpiryDelta) + require.Equal(t, int32(1000), rec0.EffectiveExpiry().UnwrapOr(0)) + require.Equal(t, []wire.OutPoint{desc0.Outpoint}, rec0.DependentVTXOs) + + rec1, err := canon.GetBatch(ctx, desc1.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateProvisional, rec1.State) + require.False(t, rec1.Ready()) + require.Equal(t, int32(590), rec1.CSVExpiryDelta) + + // Idempotency: advance one batch's state, re-run backfill, and verify + // it creates nothing new and leaves the advanced state untouched. + require.NoError( + t, canon.UpdateBatchState( + ctx, desc0.CommitmentTxID, batchcanon.StateReorgedOut, + ), + ) + n, err = canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 0, n) + + rec0, err = canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateReorgedOut, rec0.State) + + // The first authenticated producer registration atomically completes an + // upgrade placeholder. Age-derived state is discarded, a fresh + // generation starts fail-closed, and dependents learned from the old DB + // are retained alongside newly registered ones. + input := wire.OutPoint{Hash: chainhash.Hash{0xe1}, Index: 2} + watchScript := []byte{0x51, 0x20, 0xe2} + tx := wire.NewMsgTx(2) + tx.AddTxIn(wire.NewTxIn(&input, nil, nil)) + tx.AddTxOut(wire.NewTxOut(1_000, watchScript)) + var raw bytes.Buffer + require.NoError(t, tx.Serialize(&raw)) + newDependent := wire.OutPoint{ + Hash: chainhash.Hash{ + 0xe3, + }, + Index: 1, + } + require.NoError( + t, + canon.RegisterBatch( + ctx, &batchcanon.Record{ + BatchTxID: desc0.CommitmentTxID, + BatchTx: raw.Bytes(), + BatchOutputIndex: 0, + ConfirmationPkScript: watchScript, + CSVExpiryDelta: 500, + ConsumedInputs: []batchcanon.ConsumedInput{ + { + Outpoint: input, + Value: 1_100, + PkScript: []byte{0x51}, + }, + }, + DependentVTXOs: []wire.OutPoint{ + newDependent, + }, + }, + nil, + ), + ) + + rec0, err = canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.True(t, rec0.EvidenceComplete()) + require.False(t, rec0.Ready()) + require.Equal(t, batchcanon.StateUnseen, rec0.State) + require.Equal( + t, batchcanon.RegistrationRegistering, rec0.RegistrationStage, + ) + require.Equal(t, uint64(2), rec0.ObservationGeneration) + require.True(t, rec0.ConfirmationHeight.IsNone()) + require.ElementsMatch( + t, []wire.OutPoint{desc0.Outpoint, newDependent}, + rec0.DependentVTXOs, + ) +} diff --git a/db/store.go b/db/store.go index 1524da573..21143400b 100644 --- a/db/store.go +++ b/db/store.go @@ -347,6 +347,29 @@ func (s *Store) NewActivityStore(clk clock.Clock) *ActivityPersistenceStore { return NewActivityPersistenceStore(activityDB, clk) } +// NewBatchCanonicalityStore builds the batch canonicality persistence store +// with transactional query execution. +// +// The store holds the durable, reorg-aware record of how each batch +// (commitment) tx is faring against the best chain, plus the reverse +// dependencies needed to restore a provisionally consumed VTXO. It is +// behavior-free; interpretation lives in the batch canonicality manager. +func (s *Store) NewBatchCanonicalityStore( + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + baseDB := s.BaseDB() + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return s.queries.WithTx(tx) + }, + s.log, + ) + + return NewBatchCanonicalityPersistenceStore(canonDB, clk) +} + // NewUnilateralExitStore builds the unilateral-exit persistence store with // transactional query execution. func (s *Store) NewUnilateralExitStore( diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 24898a790..13b243c71 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -706,17 +706,23 @@ func (s *VTXOPersistenceStore) GetForfeitTx(ctx context.Context, // MarkForfeited marks a VTXO as forfeited and records the forfeit transaction // ID. This is called when the new round's commitment transaction confirms. func (s *VTXOPersistenceStore) MarkForfeited( - ctx context.Context, outpoint wire.OutPoint, forfeitTxID chainhash.Hash, + ctx context.Context, outpoint wire.OutPoint, + forfeitTxID, consumerBatchTxID chainhash.Hash, ) error { + if consumerBatchTxID == (chainhash.Hash{}) { + return fmt.Errorf("forfeit consumer batch txid is required") + } + writeTxOpts := WriteTxOption() return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { params := sqlc.MarkVTXOForfeitedParams{ - OutpointHash: outpoint.Hash[:], - OutpointIndex: int32(outpoint.Index), - ForfeitTxid: forfeitTxID[:], - ReplacedByHash: nil, // Set separately if needed. + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + ForfeitTxid: forfeitTxID[:], + ForfeitConsumerTxid: consumerBatchTxID[:], + ReplacedByHash: nil, // Set separately if needed. ReplacedByIndex: sql.NullInt32{ Valid: false, }, @@ -900,23 +906,30 @@ func (s *VTXOPersistenceStore) rowToDescriptor(ctx context.Context, clientKey.PubKey = derived.clientPubkey } + forfeitConsumer, err := bytesToOptionHash(row.ForfeitConsumerTxid) + if err != nil { + return nil, fmt.Errorf("decode forfeit consumer txid: %w", err) + } + return &vtxo.Descriptor{ - Outpoint: outpoint, - Amount: btcutil.Amount(row.Amount), - PolicyTemplate: derived.policyTemplate, - PkScript: row.PkScript, - ClientKey: clientKey, - OperatorKey: derived.operatorPubkey, - TapScript: derived.tapscript, - Ancestry: ancestry, - RoundID: row.RoundID, - ForfeitRoundID: row.ForfeitRoundID.String, - CommitmentTxID: commitmentTxID, - BatchExpiry: row.BatchExpiry, - RelativeExpiry: derived.relativeExpiry, - ChainDepth: int(row.ChainDepth), - CreatedHeight: row.CreatedHeight, - Status: vtxo.VTXOStatus(row.Status), + Outpoint: outpoint, + Amount: btcutil.Amount(row.Amount), + PolicyTemplate: derived.policyTemplate, + PkScript: row.PkScript, + ClientKey: clientKey, + OperatorKey: derived.operatorPubkey, + TapScript: derived.tapscript, + Ancestry: ancestry, + RoundID: row.RoundID, + ForfeitRoundID: row.ForfeitRoundID.String, + CommitmentTxID: commitmentTxID, + BatchExpiry: row.BatchExpiry, + RelativeExpiry: derived.relativeExpiry, + ChainDepth: int(row.ChainDepth), + CreatedHeight: row.CreatedHeight, + Status: vtxo.VTXOStatus(row.Status), + BusinessRevision: uint64(row.BusinessRevision), + ForfeitConsumerBatch: forfeitConsumer, ConstructionVersion: arkrpc.ConstructionVersion( row.ConstructionVersion, ), diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 606e8029a..e94a5dcf4 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -1241,6 +1241,7 @@ func TestVTXOPersistenceStoreListVTXOsByStatusSettlement(t *testing.T) { chainhash.HashH( []byte("forfeit-tx"), ), + settlementTxid, ), ) @@ -1452,7 +1453,9 @@ func TestVTXOPersistenceStoreStatusTransitions(t *testing.T) { // Transition to Forfeited via MarkForfeited. forfeitTxID := chainhash.Hash{0xab, 0xcd} - err = vtxoStore.MarkForfeited(ctx, desc.Outpoint, forfeitTxID) + err = vtxoStore.MarkForfeited( + ctx, desc.Outpoint, forfeitTxID, chainhash.Hash{0x71}, + ) require.NoError(t, err) fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) @@ -1653,7 +1656,9 @@ func TestVTXOPersistenceStoreMarkForfeitedRecordsTxID(t *testing.T) { // Now mark as forfeited with a txid. forfeitTxID := chainhash.Hash{0xde, 0xad, 0xbe, 0xef} - err = vtxoStore.MarkForfeited(ctx, desc.Outpoint, forfeitTxID) + err = vtxoStore.MarkForfeited( + ctx, desc.Outpoint, forfeitTxID, chainhash.Hash{0x72}, + ) require.NoError(t, err) // Verify via raw db query that the forfeit_txid was stored. @@ -1712,6 +1717,7 @@ func TestVTXOPersistenceStoreMultipleVTXOsLifecycle(t *testing.T) { require.NoError(t, err) err = vtxoStore.MarkForfeited( ctx, vtxos[2].Outpoint, chainhash.Hash{0x02}, + chainhash.Hash{0x73}, ) require.NoError(t, err) diff --git a/oor/local_persistence_handler_test.go b/oor/local_persistence_handler_test.go index e7d3e0dc6..f8d0f3b57 100644 --- a/oor/local_persistence_handler_test.go +++ b/oor/local_persistence_handler_test.go @@ -201,7 +201,7 @@ func (s *testVTXOStore) GetForfeitTx(_ context.Context, _ wire.OutPoint) ( // MarkForfeited is unused by these tests. func (s *testVTXOStore) MarkForfeited(_ context.Context, _ wire.OutPoint, - _ chainhash.Hash) error { + _, _ chainhash.Hash) error { return nil } diff --git a/unroll/actor_test.go b/unroll/actor_test.go index 47a82da98..839682302 100644 --- a/unroll/actor_test.go +++ b/unroll/actor_test.go @@ -123,7 +123,7 @@ func (m *mockVTXOStore) GetForfeitTx(context.Context, wire.OutPoint) ( // MarkForfeited is unused in these tests. func (m *mockVTXOStore) MarkForfeited(context.Context, wire.OutPoint, - chainhash.Hash) error { + chainhash.Hash, chainhash.Hash) error { return nil } diff --git a/vtxo/actor.go b/vtxo/actor.go index efc09769a..f05c7f3e1 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -890,6 +890,11 @@ func (a *VTXOActor) processStatusUpdate(ctx context.Context, ) switch { + case m.NewStatus == VTXOStatusForfeited: + err = a.cfg.Store.MarkForfeited( + ctx, m.Outpoint, m.ForfeitTxID, m.ConsumerBatchTxID, + ) + case isForfeitingWithTx: err = a.cfg.Store.MarkForfeiting( ctx, m.Outpoint, m.RoundID, m.ForfeitTx, diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index 3d8fc97e9..2c69507ec 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -122,9 +122,10 @@ func (m *MockVTXOStore) UpdateVTXOStatusReleasingReservation( } func (m *MockVTXOStore) MarkForfeited(ctx context.Context, - outpoint wire.OutPoint, forfeitTxID chainhash.Hash) error { + outpoint wire.OutPoint, + forfeitTxID, consumerBatchTxID chainhash.Hash) error { - args := m.Called(ctx, outpoint, forfeitTxID) + args := m.Called(ctx, outpoint, forfeitTxID, consumerBatchTxID) return args.Error(0) } diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index 947388296..a96e2fa6a 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -438,6 +438,15 @@ type Descriptor struct { // Status is the current lifecycle status of the VTXO. Status VTXOStatus + // BusinessRevision increments on every durable lifecycle transition. A + // consumer edge binds the revision installed by its exact forfeiture so + // stale restore work cannot cross an intervening spend or reservation. + BusinessRevision uint64 + + // ForfeitConsumerBatch identifies the exact commitment transaction that + // currently owns a Forfeited marker. It is None in every other state. + ForfeitConsumerBatch fn.Option[chainhash.Hash] + // ConstructionVersion is the per-VTXO construction version: the rules // under which this VTXO was built and must be spent/exited. It is // stamped at creation and never changes. Today the only understood @@ -590,7 +599,7 @@ type VTXOStore interface { // transaction confirms. MarkForfeited( ctx context.Context, outpoint wire.OutPoint, - forfeitTxID chainhash.Hash, + forfeitTxID, consumerBatchTxID chainhash.Hash, ) error // DeleteVTXO removes a VTXO from storage. Used for cleanup after diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index fbe1c3f94..4460be8a6 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -2,6 +2,7 @@ package vtxo import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/baselib/actor" "github.com/lightninglabs/wavelength/lib/actormsg" @@ -162,6 +163,14 @@ type VTXOStatusUpdate struct { // recovery. ForfeitTx *wire.MsgTx + // ForfeitTxID is the exact forfeit transaction hash recorded when the + // forfeit becomes terminal. + ForfeitTxID chainhash.Hash + + // ConsumerBatchTxID is the commitment transaction whose confirmation + // caused the forfeiture. It binds conditional restore to one consumer. + ConsumerBatchTxID chainhash.Hash + // ReleaseSpendReservation, when true, instructs the persistence layer // to delete this outpoint's durable spending-reservation row in the // same transaction as the status update. Set on transitions that move diff --git a/vtxo/transitions.go b/vtxo/transitions.go index a40edb6ad..e8d721144 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/build" @@ -1006,6 +1007,18 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, // New commitment tx confirmed, forfeit is complete. Include // the ForfeitTx so the persistence layer can call MarkForfeited // with the txid for audit/recovery purposes. + forfeitTxID := s.ForfeitTxID + if forfeitTxID == (chainhash.Hash{}) && s.ForfeitTx != nil { + forfeitTxID = s.ForfeitTx.TxHash() + } + statusUpdate := &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusForfeited, + ForfeitTx: s.ForfeitTx, + ForfeitTxID: forfeitTxID, + } + statusUpdate.ConsumerBatchTxID = evt.CommitmentTxID + return &VTXOStateTransition{ NextState: &ForfeitedState{ VTXO: s.VTXO, @@ -1014,11 +1027,7 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, }, NewEvents: fn.Some(VTXOEmittedEvent{ Outbox: []VTXOOutMsg{ - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusForfeited, - ForfeitTx: s.ForfeitTx, - }, + statusUpdate, &VTXOTerminatedNotification{ VTXOOutpoint: s.VTXO.Outpoint, FinalState: "Forfeited", diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index 07a592430..2952810b2 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -721,7 +721,8 @@ func TestForfeitingStateConfirmed(t *testing.T) { // Setup mock for marking forfeited. h.store.On( - "MarkForfeited", h.ctx, vtxo.Outpoint, commitmentTxID, + "MarkForfeited", h.ctx, vtxo.Outpoint, chainhash.Hash{}, + commitmentTxID, ).Return(nil) _, err := h.sendEvent(evt) @@ -1336,7 +1337,8 @@ func TestForfeitConfirmedEventIncludesForfeitTx(t *testing.T) { // Setup mock for marking forfeited. h.store.On( - "MarkForfeited", h.ctx, vtxo.Outpoint, commitmentTxID, + "MarkForfeited", h.ctx, vtxo.Outpoint, forfeitTx.TxHash(), + commitmentTxID, ).Return(nil) _, err := h.sendEvent(evt) diff --git a/waved/wallet_ops_test.go b/waved/wallet_ops_test.go index 57281017d..9ef677984 100644 --- a/waved/wallet_ops_test.go +++ b/waved/wallet_ops_test.go @@ -94,7 +94,7 @@ func (s *testCustomInputStore) GetForfeitTx(context.Context, wire.OutPoint) ( } func (s *testCustomInputStore) MarkForfeited(context.Context, wire.OutPoint, - chainhash.Hash) error { + chainhash.Hash, chainhash.Hash) error { return fmt.Errorf("unexpected MarkForfeited call") } From 795bd96a33978e9845ce607f671125d09911ee4d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 19:21:02 -0700 Subject: [PATCH 04/16] vtxo: Gate coin selection on batch-lineage canonicality Add an optional BatchCanonicality store to the VTXO manager and drop coin-selection candidates whose batch lineage is not a ready, confirmed member of the canonical chain. This includes reorged-out, invalidated, missing, reconciling, or unregistered lineage. The nil-store default is behaviour-neutral until round and OOR producers register their batches. --- vtxo/manager.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/vtxo/manager.go b/vtxo/manager.go index 65f645677..3946ed036 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/coinselect" @@ -100,6 +101,16 @@ type ManagerConfig struct { ChainParams *chaincfg.Params ExpiryConfig *ExpiryConfig + // BatchCanonicality, when set, gates coin selection (and the explicit + // forfeit reserve path) on batch-lineage canonicality: a VTXO whose + // batch reorged out, was conflict-invalidated, or is not a ready, + // confirmed batch is excluded so it is never spent or forfeited while + // its lineage is off the canonical chain (lumos#454). Nil disables the + // gate. Because the reducer is fail-closed, the gate must only be + // activated once the batch producers (round, OOR) register their + // batches, or every unregistered VTXO is excluded. + BatchCanonicality batchcanon.Store + // Log is an optional logger for this manager instance. If None, the // manager falls back to extracting a logger from context via // LoggerFromContext, or uses btclog.Disabled if no logger is found. @@ -1953,6 +1964,17 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( }) } + // Drop any candidate whose batch lineage is not usable (reorged out, + // invalidated, or — fail-closed — missing/reconciling/unregistered), so + // a VTXO off the canonical chain is never selected. No-op when no + // canonicality store is configured. Applied before selection so both + // the exact-outpoint and largest-first paths only ever see canonical + // VTXOs. + candidates, err = m.gateUnavailableLineage(ctx, candidates) + if err != nil { + return nil, 0, err + } + selected, err := m.selectReservationCandidates(ctx, candidates, p) if err != nil { return nil, 0, err @@ -2177,6 +2199,50 @@ func (m *Manager) exactSpendUnavailableError(ctx context.Context, // insufficientLiquidityError distinguishes a true spendable-funds shortfall // from liquidity that is present but unavailable because another operation has // already moved it out of LiveState. +// gateUnavailableLineage drops candidates whose batch lineage is not usable — +// reorged-out (limbo), conflict-invalidated, or (fail-closed) missing, +// reconciling, or unregistered — so a VTXO is never selected while its batch +// is not a ready, confirmed member of the canonical chain. It is a no-op when +// no canonicality store is configured (the gate stays dormant until the batch +// producers register their batches). It reads each candidate's direct +// commitment txid; full multi-parent ancestry gating is a follow-up. +func (m *Manager) gateUnavailableLineage(ctx context.Context, + candidates []*Descriptor) ([]*Descriptor, error) { + + if m.cfg.BatchCanonicality == nil { + return candidates, nil + } + + kept := make([]*Descriptor, 0, len(candidates)) + for _, c := range candidates { + desc, err := m.cfg.Store.GetVTXO(ctx, c.Outpoint) + if err != nil { + return nil, fmt.Errorf("load vtxo for lineage gate "+ + "%s: %w", c.Outpoint, err) + } + + blocked, avail, err := batchcanon.LineageBlocked( + ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ) + if err != nil { + return nil, fmt.Errorf("lineage gate %s: %w", + c.Outpoint, err) + } + if blocked { + m.logger(ctx).DebugS(ctx, "Excluding VTXO with "+ + "unavailable batch lineage from selection", + slog.String("outpoint", c.Outpoint.String()), + slog.String("availability", avail.String())) + + continue + } + + kept = append(kept, c) + } + + return kept, nil +} + func (m *Manager) insufficientLiquidityError(ctx context.Context, liveCandidates []*Descriptor, p reserveParams) error { From 2e5a82f23cb1427970177f743054cd1e153d64f8 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 21:51:44 -0700 Subject: [PATCH 05/16] waved: activate the batch-canonicality reorg-safety gate Wire the batch-canonicality authority into daemon startup so the VTXO coin-selection reorg-safety gate (lumos#454) can run against the live chain. On start (after the chain source is registered) waved now builds the durable canonicality store, backfills fail-closed placeholders from existing VTXOs anchored to the current tip, registers + reconciles the BatchCanonicalityManager actor, and stashes the store and manager ref on the Server. The manager is always built, reconciled, and left observing so a reorg that lands while the daemon is down is detected on the next start. The VTXO admission gate itself is threaded into the VTXO ManagerConfig only when the new BatchCanonicalityGate config flag is set. The gate is fail-closed: until the round and OOR producers register their batches (a follow-up in the reorg-safety stack), every VTXO lineage would be unregistered and therefore excluded, stranding all liquidity. The flag defaults false so the daemon stays behaviour-neutral until producer registration lands, and flips to true (the intended steady state) once producers register batches. --- sample-waved.conf | 5 + waved/config.go | 29 +++++ waved/config_reorg_safety_depth_test.go | 58 ++++++++++ waved/logging.go | 5 + waved/server.go | 137 ++++++++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 waved/config_reorg_safety_depth_test.go diff --git a/sample-waved.conf b/sample-waved.conf index 8c0f42e5c..0426d93c3 100644 --- a/sample-waved.conf +++ b/sample-waved.conf @@ -45,6 +45,11 @@ # uses the default of 30; the current cross-backend maximum is 144. # reorgsafetydepth=30 +# Activate fail-closed batch-lineage canonicality checks when selecting or +# explicitly spending VTXOs. Keep disabled unless all lineage producers are +# deployed and registering their batches. +# batchcanonicalitygate=false + # Explicit opt-in for mainnet operation. # allow-mainnet=false diff --git a/waved/config.go b/waved/config.go index f15931aec..6eeabdb54 100644 --- a/waved/config.go +++ b/waved/config.go @@ -330,6 +330,24 @@ type Config struct { // v2 safety capability and depth negotiation are enabled. ReorgSafetyDepth uint32 `mapstructure:"reorgsafetydepth"` + // BatchCanonicalityGate activates the fail-closed batch-lineage + // reorg-safety gate on the VTXO coin-selection (and forfeit) admission + // path (lumos#454). When true, the durable batch-canonicality store is + // threaded into the VTXO manager so a VTXO whose batch reorged out, was + // conflict-invalidated, or is not a ready/confirmed member of the + // canonical chain is excluded from selection. + // + // It defaults to false because the gate is fail-closed: until the round + // and OOR producers register their batches with the canonicality + // manager (a follow-up in the reorg-safety stack), every VTXO's lineage + // would be unregistered and therefore excluded, stranding all + // liquidity. The batch-canonicality manager itself is always built, + // reconciled, and left observing; only the admission gate is gated on + // this flag so the daemon stays behaviour-neutral until producer + // registration lands. Flip to true (the intended steady state) once + // producers register batches. + BatchCanonicalityGate bool `mapstructure:"batchcanonicalitygate"` + // RegistrationTimeout is the maximum wall-clock duration to // wait for the server's RoundJoined admission watermark after // sending a JoinRoundRequest. If zero, the round package @@ -1248,6 +1266,17 @@ func (c *Config) Validate() error { return fmt.Errorf("reorgsafetydepth exceeds maximum %d: got %d", MaxReorgSafetyDepth, c.ReorgSafetyDepth) } + // A zero depth resolves to the built-in default, which would silently + // diverge from the server's horizon (where zero means immediate + // finality). When the fail-closed batch-canonicality gate is active, + // require an explicit non-zero depth so the same horizon is configured + // deliberately on both sides rather than defaulted on one and + // disabled/defaulted differently on the other. + if c.ReorgSafetyDepth == 0 && c.BatchCanonicalityGate { + return fmt.Errorf("reorgsafetydepth must be set explicitly " + + "(> 0) when batchcanonicalitygate is enabled, so the " + + "reorg-safety horizon matches the server") + } if c.OOR == nil { c.OOR = defaultOORConfig() diff --git a/waved/config_reorg_safety_depth_test.go b/waved/config_reorg_safety_depth_test.go new file mode 100644 index 000000000..8d3aa3957 --- /dev/null +++ b/waved/config_reorg_safety_depth_test.go @@ -0,0 +1,58 @@ +package waved + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestConfigValidateReorgSafetyDepthWithGate asserts that when the fail-closed +// batch-canonicality gate is enabled, a zero reorg-safety depth is rejected so +// the horizon is configured explicitly and deliberately matches the server, +// rather than silently resolving to the built-in default on the client while +// the server treats zero as immediate finality. +func TestConfigValidateReorgSafetyDepthWithGate(t *testing.T) { + t.Parallel() + + base := func() *Config { + cfg := DefaultConfig() + cfg.Network = "regtest" + cfg.Server.Host = "127.0.0.1:10010" + cfg.Wallet.EsploraURL = "http://127.0.0.1:3000" + + return cfg + } + + t.Run("zero depth, gate off is valid", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.ReorgSafetyDepth = 0 + cfg.BatchCanonicalityGate = false + require.NoError(t, cfg.Validate()) + }) + + t.Run("zero depth, gate on is rejected", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.ReorgSafetyDepth = 0 + cfg.BatchCanonicalityGate = true + + err := cfg.Validate() + require.Error(t, err) + require.Contains( + t, err.Error(), + "reorgsafetydepth must be set explicitly", + ) + }) + + t.Run("nonzero depth, gate on is valid", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.ReorgSafetyDepth = 30 + cfg.BatchCanonicalityGate = true + require.NoError(t, cfg.Validate()) + }) +} diff --git a/waved/logging.go b/waved/logging.go index 368c5b653..168b554bf 100644 --- a/waved/logging.go +++ b/waved/logging.go @@ -48,6 +48,7 @@ var allSubsystems = []string{ "TXCF", "UNRL", VHTLCRecoverySubsystem, + batchCanonSubsystem, } const ( @@ -75,6 +76,10 @@ const ( // Prometheus metrics HTTP server so its logs can be level-tuned // independently of the main daemon logs. MetricsSubsystem = "PROM" + + // batchCanonSubsystem is the subsystem tag used for the + // batch-canonicality reorg-safety manager logs. + batchCanonSubsystem = "BCAN" ) // SetupLoggersWithShutdownFn registers all subsystem loggers using a plain diff --git a/waved/server.go b/waved/server.go index 8f460b54c..9cb16e257 100644 --- a/waved/server.go +++ b/waved/server.go @@ -29,6 +29,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/wavelength/arkrpc" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/btcwbackend" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainbackends" @@ -398,6 +399,19 @@ type Server struct { // subsystem is initialized. lazyChainResolver *vtxo.LazyChainResolver + // batchCanonStore is the durable batch-canonicality store backing the + // VTXO coin-selection reorg-safety gate (lumos#454). It is threaded + // into the VTXO manager only when Config.BatchCanonicalityGate is set; + // a nil store leaves the gate dormant. Read lazily at admission time. + batchCanonStore batchcanon.Store + + // batchCanonRef is the BatchCanonicalityManager actor ref. The round + // and OOR producers will Tell it a RegisterBatchRequest as their VTXOs + // are born so each lineage batch gets reorg-aware conf/spend watches + // (producer registration lands in a follow-up). None until + // initBatchCanonicality registers the manager. + batchCanonRef fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + serverConn *grpc.ClientConn arkClient arkrpc.ArkServiceClient mailboxClient mailboxpb.MailboxServiceClient @@ -2434,6 +2448,18 @@ func (s *Server) startWalletDependentActors(ctx context.Context, } s.walletRef = fn.Some(walletRef) + // ------------------------------------------------------- + // 9b. Build the batch-canonicality store and manager. The + // store backs the VTXO coin-selection reorg-safety gate; + // the manager ref lets the round and OOR producers + // register batches as their VTXOs are born (follow-up). + // Built before the VTXO manager so its config can carry + // the store directly without a post-Start mutation. + // ------------------------------------------------------- + if err := s.initBatchCanonicality(ctx, chainSourceRef); err != nil { + return err + } + // ------------------------------------------------------- // 10. Start the VTXO manager before the round actor so // the manager ref can be passed directly in the round @@ -4338,6 +4364,116 @@ func (s *Server) dropCustomForfeitSigningContexts(_ context.Context, return nil } +// initBatchCanonicality builds the durable batch-canonicality store, backfills +// canonicality placeholders from the VTXOs already in the DB, and starts the +// BatchCanonicalityManager actor that arms reorg-aware confirmation and spend +// watches on every fully-registered batch. The store and manager ref are +// stashed on the Server so the VTXO manager can consult the reorg-safety gate +// and the round/OOR producers can (in a follow-up) feed it. +// +// It must run after the chain source is registered: it needs the current best +// height to anchor backfilled records and the chain source ref to arm watches. +// +// The manager is always built, reconciled, and left observing. The VTXO +// coin-selection gate is only activated (the store threaded into the VTXO +// config) when Config.BatchCanonicalityGate is set, because the gate is +// fail-closed: until the producers register their batches, every VTXO lineage +// is unregistered and would be excluded. See Config.BatchCanonicalityGate. +func (s *Server) initBatchCanonicality(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) error { + + dbStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), + s.subLogger(db.Subsystem), + ) + canonStore := dbStore.NewBatchCanonicalityStore(s.clk) + + // Seed canonicality placeholders for batches anchoring VTXOs already in + // the DB so historical lineage is tracked too, not just batches born + // after this start. Backfill anchors each record relative to the live + // tip, so a record's confirmation depth is correct on the first + // reconcile. Placeholders are never Ready (no authenticated tx), so + // they stay fail-closed until an authenticated producer registration + // replaces them. + bestHeight, err := s.batchCanonBestHeight(ctx, chainSourceRef) + if err != nil { + return fmt.Errorf("unable to read best height for batch "+ + "canonicality backfill: %w", err) + } + created, err := canonStore.BackfillFromVTXOs( + ctx, bestHeight, s.cfg.chainFinalityDepth(), + ) + if err != nil { + return fmt.Errorf("unable to backfill batch canonicality: %w", + err) + } + + mgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSourceRef, + Log: fn.Some(s.subLogger(batchCanonSubsystem)), + }) + mgrRef := actor.RegisterWithSystem( + s.actorSystem, "batch-canonicality", + batchcanon.ManagerServiceKey, mgr, + ) + mgr.SetSelfRef(mgrRef) + + // Reconcile re-arms watches for every fully-registered non-final record + // (any that survived a restart) so a reorg that lands while the daemon + // is down is still detected on the next start. Backfilled placeholders + // lack complete evidence and are skipped (fail-closed). + if err := mgr.Reconcile(ctx); err != nil { + s.actorSystem.StopAndRemoveActor("batch-canonicality") + + return fmt.Errorf("unable to reconcile batch canonicality: %w", + err) + } + + s.batchCanonRef = fn.Some[actor.TellOnlyRef[batchcanon.ManagerMsg]]( + mgrRef, + ) + + // Only activate the VTXO admission gate when explicitly enabled. The + // gate is fail-closed, so threading the store before producers register + // their batches would strand all liquidity (see the config field doc). + if s.cfg.BatchCanonicalityGate { + s.batchCanonStore = canonStore + } + + s.log.InfoS(ctx, "Batch canonicality manager registered and started", + slog.Int("backfilled_records", created), + slog.Bool("gate_active", s.cfg.BatchCanonicalityGate), + ) + + return nil +} + +// batchCanonBestHeight asks the chain source for the current best block height, +// used to anchor batch-canonicality records during backfill. +func (s *Server) batchCanonBestHeight(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) (int32, error) { + + resp, err := chainSourceRef.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return 0, err + } + + heightResp, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0, fmt.Errorf("unexpected best-height response type %T", + resp) + } + + return heightResp.Height, nil +} + // initVTXOManager creates, registers, and starts the VTXO manager actor. // The manager recovers persisted VTXOs on startup and spawns one VTXO actor // per live descriptor. @@ -4384,6 +4520,7 @@ func (s *Server) initVTXOManager(ctx context.Context, ActorSystem: s.actorSystem, ChainParams: s.chainParams, ExpiryConfig: s.vtxoExpiryConfig(), + BatchCanonicality: s.batchCanonStore, Log: fn.Some(s.subLogger(vtxo.Subsystem)), RoundActor: roundActor, LedgerSink: fn.Some(ledgerSink), From 36de7915f78d3997c112b7cdcf584c98ff35b342 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 21:51:55 -0700 Subject: [PATCH 06/16] harness: add reorg-excluding and spendable-outpoint helpers Add the systest primitives the batch-canonicality reorg scenarios need: - ReorgExcludingMempool mines the replacement branch with EMPTY blocks (generateblock) so a disconnected transaction is NOT auto-reconfirmed on the new branch. This makes the post-reorg "transaction off-chain" window deterministic instead of the tx silently reconfirming from the mempool on the first replacement block (as it does under Reorg's generatetoaddress). Reorg and ReorgExcludingMempool now share a reorgWith driver. - FirstSpendableOutpoint returns a confirmed wallet outpoint plus its value and pkScript without spending it, so a test can register it as a batch's consumed input (the pkScript is required to arm the spend watch). - BuildSignedSpend builds, signs, and broadcasts a 1-in/1-out tx spending that outpoint, returning the fully signed wire.MsgTx and its txid. The corrected batch-canonicality registration authenticates the serialized commitment tx and its exact input set, so a seeded batch must be a real transaction whose single input matches the registered ConsumedInput; an opaque sendtoaddress faucet tx cannot satisfy that. SpendOutpoint is a thin wrapper for controlled double-spends. bitcoindFirstSpendableUTXO now also returns the output scriptPubKey. --- harness/harness.go | 231 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 219 insertions(+), 12 deletions(-) diff --git a/harness/harness.go b/harness/harness.go index ac0dc791a..c00a95e79 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -1630,10 +1630,47 @@ func (h *Harness) ReorgDepth(depth int) ReorgResult { // Reorg invalidates the current tip's last depth blocks, mines newBlocks on // top of the fork point, and waits for the primary LND node to resync. The -// harness must be fully started before calling Reorg. +// harness must be fully started before calling Reorg. The replacement branch +// is mined with generatetoaddress, which sweeps the mempool, so a transaction +// from the disconnected branch re-confirms on the first replacement block. Use +// ReorgExcludingMempool when the disconnected transaction must stay off-chain. func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { h.T.Helper() + return h.reorgWith(depth, newBlocks, "", h.Generate) +} + +// ReorgExcludingMempool performs a reorg like Reorg, but mines the replacement +// branch with EMPTY blocks (via the generateblock RPC) so transactions from the +// disconnected branch are NOT automatically re-confirmed on the new branch. +// This lets a caller observe the post-reorg "transaction off-chain" window +// deterministically -- e.g. a confirmation watch staying in its reorged-out +// state, or a canonicality record holding ReorgedOut -- instead of the tx +// silently re-confirming on the first replacement block as it would under +// Reorg's generatetoaddress (which pulls the mempool). The stranded tx stays in +// the mempool; mine a normal block afterwards with Generate to re-confirm it. +// +// newBlocks must be > depth so the replacement branch strictly outweighs the +// disconnected one and becomes active. +func (h *Harness) ReorgExcludingMempool(depth, newBlocks int) ReorgResult { + h.T.Helper() + + return h.reorgWith( + depth, newBlocks, " (empty replacement)", h.generateEmptyBlocks, + ) +} + +// reorgWith is the shared reorg driver behind Reorg and ReorgExcludingMempool. +// It invalidates the last depth blocks, waits for bitcoind to roll the active +// chain back to the fork point, mines the replacement branch via the supplied +// generate function, asserts the new branch became active, and waits for the +// primary LND node to resync. logSuffix is appended to the progress log line so +// the variant is identifiable. +func (h *Harness) reorgWith(depth, newBlocks int, logSuffix string, + generate func(int) []BlockHeader) ReorgResult { + + h.T.Helper() + require.Positive(h.T, depth, "reorg depth must be positive") require.Greater( h.T, newBlocks, depth, @@ -1658,13 +1695,14 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { invalidateHash := disconnected[0].Hash h.Logf( - "Reorging depth=%d from old_tip=%s fork_point=%s "+ - "invalidate=%s new_blocks=%d", depth, oldTip.Hash, - forkPoint.Hash, invalidateHash, newBlocks, + "Reorging%s depth=%d from old_tip=%s fork_point=%s "+ + "invalidate=%s new_blocks=%d", logSuffix, depth, + oldTip.Hash, forkPoint.Hash, invalidateHash, newBlocks, ) _, err := h.bitcoinRPCCall("invalidateblock", invalidateHash) require.NoError(h.T, err, "invalidateblock %s", invalidateHash) + // forkHeight is validated non-negative above. expectedForkHeight := uint32(forkHeight) require.Eventually( @@ -1674,7 +1712,7 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { "bitcoind did not roll back to fork height %d", forkHeight, ) - connected := h.Generate(newBlocks) + connected := generate(newBlocks) newTip := h.BestBlockHeader() require.Equal( h.T, connected[len(connected)-1].Hash, newTip.Hash, @@ -1695,6 +1733,39 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { } } +// generateEmptyBlocks mines the given number of blocks that contain only their +// coinbase, using the generateblock RPC with an empty transaction list so the +// mempool is NOT swept into them. Returns the new block headers in height +// order. Used by ReorgExcludingMempool to build a replacement branch that does +// not re-confirm the disconnected branch's transactions. +func (h *Harness) generateEmptyBlocks(blocks int) []BlockHeader { + h.T.Helper() + + addr := h.bitcoindGetNewAddress() + + headers := make([]BlockHeader, 0, blocks) + for range blocks { + // generateblock mines a single block containing only the + // listed transactions (plus coinbase); an empty list yields an + // empty block that ignores the mempool entirely. + res, err := h.bitcoinRPCCall( + "generateblock", addr, []string{}, + ) + require.NoError(h.T, err, "generateblock rpc failed") + + var out struct { + Hash string `json:"hash"` + } + require.NoError( + h.T, json.Unmarshal(res, &out), + "generateblock unmarshal failed", + ) + headers = append(headers, h.BlockHeader(out.Hash)) + } + + return headers +} + // ReconsiderBlock asks bitcoind to reconsider a previously invalidated block. func (h *Harness) ReconsiderBlock(hash string) { h.T.Helper() @@ -1828,7 +1899,7 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, // Find a confirmed wallet UTXO with enough value to cover the // destination + change + a generous fee. - utxoTxid, utxoVout, utxoValueBTC := h.bitcoindFirstSpendableUTXO() + utxoTxid, utxoVout, utxoValueBTC, _ := h.bitcoindFirstSpendableUTXO() utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. @@ -1918,8 +1989,11 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, } // bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via -// `listunspent` and returns its txid, vout, and amount in BTC. -func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { +// `listunspent` and returns its txid, vout, amount in BTC, and the hex-encoded +// scriptPubKey of the output. +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64, + string) { + h.T.Helper() // Restrict to confirmed and spendable; bitcoind defaults are @@ -1928,9 +2002,10 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { require.NoError(h.T, err, "listunspent rpc failed") var utxos []struct { - Txid string `json:"txid"` - Vout uint32 `json:"vout"` - Amount float64 `json:"amount"` + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + ScriptPubKey string `json:"scriptPubKey"` } require.NoError( h.T, json.Unmarshal(res, &utxos), @@ -1940,7 +2015,139 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { first := utxos[0] - return first.Txid, first.Vout, first.Amount + return first.Txid, first.Vout, first.Amount, first.ScriptPubKey +} + +// FirstSpendableOutpoint returns a confirmed, spendable wallet outpoint along +// with its value in BTC and the pkScript of the output, WITHOUT spending it. It +// is used to obtain an outpoint a test can register as a batch's consumed input +// (the pkScript is needed to arm the spend watch) and, via BuildSignedSpend, +// build the batch (commitment) transaction that actually spends it. +func (h *Harness) FirstSpendableOutpoint() (wire.OutPoint, float64, []byte) { + h.T.Helper() + + h.bitcoindEnsureWallet() + + txid, vout, valueBTC, scriptHex := h.bitcoindFirstSpendableUTXO() + hash, err := chainhash.NewHashFromStr(txid) + require.NoError(h.T, err, "parse spendable utxo txid") + + pkScript, err := hex.DecodeString(scriptHex) + require.NoError(h.T, err, "decode spendable utxo pkScript") + + return wire.OutPoint{Hash: *hash, Index: vout}, valueBTC, pkScript +} + +// BuildSignedSpend builds, signs, and broadcasts a 1-input/1-output transaction +// that spends the given wallet-owned outpoint (worth valueBTC) to a fresh +// wallet address, minus a flat fee. It returns the fully signed transaction +// (exactly as it will confirm on-chain, so its output script and TxHash are +// stable) and the broadcast txid. The transaction is left in the mempool for +// the caller to mine. +// +// It underpins the batch-canonicality systests: a seeded batch must be a real +// wire.MsgTx whose single input matches the registered ConsumedInput and whose +// output script matches the registered ConfirmationPkScript. Building the tx +// ourselves (rather than via a sendtoaddress faucet) is the only way to know +// the exact input set the authenticated registration cross-check demands. +func (h *Harness) BuildSignedSpend(op wire.OutPoint, valueBTC float64) ( + *wire.MsgTx, string) { + + h.T.Helper() + + h.bitcoindEnsureWallet() + + value := btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin) + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, value, feeSat, "outpoint value too small to cover fee", + ) + + destAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for spend dest failed") + var destAddrStr string + require.NoError( + h.T, json.Unmarshal(destAddrRes, &destAddrStr), + "getnewaddress unmarshal failed", + ) + destAddr, err := btcaddr.DecodeAddress( + destAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode spend dest address failed") + destPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(h.T, err, "derive spend dest pkScript failed") + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: op, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(value - feeSat), + PkScript: destPkScript, + }) + + var buf bytes.Buffer + require.NoError(h.T, tx.Serialize(&buf), "serialize spend tx failed") + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", + hex.EncodeToString( + buf.Bytes(), + ), + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "spend tx signing incomplete: %s", + signResult.Hex, + ) + + signedBytes, err := hex.DecodeString(signResult.Hex) + require.NoError(h.T, err, "decode signed spend tx hex failed") + + signed := wire.NewMsgTx(2) + require.NoError( + h.T, + signed.Deserialize( + bytes.NewReader(signedBytes), + ), + "deserialize signed spend tx failed", + ) + + sendRes, err := h.bitcoinRPCCall("sendrawtransaction", signResult.Hex) + require.NoError(h.T, err, "sendrawtransaction failed") + var txid string + require.NoError( + h.T, json.Unmarshal(sendRes, &txid), + "sendrawtransaction unmarshal failed", + ) + + h.Logf("Built + broadcast spend of %s in tx %s", op, txid) + + return signed, txid +} + +// SpendOutpoint builds, signs, and broadcasts a transaction that spends the +// given wallet-owned outpoint (worth valueBTC) to a fresh wallet address, +// returning the spending txid. The transaction is broadcast to the mempool but +// NOT mined -- the caller mines it -- so a test can register watches before the +// spend confirms. Used to create a controlled double-spend of a batch's +// registered consumed input. +func (h *Harness) SpendOutpoint(op wire.OutPoint, valueBTC float64) string { + h.T.Helper() + + _, txid := h.BuildSignedSpend(op, valueBTC) + + return txid } // Faucet funds a test address by sending the specified amount from bitcoind's From 321b13e0979fbc1477b5674726b6a198ed7059fa Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 Jul 2026 21:52:05 -0700 Subject: [PATCH 07/16] systest: prove 1-conf usable + reorg-safe VTXO gate Add TestBatchCanonicalityGateBlocksReorgedVTXO (F2): an end-to-end, real-reorg proof that the batch-canonicality coin-selection gate makes a VTXO usable at ONE confirmation, unavailable when its batch reorgs off the canonical chain, and usable again once the batch reconfirms. The test wires a real chainsource actor over the harness LND, a real batchcanon.Manager arming reorg-aware watches, and a real vtxo.Manager whose BatchCanonicality store is the same durable store the manager writes -- mirroring waved's activation. The batch (commitment) tx is a real wire.MsgTx built by the harness that spends one wallet outpoint, so the authenticated registration (serialized tx hash + every TxIn) is satisfied. A single seeded live VTXO anchored on that batch is the sole coin-selection candidate, so the contrast across three beats isolates the gate: 1. Provisional (1 conf): SelectAndReserveSpend succeeds, then release. 2. ReorgExcludingMempool strands the batch (stable ReorgedOut): SelectAndReserveSpend fails. 3. Reconfirm to Provisional: SelectAndReserveSpend succeeds again. --- systest/batch_canonicality_gate_test.go | 447 ++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 systest/batch_canonicality_gate_test.go diff --git a/systest/batch_canonicality_gate_test.go b/systest/batch_canonicality_gate_test.go new file mode 100644 index 000000000..192a7b98c --- /dev/null +++ b/systest/batch_canonicality_gate_test.go @@ -0,0 +1,447 @@ +//go:build systest + +package systest + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +const ( + // f2VTXOCSVDelay is the relative-expiry CSV delay stamped on the + // synthetic test VTXO and registered as the batch's CSV expiry delta. + // The value is arbitrary for this test (expiry is never exercised); it + // just has to be a valid non-zero delay for descriptor/tapscript + // construction. + f2VTXOCSVDelay = 144 + + // f2VTXOAmount is the value of the seeded live VTXO. It is the sole + // coin selection candidate, so its exact value only needs to be a + // spendable amount comfortably above dust. + f2VTXOAmount = btcutil.Amount(50_000) + + // f2ChainPollTimeout bounds how long the test waits for a chain + // observation (confirmation, reorg, reconfirmation) to propagate + // through LND -> chainsource -> the canonicality manager and land in + // the durable record. It is generous because a reorg forces a full LND + // chain resync. + f2ChainPollTimeout = 90 * time.Second + + // f2ChainPollInterval is how often the chain-observation pollers + // re-read the durable record. + f2ChainPollInterval = 500 * time.Millisecond +) + +// TestBatchCanonicalityGateBlocksReorgedVTXO proves the LIVE coin-selection +// reorg-safety gate does its job end to end: a VTXO usable at ONE confirmation +// becomes UNAVAILABLE when its batch (commitment tx) is reorged off the +// canonical chain, then USABLE again once the batch reconfirms. This is the F2 +// acceptance scenario at the vtxo.Manager seam, driven by a real bitcoind +// reorg. +// +// The wiring mirrors production: a real chainsource actor over the harness +// LND, a real batchcanon.Manager arming reorg-aware watches, and a real +// vtxo.Manager whose ManagerConfig.BatchCanonicality points at the SAME durable +// store the manager writes -- exactly how waved threads s.batchCanonStore into +// the VTXO config when the gate is activated. Only the VTXO is seeded directly +// (as seedLiveVTXO does for the directed-send systest) rather than produced by +// a live round; the round production path is covered by TestSendVTXOEndToEnd. +// +// The batch (commitment) tx is a REAL wire.MsgTx built by the harness: it +// spends one confirmed wallet outpoint to a fresh output. Registration is +// authenticated on the corrected API -- the manager cross-checks the serialized +// tx (hash == BatchTxID, output pkScript, and every TxIn registered) -- so the +// batch must be registered with its serialized bytes and the exact consumed +// input. The seeded VTXO's CommitmentTxID is set to the batch txid so the gate +// (which reloads the full descriptor via GetVTXO and reads its direct +// commitment txid) governs the VTXO by that batch. +// +// To make the "batch off-chain" window deterministic, the reorg mines its +// replacement branch with EMPTY blocks (ReorgExcludingMempool), so the batch tx +// is NOT auto-re-confirmed and the canonicality record holds ReorgedOut stably. +// A plain Reorg would re-mine the tx from the mempool on the first replacement +// block, collapsing the window before coin selection could observe it. A +// subsequent normal block re-confirms the stranded tx. +// +// The proof is a contrast on a single VTXO with a single selection target, +// where the ONLY thing that changes between beats is the batch's chain +// canonicality: +// +// 1. Batch Provisional (1 conf) -> SelectAndReserveSpendRequest succeeds. +// 2. Batch ReorgedOut -> SelectAndReserveSpendRequest fails. +// 3. Batch reconfirmed -> SelectAndReserveSpendRequest succeeds. +func TestBatchCanonicalityGateBlocksReorgedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Build a real batch (commitment) tx that spends a confirmed wallet + // outpoint. The corrected registration API authenticates the serialized + // tx and its exact input set, so we cannot use an opaque sendtoaddress + // faucet tx (whose inputs we do not control): we must build the tx + // ourselves so ConsumedInputs matches every TxIn. + consumedOp, valueBTC, inputPkScript := + h.Harness.FirstSpendableOutpoint() + batchTx, batchTxidStr := h.Harness.BuildSignedSpend( + consumedOp, valueBTC, + ) + batchTxid := batchTx.TxHash() + require.Equal( + t, batchTxidStr, batchTxid.String(), + "broadcast txid must match the serialized tx hash", + ) + + var batchBuf bytes.Buffer + require.NoError(t, batchTx.Serialize(&batchBuf), "serialize batch tx") + + inputValueSat := int64( + btcutil.Amount( + valueBTC * btcutil.SatoshiPerBitcoin, + ), + ) + confirmationPkScript := batchTx.TxOut[0].PkScript + + // Seed a live VTXO anchored on the batch tx BEFORE the manager starts + // so it is recovered into a resident actor. Its outpoint is synthetic + // (a VTXO leaf is not the batch tx itself); only its CommitmentTxID + // matters to the gate. + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), batchTxid, f2VTXOAmount, + ) + + // Real batchcanon.Manager over the durable store, wired to the real + // chainsource so it arms reorg-aware watches. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate pointed at the SAME + // canonicality store, mirroring waved's wiring. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f2-gate" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch with authenticated evidence so the manager arms a + // reorg-aware conf watch on the batch tx plus a spend watch on the + // consumed input. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: batchTxid, + BatchTx: batchBuf.Bytes(), + BatchOutputIndex: 0, + ConfirmationPkScript: confirmationPkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: consumedOp, + Value: inputValueSat, + PkScript: inputPkScript, + }}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // ---------------------------------------------------------------- + // Beat 1: confirm the batch at ONE confirmation -> Provisional + + // Ready -> the VTXO must be ADMITTED into coin selection. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, batchTxid) + + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + t.Logf( + "batch Provisional (1 conf): coin selection admitted %s", + outpoint, + ) + + // Release the reservation so the SAME VTXO is a Live candidate again + // for the reorg beat, and confirm it settled back to Live before + // reorging so the next exclusion is unambiguously the gate's doing. + releaseVTXOToLive(ctx, t, vtxoRef, vtxoStore, outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reorg the batch off-chain (stable ReorgedOut) -> the VTXO + // must be EXCLUDED from coin selection. The replacement branch is + // mined empty so the batch tx is not auto-re-confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg (empty replacement): disconnected=%d connected=%d "+ + "fork_height=%d", len(reorg.Disconnected), + len(reorg.Connected), reorg.ForkPoint.Height, + ) + + awaitBatchState(ctx, t, bcRef, batchTxid, batchcanon.StateReorgedOut) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: f2VTXOAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's batch is "+ + "reorged out: the only candidate is gated out, "+ + "leaving no liquidity", + ) + t.Logf( + "batch ReorgedOut: coin selection correctly excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 3: reconfirm the batch -> Provisional -> the VTXO must be + // ADMITTED again. Mine a normal block so the stranded mempool tx is + // re-included. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, batchTxid) + + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + t.Logf( + "batch reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// assertVTXOSelected asserts that a single SelectAndReserveSpendRequest for the +// VTXO amount succeeds and that the given outpoint is among the reserved VTXOs. +func assertVTXOSelected(ctx context.Context, t *testing.T, + vtxoRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], + want wire.OutPoint) { + + t.Helper() + + admitted := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: f2VTXOAmount, + }).Await(ctx) + require.True( + t, admitted.IsOk(), + "coin selection must succeed while the batch is a ready, "+ + "confirmed member of the canonical chain", + ) + + resp, err := admitted.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + outpoints := make([]wire.OutPoint, 0, len(selected.SelectedVTXOs)) + for _, s := range selected.SelectedVTXOs { + outpoints = append(outpoints, s.Outpoint) + } + require.Contains(t, outpoints, want, "the usable VTXO must be selected") +} + +// releaseVTXOToLive releases a previously reserved VTXO and waits until it has +// durably settled back to LiveState, so the same VTXO can be re-tested by a +// later coin-selection beat without a stale reservation or Spending status +// masking the gate's decision. +// +// The spend reservation is detached (the manager marks the outpoint reserved +// in-memory and hands the FSM event to the child without awaiting its write), +// so the release Ask is retried until the child has settled into SpendingState +// and can accept the release. +func releaseVTXOToLive(ctx context.Context, t *testing.T, + vtxoRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], + vtxoStore *db.VTXOPersistenceStore, op wire.OutPoint) { + + t.Helper() + + require.Eventually(t, func() bool { + resp := vtxoRef.Ask(ctx, &vtxo.ReleaseSpendRequest{ + Outpoints: []wire.OutPoint{op}, + }).Await(ctx) + + return resp.IsOk() + }, f2ChainPollTimeout, f2ChainPollInterval, + "spend reservation never released back to live") + + require.Eventually(t, func() bool { + desc, err := vtxoStore.GetVTXO(ctx, op) + require.NoError(t, err, "load released vtxo status") + + return desc.Status == vtxo.VTXOStatusLive + }, f2ChainPollTimeout, f2ChainPollInterval, + "released VTXO never settled back to Live in the store") +} + +// awaitBatchUsable polls the manager until the batch record is Ready and its +// state confers a usable (provisional or final) lineage availability -- exactly +// the condition the fail-closed coin-selection gate requires to admit a VTXO. +func awaitBatchUsable(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord(ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.Ready() && + batchcanon.AvailabilityForState( + rec.State, + ).Usable() + }, "ready + usable") +} + +// awaitBatchState polls the manager until the batch reaches the wanted state. +func awaitBatchState(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, want batchcanon.State) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord(ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == want + }, "state %v", want) +} + +// awaitBatchRecord polls the batch-canonicality manager's GetBatchStateRequest +// until the returned record satisfies pred, then returns it. It fails the test +// if pred is not satisfied within f2ChainPollTimeout. +func awaitBatchRecord(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, pred func(*batchcanon.Record) bool, + descFormat string, descArgs ...any) *batchcanon.Record { + + t.Helper() + + var last *batchcanon.Record + require.Eventuallyf(t, func() bool { + resp, err := mgrRef.Ask(ctx, &batchcanon.GetBatchStateRequest{ + BatchTxID: txid, + }).Await(ctx).Unpack() + if err != nil { + return false + } + + state, ok := resp.(*batchcanon.GetBatchStateResponse) + if !ok || !state.Found || state.Record == nil { + return false + } + last = state.Record + + return pred(state.Record) + }, f2ChainPollTimeout, f2ChainPollInterval, + "batch %s never reached "+descFormat, + append([]any{txid}, descArgs...)...) + + return last +} + +// seedLiveVTXOForBatch persists a single live VTXO whose lineage is anchored on +// batchTxid, returning its outpoint. It is a focused analogue of the directed- +// send systest's seedLiveVTXO: it writes straight to the provided VTXO store +// (SaveVTXO auto-inserts the backing round row) instead of a daemon DB dir, and +// stamps CommitmentTxID = batchTxid so the canonicality gate governs it by that +// batch. The owner/operator keys and tapscript are real so the descriptor is +// well-formed, but they are never used to sign in this test. +func seedLiveVTXOForBatch(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, batchTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + roundID, err := round.NewRoundID() + require.NoError(t, err, "round id") + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + RoundID: roundID.String(), + CommitmentTxID: batchTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo") + + return outpoint +} From faba0ce1d9101f99ee2be836daa3ae82f8770436 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 08:47:01 -0700 Subject: [PATCH 08/16] harness: add no-broadcast spend and tx-replacing reorg helpers The batch-canonicality conflict systests must double-spend a REAL input of an already-confirmed batch transaction. Only one transaction spending a given outpoint can sit on the canonical chain at a time, so creating the conflict requires reorging the batch tx out and confirming a competing spend of the same input in its place. Add BuildSignedSpendNoBroadcast so the competing double-spend can be signed while the input is still unspent (avoiding a mempool conflict at build time) and mined later, and ReorgReplacingTxs (plus its generateBlockWithTxs primitive) which invalidates the tip and mines a strictly-longer replacement branch whose first block confirms the given transactions against the UTXO set rather than pulling the mempool. Refactor BuildSignedSpend onto a shared buildSignedSpend so the broadcast and no-broadcast variants cannot drift. --- harness/harness.go | 109 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/harness/harness.go b/harness/harness.go index c00a95e79..2f0b24904 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -1766,6 +1766,76 @@ func (h *Harness) generateEmptyBlocks(blocks int) []BlockHeader { return headers } +// ReorgReplacingTxs invalidates the current tip's last depth blocks and mines a +// strictly longer replacement branch whose FIRST block contains exactly txs +// (plus its coinbase) and whose remaining blocks are empty, then waits for the +// primary LND node to resync. newBlocks must be > depth so the new branch +// outweighs the disconnected one. +// +// It models a reorg in which competing transactions replace the disconnected +// branch's transactions on the new best chain -- the only way to make an input +// a confirmed transaction spent become spent by a DIFFERENT transaction. The +// batch-canonicality conflict systests use it to double-spend a batch's +// registered consumed input: the batch tx is reorged out and a conflicting +// spend of the same input confirms in its place, which the manager's per-input +// spend watch classifies as a conflict. +func (h *Harness) ReorgReplacingTxs(depth, newBlocks int, + txs []*wire.MsgTx) ReorgResult { + + h.T.Helper() + + return h.reorgWith( + depth, newBlocks, " (tx-replacement)", + func(n int) []BlockHeader { + headers := make([]BlockHeader, 0, n) + headers = append(headers, h.generateBlockWithTxs(txs)) + if n > 1 { + headers = append( + headers, h.generateEmptyBlocks(n-1)..., + ) + } + + return headers + }, + ) +} + +// generateBlockWithTxs mines a single block containing exactly the given +// transactions (plus the coinbase) via the generateblock RPC. Unlike Generate +// and generateEmptyBlocks it takes explicit transactions rather than pulling +// the mempool, so it can confirm a transaction that conflicts with one already +// sitting in the mempool: generateblock validates the submitted txns against +// the current UTXO set and connects them, evicting any now-double-spent mempool +// tx once the block connects. Returns the new block's header. +func (h *Harness) generateBlockWithTxs(txs []*wire.MsgTx) BlockHeader { + h.T.Helper() + + addr := h.bitcoindGetNewAddress() + + txHexes := make([]string, 0, len(txs)) + for _, tx := range txs { + var buf bytes.Buffer + require.NoError( + h.T, tx.Serialize(&buf), + "serialize tx for block", + ) + txHexes = append(txHexes, hex.EncodeToString(buf.Bytes())) + } + + res, err := h.bitcoinRPCCall("generateblock", addr, txHexes) + require.NoError(h.T, err, "generateblock with txs rpc failed") + + var out struct { + Hash string `json:"hash"` + } + require.NoError( + h.T, json.Unmarshal(res, &out), + "generateblock unmarshal failed", + ) + + return h.BlockHeader(out.Hash) +} + // ReconsiderBlock asks bitcoind to reconsider a previously invalidated block. func (h *Harness) ReconsiderBlock(hash string) { h.T.Helper() @@ -2055,6 +2125,35 @@ func (h *Harness) BuildSignedSpend(op wire.OutPoint, valueBTC float64) ( h.T.Helper() + return h.buildSignedSpend(op, valueBTC, true) +} + +// BuildSignedSpendNoBroadcast builds and signs the same 1-input/1-output +// transaction as BuildSignedSpend but does NOT broadcast it. It lets a test +// prepare a competing double-spend of an outpoint while another transaction +// spending that same outpoint is still in the mempool: broadcasting the second +// spend then would be rejected as a mempool conflict, so instead the caller +// confirms it later by mining it explicitly (e.g. via ReorgReplacingTxs), which +// validates it against the UTXO set rather than the mempool. Because the tx is +// signed while the outpoint is still unspent, sign it BEFORE any spend of the +// same outpoint is broadcast. +func (h *Harness) BuildSignedSpendNoBroadcast(op wire.OutPoint, + valueBTC float64) (*wire.MsgTx, string) { + + h.T.Helper() + + return h.buildSignedSpend(op, valueBTC, false) +} + +// buildSignedSpend is the shared implementation behind BuildSignedSpend and +// BuildSignedSpendNoBroadcast: it builds and signs a 1-input/1-output tx that +// spends op (worth valueBTC) to a fresh wallet address minus a flat fee, +// broadcasting it to the mempool only when broadcast is true. +func (h *Harness) buildSignedSpend(op wire.OutPoint, valueBTC float64, + broadcast bool) (*wire.MsgTx, string) { + + h.T.Helper() + h.bitcoindEnsureWallet() value := btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin) @@ -2123,6 +2222,16 @@ func (h *Harness) BuildSignedSpend(op wire.OutPoint, valueBTC float64) ( "deserialize signed spend tx failed", ) + // When not broadcasting, return the signed tx and its stable hash so + // the caller can mine it later against the UTXO set (bypassing the + // mempool). + if !broadcast { + txid := signed.TxHash().String() + h.Logf("Built (no broadcast) spend of %s in tx %s", op, txid) + + return signed, txid + } + sendRes, err := h.bitcoinRPCCall("sendrawtransaction", signResult.Hex) require.NoError(h.T, err, "sendrawtransaction failed") var txid string From 998c53f32e9d541cd38055a15b01af33dc62e216 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 08:47:10 -0700 Subject: [PATCH 09/16] vtxo: gate coin selection on the full multi-parent lineage The coin-selection reorg-safety gate previously combined availability over a VTXO's direct commitment txid only. A multi-input (OOR-born) VTXO descends from more than one batch, and any single reorged-out or conflict-invalidated parent makes the leaf unspendable, so the gate must reduce over the whole lineage and take the worst state. Add lineageCommitmentTxids, which collects a candidate's direct commitment plus every distinct cross-commitment ancestor recorded in its ancestry, and feed the full set into LineageBlocked (worst-of-N via CombineAvailability). Single-commitment VTXOs yield exactly the previous one-element input, so behaviour is unchanged for them. --- vtxo/manager.go | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/vtxo/manager.go b/vtxo/manager.go index 3946ed036..aa0056425 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -2199,13 +2199,47 @@ func (m *Manager) exactSpendUnavailableError(ctx context.Context, // insufficientLiquidityError distinguishes a true spendable-funds shortfall // from liquidity that is present but unavailable because another operation has // already moved it out of LiveState. +// lineageCommitmentTxids returns the deduped set of commitment txids in a +// VTXO's lineage: its direct commitment tx plus every distinct cross-commitment +// ancestor batch recorded in its ancestry. A round-direct or same-commitment +// OOR VTXO yields one txid; a cross-commitment multi-input OOR VTXO (born from +// a merge that draws inputs from several batches) yields one per contributing +// batch. The direct commitment txid is included even when the ancestry slice is +// empty (e.g. an incoming VTXO materialized without its commitment tree) so the +// gate still governs the leaf by its batch. The zero hash is skipped. +func lineageCommitmentTxids(desc *Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + // gateUnavailableLineage drops candidates whose batch lineage is not usable — // reorged-out (limbo), conflict-invalidated, or (fail-closed) missing, -// reconciling, or unregistered — so a VTXO is never selected while its batch -// is not a ready, confirmed member of the canonical chain. It is a no-op when -// no canonicality store is configured (the gate stays dormant until the batch -// producers register their batches). It reads each candidate's direct -// commitment txid; full multi-parent ancestry gating is a follow-up. +// reconciling, or unregistered — so a VTXO is never selected while ANY batch in +// its lineage is not a ready, confirmed member of the canonical chain. It is a +// no-op when no canonicality store is configured (the gate stays dormant until +// the batch producers register their batches). It gates on the FULL lineage: +// each candidate's direct commitment txid plus every cross-commitment ancestor +// batch, since a multi-input OOR VTXO descends from more than one batch and any +// single reorged-out or invalidated parent makes the leaf unspendable +// (worst-of-N via CombineAvailability). func (m *Manager) gateUnavailableLineage(ctx context.Context, candidates []*Descriptor) ([]*Descriptor, error) { @@ -2222,7 +2256,8 @@ func (m *Manager) gateUnavailableLineage(ctx context.Context, } blocked, avail, err := batchcanon.LineageBlocked( - ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ctx, m.cfg.BatchCanonicality, + lineageCommitmentTxids(desc)..., ) if err != nil { return nil, fmt.Errorf("lineage gate %s: %w", From ac176ce35c614ff0968aa11ec154a0702162ae5d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 08:47:21 -0700 Subject: [PATCH 10/16] systest: prove the conflict-invalidation VTXO gate (F3) Extend the seeded real-chain gate systests to the input-conflict path. A VTXO whose batch confirmed at one confirmation is admitted into coin selection; double-spending one of the batch's registered consumed inputs with a competing transaction drives the batch ConflictProvisional (limbo_conflict) and excludes the VTXO; reorging the conflicting spend away lets the batch reconfirm and re-admits the VTXO; and re-establishing the conflict and maturing it past the reorg-safety depth drives the batch ConflictFinalized (invalidated), excluding the VTXO terminally. This trips the per-input spend watch rather than the batch conf watch that the F2 reorg test exercises. Because the corrected registration API is authenticated (the manager cross-checks the serialized batch tx, its output, and every TxIn), the conflict is a double-spend of a real batch input created via the tx-replacing reorg helpers. --- systest/batch_canonicality_conflict_test.go | 282 ++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 systest/batch_canonicality_conflict_test.go diff --git a/systest/batch_canonicality_conflict_test.go b/systest/batch_canonicality_conflict_test.go new file mode 100644 index 000000000..4827348cc --- /dev/null +++ b/systest/batch_canonicality_conflict_test.go @@ -0,0 +1,282 @@ +//go:build systest + +package systest + +import ( + "bytes" + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/harness" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksConflictedVTXO proves the LIVE coin-selection +// reorg-safety gate handles the INPUT-CONFLICT path end to end (the F3 +// acceptance scenario), driven by a real bitcoind double-spend + reorg: +// +// 1. A VTXO whose batch confirmed at ONE confirmation is usable +// (AvailableProvisional) and admitted into coin selection. +// 2. A competing transaction double-spends one of the batch's registered +// consumed inputs and confirms in its place: the batch becomes +// ConflictProvisional (limbo_conflict) and the VTXO is EXCLUDED. +// 3. Reorging the conflicting spend away lets the batch reconfirm +// (Provisional): the VTXO is ADMITTED again -- the conflict was recoverable +// because it never reached policy finality. +// 4. In a second flow the conflict is re-established and matured PAST the +// reorg-safety depth: the batch reaches ConflictFinalized (Invalidated) and +// the VTXO is EXCLUDED terminally -- it never recovers. +// +// It exercises a DIFFERENT code path from the F2 reorg test +// (TestBatchCanonicalityGateBlocksReorgedVTXO). F2 trips the conf watch +// (LimboReorg) by reorging the batch tx itself off-chain; this test trips the +// per-input SPEND watch (LimboConflict / Invalidated). The manager flags a +// batch conflicting the moment one of its registered consumed inputs is spent +// by a transaction other than the batch itself, clears the conflict when that +// spend reorgs out, and finalizes it once the spend matures past the +// reorg-safety depth. All three are governed by the same fail-closed gate, so +// coin selection admits the VTXO only while its lineage is a ready, confirmed, +// non-conflicted member of the canonical chain. +// +// The corrected registration API is authenticated: the manager cross-checks the +// serialized batch tx (hash == BatchTxID, output pkScript, every TxIn +// registered). A conflict must therefore be a double-spend of a REAL input of +// the batch tx, which the harness cannot create against an already-confirmed +// input without a reorg: the batch is reorged out and a conflicting spend of +// the same input confirms in its place (ReorgReplacingTxs). The competing spend +// is built up front (BuildSignedSpendNoBroadcast) while the input is still +// unspent, so signing succeeds, and mined explicitly against the UTXO set +// rather than broadcast into a mempool conflict. +func TestBatchCanonicalityGateBlocksConflictedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Pick a confirmed wallet outpoint and build BOTH the batch tx (which + // spends it) and a conflicting double-spend of it. The conflicting + // spend is signed now -- while the outpoint is still unspent -- but not + // broadcast, so it can be mined later against the UTXO set without a + // mempool conflict against the batch tx. + consumedOp, valueBTC, inputPkScript := + h.Harness.FirstSpendableOutpoint() + conflictTx, conflictTxidStr := h.Harness.BuildSignedSpendNoBroadcast( + consumedOp, valueBTC, + ) + batchTx, batchTxidStr := h.Harness.BuildSignedSpend( + consumedOp, valueBTC, + ) + require.NotEqual( + t, batchTxidStr, conflictTxidStr, + "batch tx and its conflicting double-spend must differ", + ) + batchTxid := batchTx.TxHash() + + var batchBuf bytes.Buffer + require.NoError(t, batchTx.Serialize(&batchBuf), "serialize batch tx") + + inputValueSat := int64( + btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin), + ) + + // Seed a live VTXO anchored on the batch tx before the manager starts + // so it is recovered into a resident actor. + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), batchTxid, f2VTXOAmount, + ) + + // Real batchcanon.Manager over the durable store + real chainsource. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate on the same store. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f3-conflict" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch with authenticated evidence: the serialized tx, + // its confirmation output, and its exact consumed input (which arms the + // reorg-aware spend watch used to detect the conflict). + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: batchTxid, + BatchTx: batchBuf.Bytes(), + BatchOutputIndex: 0, + ConfirmationPkScript: batchTx.TxOut[0].PkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: consumedOp, + Value: inputValueSat, + PkScript: inputPkScript, + }}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // Anchor a fixed fork point below the batch tx's block so every reorg + // beat can swap the batch tx and its conflicting double-spend across + // the same fork. + forkHeight := h.Harness.BestBlockHeader().Height + + // ---------------------------------------------------------------- + // Beat 1: confirm the batch at ONE confirmation -> Provisional -> the + // VTXO is ADMITTED into coin selection. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, batchTxid) + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + t.Logf( + "batch Provisional (1 conf): coin selection admitted %s", + outpoint, + ) + + releaseVTXOToLive(ctx, t, vtxoRef, vtxoStore, outpoint) + + // ---------------------------------------------------------------- + // Beat 2: double-spend the batch's consumed input -> conflict + // (limbo_conflict) -> the VTXO must be EXCLUDED from coin selection. + // ---------------------------------------------------------------- + reorgToForkHeightWithTx(t, h, forkHeight, conflictTx) + awaitBatchState( + ctx, t, bcRef, batchTxid, batchcanon.StateConflictProvisional, + ) + assertSpendSelectionFails( + ctx, t, vtxoRef, "coin selection must fail while the "+ + "VTXO's batch input is conflicted "+ + "(limbo_conflict): the only candidate is gated out", + ) + t.Logf( + "batch ConflictProvisional: coin selection excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 3: reorg the conflicting spend away -> the batch reconfirms + // (Provisional) -> the VTXO is ADMITTED again. The conflict was + // recoverable because it never reached policy finality. + // ---------------------------------------------------------------- + reorgToForkHeightWithTx(t, h, forkHeight, batchTx) + awaitBatchUsable(ctx, t, bcRef, batchTxid) + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + t.Logf( + "conflict reorged away, batch Provisional: coin selection "+ + "re-admitted %s", outpoint, + ) + + releaseVTXOToLive(ctx, t, vtxoRef, vtxoStore, outpoint) + + // ---------------------------------------------------------------- + // Beat 4: re-establish the conflict, then mature it past the + // reorg-safety depth -> ConflictFinalized (Invalidated) -> the VTXO is + // EXCLUDED terminally and never recovers. + // ---------------------------------------------------------------- + reorgToForkHeightWithTx(t, h, forkHeight, conflictTx) + awaitBatchState( + ctx, t, bcRef, batchTxid, batchcanon.StateConflictProvisional, + ) + + // Mature the conflicting spend past the finality depth so the spend + // Done event synthesizes and the batch reaches ConflictFinalized. A + // margin beyond DefaultFinalityDepth absorbs the height at which the + // spend was re-mined above the fork point. + h.Harness.Generate(int(chainsource.DefaultFinalityDepth) + 2) + awaitBatchState( + ctx, t, bcRef, batchTxid, batchcanon.StateConflictFinalized, + ) + assertSpendSelectionFails( + ctx, t, vtxoRef, "coin selection must fail terminally once "+ + "the batch's input conflict is finalized "+ + "(invalidated): the VTXO never recovers", + ) + t.Logf( + "batch ConflictFinalized: coin selection terminally "+ + "excluded %s", outpoint, + ) +} + +// reorgToForkHeightWithTx reorgs the chain back to forkHeight and mines a +// strictly-longer replacement branch whose first block confirms tx (bypassing +// the mempool). It is the primitive the conflict systests use to alternately +// confirm the batch tx and a conflicting double-spend of the batch's registered +// input across a fixed fork point: because both spend the same outpoint, only a +// reorg can swap which one is on the canonical chain. +func reorgToForkHeightWithTx(t *testing.T, h *SysTestHarness, forkHeight int64, + tx *wire.MsgTx) harness.ReorgResult { + + t.Helper() + + tip := h.Harness.BestBlockHeader().Height + depth := int(tip - forkHeight) + require.Positive( + t, depth, "fork height %d is not below tip %d", forkHeight, tip, + ) + + reorg := h.Harness.ReorgReplacingTxs( + depth, depth+1, []*wire.MsgTx{tx}, + ) + t.Logf( + "reorg to fork height %d (depth %d): disconnected=%d "+ + "connected=%d, confirmed %s", forkHeight, depth, + len(reorg.Disconnected), len(reorg.Connected), tx.TxHash(), + ) + + return reorg +} + +// assertSpendSelectionFails asserts that a single SelectAndReserveSpendRequest +// for the standard test VTXO amount fails, i.e. the sole gated-out candidate +// leaves no spendable liquidity. +func assertSpendSelectionFails(ctx context.Context, t *testing.T, + vtxoRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp], msg string) { + + t.Helper() + + resp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: f2VTXOAmount, + }).Await(ctx) + require.False(t, resp.IsOk(), msg) +} From f0f9ad9c92be76441e69fb1444938428b2238c9a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 08:47:32 -0700 Subject: [PATCH 11/16] systest: prove forfeit restore on batch conflict finality (F6) Extend the seeded real-chain gate systests to the reverse-dependency restore. A VTXO is forfeited into a consumer batch (MarkForfeited stamps the forfeit-consumer marker and business revision the restore CAS keys on), and the consumer batch is registered with an authenticated ConsumerEdge binding that revision plus the complete creator lineage. Maturing a conflicting double-spend of the consumer batch's input past the reorg-safety depth drives it ConflictFinalized, which fires the store's conditional-restore compare-and-swap: the VTXO is atomically restored to Live and re-admitted into coin selection. The test also proves the no-false-restore guard: a consumer batch that only reorgs out and reconfirms (never final) leaves the forfeit standing. --- ...batch_canonicality_forfeit_restore_test.go | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 systest/batch_canonicality_forfeit_restore_test.go diff --git a/systest/batch_canonicality_forfeit_restore_test.go b/systest/batch_canonicality_forfeit_restore_test.go new file mode 100644 index 000000000..4db644f1d --- /dev/null +++ b/systest/batch_canonicality_forfeit_restore_test.go @@ -0,0 +1,434 @@ +//go:build systest + +package systest + +import ( + "bytes" + "context" + "sync" + "testing" + + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityRestoresForfeitedVTXO is the F6 acceptance scenario -- +// the strongest proof that business state tracks chain canonicality rather than +// the reverse. A VTXO forfeited into a consumer batch is restored to a +// spendable state when that batch is invalidated (its forfeit reversed by a +// finalized conflict), driven end to end through real bitcoind + LND. The test +// also proves the NO-false-restore guard: a consumer batch that merely reorgs +// out and reconfirms (never reaching finality) must leave the forfeit standing. +// +// It exercises the reverse-dependency restore wired across two managers on the +// corrected, authenticated API: +// +// batchcanon.Manager (records the ConsumerEdge{ConsumedVTXO, ExpectedRevision, +// CreatorLineage}, detects the finalized conflict invalidating the consumer) -> +// Store.ResolveConsumerEdge: the exact ForfeitedBy compare-and-swap (business +// revision + forfeit-consumer marker + creator lineage usable + no competing +// edge/reservation) atomically restores the VTXO to Live -> +// ActivateRestoredVTXO callback (materialize the Live actor) +// +// The conditional-restore CAS is the safety core: it fires ONLY when the +// consumed VTXO still carries the exact business revision installed by that +// forfeiture, is forfeited by that exact consumer batch, its own creator +// lineage is ready and usable, and no competing consumer or reservation is +// outstanding. +// +// The consumer batch must be authenticated (serialized tx + every real TxIn), +// so invalidating it means double-spending one of its REAL inputs with a +// competing tx that reaches finality. Because an input a confirmed batch spends +// can only become spent by a different tx via a reorg, the conflict is created +// by reorging the batch out and confirming the competing spend in its place +// (reorgToForkHeightWithTx), then maturing that spend past the reorg-safety +// depth. +func TestBatchCanonicalityRestoresForfeitedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real vtxo.Manager first so coin selection is available and the + // store's CAS-restored rows can be re-materialized by selection + // self-heal. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f6-restore" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Real batchcanon.Manager with the restore-activation callback. The + // store CAS is what atomically restores the VTXO row to Live; the + // callback records the activation so the test can assert the restore + // fired. + rec := &forfeitRestoreRecorder{} + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + ActivateRestoredVTXO: rec.record, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // ================================================================ + // Phase 1 -- NO false restore: a consumer batch that only reorgs out + // and reconfirms (never final) must leave its forfeit standing. + // ================================================================ + assertNoFalseRestore(t, h, bcRef, vtxoStore) + + // ================================================================ + // Phase 2 -- real restore: a consumer batch invalidated by a finalized + // conflict must restore its forfeited VTXO to Live. + // ================================================================ + + // A_c: the CREATOR batch that makes the forfeited VTXO exist. Its + // lineage must be ready and usable for the restore CAS to fire, so it + // is a real, registered, confirmed batch. + creatorOp, creatorValueBTC, creatorPkScript := + h.Harness.FirstSpendableOutpoint() + creatorTx, _ := h.Harness.BuildSignedSpend(creatorOp, creatorValueBTC) + creatorTxid := creatorTx.TxHash() + registerRealBatch( + ctx, t, bcRef, creatorTx, creatorOp, creatorPkScript, + int64( + btcutil.Amount( + creatorValueBTC*btcutil.SatoshiPerBitcoin, + ), + ), + ) + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, creatorTxid) + + // B: the CONSUMER batch that forfeits the VTXO. Build both B and a + // conflicting double-spend of its input up front (the conflict is + // signed while the input is still unspent). + consumedOp, consumedValueBTC, consumedPkScript := + h.Harness.FirstSpendableOutpoint() + conflictTx, _ := h.Harness.BuildSignedSpendNoBroadcast( + consumedOp, consumedValueBTC, + ) + consumerTx, _ := h.Harness.BuildSignedSpend( + consumedOp, consumedValueBTC, + ) + consumerTxid := consumerTx.TxHash() + + // Seed the VTXO Live anchored on its creator batch, then forfeit it + // into the consumer batch B: MarkForfeited installs status=Forfeited, + // the forfeit-consumer marker (B), and a fresh business revision -- the + // exact (revision, consumer) pair the restore CAS keys on. + forfeitedOutpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name()+"-restore", creatorTxid, f2VTXOAmount, + ) + forfeitTxid := chainhash.HashH([]byte(t.Name() + "-forfeit-tx")) + require.NoError( + t, vtxoStore.MarkForfeited( + ctx, forfeitedOutpoint, forfeitTxid, consumerTxid, + ), + "forfeit the seeded VTXO into the consumer batch", + ) + + forfeited, err := vtxoStore.GetVTXO(ctx, forfeitedOutpoint) + require.NoError(t, err, "load forfeited vtxo") + require.Equal( + t, vtxo.VTXOStatusForfeited, forfeited.Status, + "precondition: the VTXO must be forfeited", + ) + expectedRevision := forfeited.BusinessRevision + + // Register the consumer batch with the authenticated ConsumerEdge + // binding the exact business revision and complete creator lineage. + registerRealConsumerBatch( + ctx, t, bcRef, consumerTx, consumedOp, consumedPkScript, + int64( + btcutil.Amount( + consumedValueBTC*btcutil.SatoshiPerBitcoin, + ), + ), + []batchcanon.ConsumerEdge{{ + ConsumedVTXO: forfeitedOutpoint, + ConsumerBatch: consumerTxid, + ExpectedRevision: expectedRevision, + CreatorLineage: []chainhash.Hash{creatorTxid}, + }}, + ) + + // Anchor the fork below the consumer batch's block, confirm the + // consumer batch, and double-spend its input to a finalized conflict. + forkHeight := h.Harness.BestBlockHeader().Height + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, consumerTxid) + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "the VTXO must stay forfeited while the consumer batch is "+ + "provisionally canonical", + ) + + reorgToForkHeightWithTx(t, h, forkHeight, conflictTx) + awaitBatchState( + ctx, t, bcRef, consumerTxid, + batchcanon.StateConflictProvisional, + ) + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "a provisional (non-final) conflict must not restore the "+ + "forfeited VTXO", + ) + + // Mature the conflicting spend past the reorg-safety depth: the + // consumer batch is now permanently invalidated, so its forfeit is + // reversed. + h.Harness.Generate(int(chainsource.DefaultFinalityDepth) + 2) + awaitBatchState( + ctx, t, bcRef, consumerTxid, batchcanon.StateConflictFinalized, + ) + + // The forfeit is reversed: the VTXO is restored to Live, its activation + // callback fired, and it is selectable for spending again. + awaitVTXOStatus( + ctx, t, vtxoStore, forfeitedOutpoint, vtxo.VTXOStatusLive, + ) + require.Eventually(t, func() bool { + return rec.contains(forfeitedOutpoint) + }, f2ChainPollTimeout, f2ChainPollInterval, + "the restored VTXO must be activated via ActivateRestoredVTXO") + assertVTXOSelected(ctx, t, vtxoRef, forfeitedOutpoint) + t.Logf( + "consumer ConflictFinalized: forfeited VTXO %s restored to "+ + "Live and re-admitted", forfeitedOutpoint, + ) +} + +// assertNoFalseRestore proves the negative half of the restore lifecycle: a +// consumer batch that reorgs out and then reconfirms (same txid) never reaches +// policy finality, so its forfeit must remain standing throughout. It seeds an +// independent forfeited VTXO, registers + confirms its consumer batch, reorgs +// that batch out (empty replacement, so it is not auto-re-mined) and then +// reconfirms it, asserting the VTXO stays forfeited at every step. +func assertNoFalseRestore(t *testing.T, h *SysTestHarness, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + vtxoStore *db.VTXOPersistenceStore) { + + t.Helper() + + ctx := h.Context() + + consumedOp, valueBTC, pkScript := h.Harness.FirstSpendableOutpoint() + consumerTx, _ := h.Harness.BuildSignedSpend(consumedOp, valueBTC) + consumerTxid := consumerTx.TxHash() + + // A synthetic (unregistered) creator lineage is sufficient here: no + // restore is ever attempted, so the lineage is never consulted. It only + // has to satisfy the non-zero registration validation. + syntheticCreator := chainhash.HashH( + []byte(t.Name() + "-nofalse-creator"), + ) + forfeitedOutpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name()+"-nofalse", syntheticCreator, + f2VTXOAmount, + ) + forfeitTxid := chainhash.HashH([]byte(t.Name() + "-nofalse-forfeit")) + require.NoError( + t, vtxoStore.MarkForfeited( + ctx, forfeitedOutpoint, forfeitTxid, consumerTxid, + ), + "forfeit the seeded VTXO into the consumer batch", + ) + forfeited, err := vtxoStore.GetVTXO(ctx, forfeitedOutpoint) + require.NoError(t, err) + + registerRealConsumerBatch( + ctx, t, bcRef, consumerTx, consumedOp, pkScript, + int64(btcutil.Amount(valueBTC*btcutil.SatoshiPerBitcoin)), + []batchcanon.ConsumerEdge{{ + ConsumedVTXO: forfeitedOutpoint, + ConsumerBatch: consumerTxid, + ExpectedRevision: forfeited.BusinessRevision, + CreatorLineage: []chainhash.Hash{syntheticCreator}, + }}, + ) + + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, consumerTxid) + + // Reorg the consumer batch off-chain (empty replacement so it is not + // re-mined) -> ReorgedOut, then reconfirm it (same txid) -> + // Provisional. Neither transition is terminal, so the forfeit must + // never be reversed. + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + awaitBatchState( + ctx, t, bcRef, consumerTxid, batchcanon.StateReorgedOut, + ) + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "a reorged-out (non-final) consumer batch must not restore "+ + "the forfeited VTXO", + ) + + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, consumerTxid) + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "a reconfirmed (still non-final) consumer batch must not "+ + "restore the forfeited VTXO", + ) + t.Logf( + "consumer reorged out + reconfirmed: forfeited VTXO %s "+ + "stayed forfeited (no false restore)", + forfeitedOutpoint, + ) +} + +// registerRealBatch registers an authenticated batch with a single real +// consumed input and no consumer edges, and asserts the registration succeeded. +func registerRealBatch(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + tx *wire.MsgTx, input wire.OutPoint, inputPkScript []byte, + inputValueSat int64) { + + t.Helper() + + registerRealConsumerBatch( + ctx, t, bcRef, tx, input, inputPkScript, inputValueSat, nil, + ) +} + +// registerRealConsumerBatch registers an authenticated batch (serialized tx, +// its confirmation output, and its single real consumed input) together with +// the given consumer edges, and asserts the registration succeeded. +func registerRealConsumerBatch(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + tx *wire.MsgTx, input wire.OutPoint, inputPkScript []byte, + inputValueSat int64, edges []batchcanon.ConsumerEdge) { + + t.Helper() + + txid := tx.TxHash() + var buf bytes.Buffer + require.NoError(t, tx.Serialize(&buf), "serialize batch tx") + + resp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + BatchTx: buf.Bytes(), + BatchOutputIndex: 0, + ConfirmationPkScript: tx.TxOut[0].PkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: input, + Value: inputValueSat, + PkScript: inputPkScript, + }}, + ConsumedVTXOs: edges, + }).Await(ctx) + require.True(t, resp.IsOk(), "register batch %s", txid) +} + +// vtxoStatus reads a VTXO's persisted status. +func vtxoStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint) vtxo.VTXOStatus { + + t.Helper() + + desc, err := store.GetVTXO(ctx, op) + require.NoError(t, err) + require.NotNil(t, desc) + + return desc.Status +} + +// awaitVTXOStatus polls until a VTXO reaches the wanted persisted status. The +// restore is asynchronous (the canonicality manager resolves the edge and +// invokes the activation callback after the store CAS), so a retry is required. +func awaitVTXOStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint, + want vtxo.VTXOStatus) { + + t.Helper() + + require.Eventuallyf(t, func() bool { + desc, err := store.GetVTXO(ctx, op) + if err != nil || desc == nil { + return false + } + + return desc.Status == want + }, f2ChainPollTimeout, f2ChainPollInterval, + "vtxo %s never reached status %v", op, want) +} + +// forfeitRestoreRecorder captures the VTXO outpoints activated after an atomic +// store restore, so a test can assert the restore-activation callback fired. +type forfeitRestoreRecorder struct { + mu sync.Mutex + restored []wire.OutPoint +} + +// record appends an activated outpoint. It satisfies the +// ManagerConfig.ActivateRestoredVTXO callback signature. +func (r *forfeitRestoreRecorder) record(_ context.Context, + op wire.OutPoint) error { + + r.mu.Lock() + defer r.mu.Unlock() + r.restored = append(r.restored, op) + + return nil +} + +// contains reports whether the given outpoint was activated. +func (r *forfeitRestoreRecorder) contains(op wire.OutPoint) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, restored := range r.restored { + if restored == op { + return true + } + } + + return false +} From 1051b9d9b37b28a2d7494a2fb9fcd7d8e8679740 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 08:47:32 -0700 Subject: [PATCH 12/16] systest: prove the multi-parent lineage reorg VTXO gate (F7) Extend the seeded real-chain gate systests to a multi-input (OOR-born) VTXO whose lineage spans two batches: a direct commitment plus a distinct cross-commitment ancestor. Reorging only the ancestor out excludes the VTXO even while the direct commitment stays confirmed (worst-parent), and reconfirming the ancestor re-admits it. This exercises the full-lineage gate: the two parents are confirmed in distinct blocks so reorging only the tip block cleanly targets the ancestor, making the contrast unambiguous. --- systest/batch_canonicality_multiroot_test.go | 284 +++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 systest/batch_canonicality_multiroot_test.go diff --git a/systest/batch_canonicality_multiroot_test.go b/systest/batch_canonicality_multiroot_test.go new file mode 100644 index 000000000..44457d8ce --- /dev/null +++ b/systest/batch_canonicality_multiroot_test.go @@ -0,0 +1,284 @@ +//go:build systest + +package systest + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksReorgedParent proves the F7 acceptance +// scenario: the coin-selection gate governs a VTXO's FULL multi-parent lineage, +// not just its direct commitment. A multi-input (OOR-born) VTXO descends from +// more than one batch, and the gate combines availability across ALL of them +// (worst-of-N). Reorging ONE parent out therefore excludes the VTXO even while +// every other parent stays confirmed; reconfirming that parent re-admits it. +// +// This is the lineage-BREADTH dimension F2/F3 do not exercise: those use a +// single-commitment VTXO, so they only ever exercise the direct commitment. +// Here the VTXO carries its direct commitment PLUS a distinct cross-commitment +// ancestor batch. The gate (gateUnavailableLineage -> lineageCommitmentTxids) +// reloads the full descriptor, collects both commitment txids, and takes the +// worst state across them (CombineAvailability). A reorged-out ancestor must +// block the VTXO even though the direct commitment is still confirmed; if the +// gate stopped at the direct commitment, the reorged-out ancestor would slip +// through and the VTXO would be wrongly spendable against a lineage no longer +// fully on the canonical chain. +// +// The two parents are isolated into distinct blocks (direct commitment first, +// ancestor second) so reorging ONLY the tip block cleanly targets the ancestor +// and leaves the direct commitment untouched -- making the contrast +// unambiguous: the sole batch that changes state is the ancestor. +func TestBatchCanonicalityGateBlocksReorgedParent(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real batchcanon.Manager over the durable store + real chainsource. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate on the same store. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f7-multiparent" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Confirm the DIRECT commitment first, in its own earlier block, so + // reorging the ancestor's later block leaves it untouched. Each batch + // is a real authenticated tx: build+broadcast -> register -> mine, one + // block apart, so they land in distinct blocks. + directOp, directValueBTC, directPkScript := + h.Harness.FirstSpendableOutpoint() + directTx, _ := h.Harness.BuildSignedSpend(directOp, directValueBTC) + directTxid := directTx.TxHash() + registerRealBatch( + ctx, t, bcRef, directTx, directOp, directPkScript, + int64( + btcutil.Amount( + directValueBTC*btcutil.SatoshiPerBitcoin, + ), + ), + ) + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, directTxid) + + // Now the ANCESTOR, in the next block. + ancestorOp, ancestorValueBTC, ancestorPkScript := + h.Harness.FirstSpendableOutpoint() + ancestorTx, _ := h.Harness.BuildSignedSpend( + ancestorOp, ancestorValueBTC, + ) + ancestorTxid := ancestorTx.TxHash() + registerRealBatch( + ctx, t, bcRef, ancestorTx, ancestorOp, ancestorPkScript, + int64( + btcutil.Amount( + ancestorValueBTC*btcutil.SatoshiPerBitcoin, + ), + ), + ) + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, ancestorTxid) + + // Seed a live VTXO whose direct commitment is directTxid and whose + // ancestry carries ancestorTxid as a distinct cross-commitment parent. + outpoint := seedLiveVTXOWithAncestor( + t, vtxoStore, t.Name(), directTxid, ancestorTxid, f2VTXOAmount, + ) + + // Sanity: with both parents confirmed the VTXO is admitted. + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + releaseVTXOToLive(ctx, t, vtxoRef, vtxoStore, outpoint) + t.Logf("both parents Provisional: coin selection admitted %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 1: reorg ONLY the ancestor off-chain (empty replacement so it is + // not re-mined) -> the VTXO must be EXCLUDED even though its direct + // commitment stays confirmed (worst-parent). + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + + awaitBatchState(ctx, t, bcRef, ancestorTxid, batchcanon.StateReorgedOut) + + // The direct commitment must remain Provisional throughout, so the + // reorged block can only be attributed to the ancestor. + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, directTxid), + "direct commitment must stay confirmed while the ancestor "+ + "is reorged out", + ) + assertSpendSelectionFails( + ctx, t, vtxoRef, "coin selection must fail while the "+ + "VTXO's ANCESTOR parent is reorged out, even "+ + "though its direct commitment is still confirmed "+ + "(worst-parent)", + ) + t.Logf("ancestor ReorgedOut: coin selection excluded %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm the ancestor -> the whole lineage is canonical + // again, so the VTXO must be ADMITTED. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchUsable(ctx, t, bcRef, ancestorTxid) + assertVTXOSelected(ctx, t, vtxoRef, outpoint) + t.Logf( + "ancestor reconfirmed Provisional: coin selection "+ + "re-admitted %s", outpoint, + ) +} + +// batchState reads a batch's current canonicality state via the manager. +func batchState(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) batchcanon.State { + + t.Helper() + + resp, err := bcRef.Ask( + ctx, &batchcanon.GetBatchStateRequest{BatchTxID: txid}, + ).Await(ctx).Unpack() + require.NoError(t, err) + got, ok := resp.(*batchcanon.GetBatchStateResponse) + require.True(t, ok, "unexpected get-state response type %T", resp) + require.True(t, got.Found, "batch %s not found", txid) + + return got.Record.State +} + +// seedLiveVTXOWithAncestor persists a single live VTXO whose direct commitment +// is directTxid and whose ancestry carries ancestorTxid as a distinct +// cross-commitment parent, returning its outpoint. It is the multi-parent +// analogue of seedLiveVTXOForBatch: the owner/operator keys and tapscript are +// real so the descriptor is well-formed, while the ancestry tree fragment is a +// minimal placeholder (the canonicality gate reads only the commitment txids). +func seedLiveVTXOWithAncestor(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, directTxid, ancestorTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + roundID, err := round.NewRoundID() + require.NoError(t, err, "round id") + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + // Minimal ancestry tree fragment; only the CommitmentTxID is consulted + // by the canonicality gate. + ancestorTree := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + Ancestry: []types.Ancestry{{ + TreePath: ancestorTree, + CommitmentTxID: ancestorTxid, + TreeDepth: 0, + }}, + RoundID: roundID.String(), + CommitmentTxID: directTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo with ancestor") + + return outpoint +} From 7ca0718886ce4cd571eaab6458ae141ed48a6e33 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Fri, 17 Jul 2026 15:56:59 -0700 Subject: [PATCH 13/16] batchcanon: Serialize startup reconciliation Fail closed when lineage storage errors so admission callers never see a false unblock signal alongside the error. Protect startup reconciliation with the same mutex as actor delivery because the actor is live before the daemon invokes Reconcile. --- batchcanon/availability.go | 2 +- batchcanon/availability_test.go | 25 +++++++++++++++++++++++++ batchcanon/manager.go | 18 ++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/batchcanon/availability.go b/batchcanon/availability.go index 7ec4a97ab..371d61661 100644 --- a/batchcanon/availability.go +++ b/batchcanon/availability.go @@ -210,7 +210,7 @@ func LineageBlocked(ctx context.Context, store Reader, avail, err := LineageAvailability(ctx, store, batchTxids...) if err != nil { - return false, avail, err + return true, avail, err } return !avail.Usable(), avail, nil diff --git a/batchcanon/availability_test.go b/batchcanon/availability_test.go index 7db72bbf3..3d67766eb 100644 --- a/batchcanon/availability_test.go +++ b/batchcanon/availability_test.go @@ -1,6 +1,8 @@ package batchcanon import ( + "context" + "errors" "testing" "github.com/btcsuite/btcd/chainhash/v2" @@ -8,6 +10,16 @@ import ( "github.com/stretchr/testify/require" ) +type failingReader struct { + err error +} + +func (r failingReader) GetBatch(context.Context, chainhash.Hash) (*Record, + error) { + + return nil, r.err +} + // TestAvailabilityForState pins the State -> Availability mapping. func TestAvailabilityForState(t *testing.T) { t.Parallel() @@ -205,4 +217,17 @@ func TestLineageAvailabilityFromStore(t *testing.T) { blocked, _, err = LineageBlocked(ctx, store) require.NoError(t, err) require.True(t, blocked) + + // Store failures cannot open the gate even when callers also inspect + // the returned error. + storeErr := errors.New("store unavailable") + blocked, avail, err = LineageBlocked( + ctx, failingReader{ + err: storeErr, + }, + finalTx, + ) + require.ErrorIs(t, err, storeErr) + require.True(t, blocked) + require.Equal(t, AvailabilityUnknown, avail) } diff --git a/batchcanon/manager.go b/batchcanon/manager.go index e60a1fa94..24aebf845 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -131,6 +132,12 @@ type ManagerConfig struct { // authenticated prevout pkScript, which lnd's notifier requires. Incomplete // evidence fails the whole registration closed. type Manager struct { + // mu serializes direct startup reconciliation with actor mailbox work. + // Reconcile is intentionally called by the daemon after actor + // registration so watches are live before startup completes, while + // Receive may already be processing queued chain observations. + mu sync.Mutex + cfg ManagerConfig log btclog.Logger selfRef actor.TellOnlyRef[ManagerMsg] @@ -155,11 +162,15 @@ func (m *Manager) SetSelfRef(ref actor.TellOnlyRef[ManagerMsg]) { m.selfRef = ref } -// Receive implements actor.ActorBehavior. It serializes all canonicality -// mutations through the single actor mailbox. +// Receive implements actor.ActorBehavior. It serializes canonicality +// mutations with both the single actor mailbox and direct startup +// reconciliation. func (m *Manager) Receive(ctx context.Context, msg ManagerMsg) fn.Result[ManagerResp] { + m.mu.Lock() + defer m.mu.Unlock() + switch v := msg.(type) { case *RegisterBatchRequest: return m.handleRegisterBatch(ctx, v) @@ -1344,6 +1355,9 @@ func watchInputs(w *batchWatch) []ConsumedInput { // live re-observation does not transiently downgrade a persisted conflict or // finalized state. It must run after SetSelfRef. func (m *Manager) Reconcile(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + // Non-final states whose watches must be re-armed. Finalized and // conflict_finalized batches need no further watching. live := []State{ From 38a245970e570f197972f2f5495f5f7f7bd983b9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 20 Jul 2026 13:00:34 -0700 Subject: [PATCH 14/16] batchcanon: Persist watch scan height Capture the pre-confirmation height supplied by batch producers and reuse it for initial and restart watch registration. This prevents delayed light-client watch installation from starting above a confirmation that already raced onto the chain. --- batchcanon/manager.go | 34 ++++--------------- batchcanon/manager_test.go | 30 ++++++++++++++++ batchcanon/messages.go | 6 ++++ batchcanon/record.go | 6 ++++ db/batch_canonicality_store.go | 6 ++++ db/batch_canonicality_store_test.go | 2 ++ db/sqlc/batch_canonicality.sql.go | 20 ++++++++--- .../000016_batch_canonicality.up.sql | 7 ++++ db/sqlc/models.go | 1 + db/sqlc/queries/batch_canonicality.sql | 15 +++++--- db/sqlc/schemas/generated_schema.sql | 7 ++++ 11 files changed, 96 insertions(+), 38 deletions(-) diff --git a/batchcanon/manager.go b/batchcanon/manager.go index 24aebf845..230325c42 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -74,8 +74,9 @@ type inputWatch struct { // batchWatch is the manager's in-memory state for one watched batch. type batchWatch struct { - txid chainhash.Hash - pkScript []byte + txid chainhash.Hash + pkScript []byte + heightHint uint32 conf confState inputs map[wire.OutPoint]*inputWatch @@ -253,6 +254,7 @@ func (m *Manager) handleRegisterBatch(ctx context.Context, CSVExpiryDelta: req.CSVExpiryDelta, PolicyState: PolicyStateDefault, ConfirmationPkScript: req.ConfirmationPkScript, + WatchHeightHint: req.WatchHeightHint, ConsumedInputs: req.ConsumedInputs, DependentVTXOs: req.DependentVTXOs, } @@ -445,7 +447,7 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, } } - heightHint := m.bestHeightHint(ctx) + heightHint := w.heightHint confReq := &chainsource.RegisterConfRequest{ CallerID: confCallerID(w.txid), @@ -634,31 +636,6 @@ func (m *Manager) releaseWatchSet(ctx context.Context, w *batchWatch, } } -// bestHeightHint asks chainsource for the current best height to use as a -// watch height hint. On error it returns 0 (scan from the backend's default), -// logging the failure rather than aborting registration. -func (m *Manager) bestHeightHint(ctx context.Context) uint32 { - resp, err := m.cfg.ChainSource.Ask( - ctx, &chainsource.BestHeightRequest{}, - ).Await(ctx).Unpack() - if err != nil { - m.logger(ctx).WarnS(ctx, "Batch canonicality best-height "+ - "query failed; using zero height hint", err) - - return 0 - } - - height, ok := resp.(*chainsource.BestHeightResponse) - if !ok { - return 0 - } - if height.Height < 0 { - return 0 - } - - return uint32(height.Height) -} - // handleGetBatchState serves a read of the persisted canonicality record. func (m *Manager) handleGetBatchState(ctx context.Context, req *GetBatchStateRequest) fn.Result[ManagerResp] { @@ -1444,6 +1421,7 @@ func watchFromRecord(record *Record) *batchWatch { w := &batchWatch{ txid: record.BatchTxID, pkScript: record.ConfirmationPkScript, + heightHint: record.WatchHeightHint, inputs: make(map[wire.OutPoint]*inputWatch), confHeight: record.ConfirmationHeight, confBlock: record.ConfirmationBlock, diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go index 5782ff023..5b1fad50c 100644 --- a/batchcanon/manager_test.go +++ b/batchcanon/manager_test.go @@ -533,6 +533,8 @@ type mockChainSource struct { bestHeight int32 confByTxid map[chainhash.Hash]confRefs spendByOp map[wire.OutPoint]map[string]spendRefs + confHints map[chainhash.Hash]uint32 + spendHints map[wire.OutPoint]uint32 confCancels map[chainhash.Hash]int spendCancel map[wire.OutPoint]int } @@ -542,6 +544,8 @@ func newMockChainSource(bestHeight int32) *mockChainSource { bestHeight: bestHeight, confByTxid: make(map[chainhash.Hash]confRefs), spendByOp: make(map[wire.OutPoint]map[string]spendRefs), + confHints: make(map[chainhash.Hash]uint32), + spendHints: make(map[wire.OutPoint]uint32), confCancels: make(map[chainhash.Hash]int), spendCancel: make(map[wire.OutPoint]int), } @@ -564,6 +568,7 @@ func (c *mockChainSource) Receive(_ context.Context, case *chainsource.RegisterConfRequest: c.mu.Lock() + c.confHints[*v.Txid] = v.HeightHint c.confByTxid[*v.Txid] = confRefs{ confirmed: v.NotifyActor.UnwrapOr(nil), reorged: v.NotifyReorged.UnwrapOr(nil), @@ -577,6 +582,7 @@ func (c *mockChainSource) Receive(_ context.Context, case *chainsource.RegisterSpendRequest: c.mu.Lock() + c.spendHints[*v.Outpoint] = v.HeightHint registrations, ok := c.spendByOp[*v.Outpoint] if !ok { registrations = make(map[string]spendRefs) @@ -912,6 +918,30 @@ func TestManagerConfirmThenFinalize(t *testing.T) { require.Equal(t, StateFinalized, got.Record.State) } +// TestManagerUsesDurableWatchHeightHint proves registration does not replace +// the producer's pre-broadcast scan point with the current tip. Doing so can +// miss a confirmation that raced ahead of watch installation. +func TestManagerUsesDurableWatchHeightHint(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xab) + input := testOutpoint(0xbc, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + WatchHeightHint: 77, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + record := h.state(t, txid).Record + + h.mock.mu.Lock() + defer h.mock.mu.Unlock() + require.Equal(t, uint32(77), h.mock.confHints[txid]) + require.Equal(t, uint32(77), h.mock.spendHints[input]) + require.Equal(t, uint32(77), record.WatchHeightHint) +} + // TestManagerReorgRecovers proves the core reorg-safety property: a confirmed // batch that is reorged out moves to reorged_out (with expiry erased), then // recovers to provisional on reconfirmation at a new height (with a fresh diff --git a/batchcanon/messages.go b/batchcanon/messages.go index b90f8b65d..31faefd8e 100644 --- a/batchcanon/messages.go +++ b/batchcanon/messages.go @@ -47,6 +47,12 @@ type RegisterBatchRequest struct { // persisted for restart re-registration. ConfirmationPkScript []byte + // WatchHeightHint is the best-chain height from before the batch could + // have confirmed. It is persisted so both initial registration and + // restart reconciliation scan across confirmations that raced ahead of + // watch installation. + WatchHeightHint uint32 + // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. CSVExpiryDelta int32 diff --git a/batchcanon/record.go b/batchcanon/record.go index e4ca57523..561531334 100644 --- a/batchcanon/record.go +++ b/batchcanon/record.go @@ -70,6 +70,12 @@ type Record struct { // batch-output pkScript to derive. ConfirmationPkScript []byte + // WatchHeightHint is the earliest height from which confirmation and + // spend watches must scan. It is captured before the batch can confirm + // and retained across restarts so delayed watch installation cannot + // miss already-mined evidence. + WatchHeightHint uint32 + // PolicyState is the reserved policy classification slot. See // PolicyState. PolicyState PolicyState diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index 845222406..0d80ef7cb 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -490,6 +490,7 @@ func replaceBatchRecord(ctx context.Context, q BatchCanonicalityStore, CsvExpiryDelta: record.CSVExpiryDelta, PolicyState: int32(record.PolicyState), ConfirmationPkScript: record.ConfirmationPkScript, + WatchHeightHint: int64(record.WatchHeightHint), CreatedAt: now, UpdatedAt: now, }, @@ -545,6 +546,10 @@ func registrationMatches(existing, next *batchcanon.Record) error { return fmt.Errorf("%w: confirmation script changed", batchcanon.ErrRegistrationConflict) + case existing.WatchHeightHint != next.WatchHeightHint: + return fmt.Errorf("%w: watch height hint changed", + batchcanon.ErrRegistrationConflict) + case existing.CSVExpiryDelta != next.CSVExpiryDelta: return fmt.Errorf("%w: csv expiry changed", batchcanon.ErrRegistrationConflict) @@ -1343,6 +1348,7 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, CSVExpiryDelta: row.CsvExpiryDelta, PolicyState: batchcanon.PolicyState(row.PolicyState), ConfirmationPkScript: row.ConfirmationPkScript, + WatchHeightHint: uint32(row.WatchHeightHint), ConsumedInputs: inputs, DependentVTXOs: deps, }, nil diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go index da750990c..aad549b04 100644 --- a/db/batch_canonicality_store_test.go +++ b/db/batch_canonicality_store_test.go @@ -246,6 +246,7 @@ func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), CSVExpiryDelta: 144, PolicyState: batchcanon.PolicyStateDefault, + WatchHeightHint: 77, ConsumedInputs: []batchcanon.ConsumedInput{ consumedInput(outpoint(0x01, 0)), consumedInput(outpoint(0x02, 3)), @@ -266,6 +267,7 @@ func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { require.True(t, got.ConfirmationBlock.IsSome()) require.Equal(t, int32(144), got.CSVExpiryDelta) require.Equal(t, batchcanon.PolicyStateDefault, got.PolicyState) + require.Equal(t, uint32(77), got.WatchHeightHint) require.ElementsMatch(t, rec.ConsumedInputs, got.ConsumedInputs) require.ElementsMatch(t, rec.DependentVTXOs, got.DependentVTXOs) diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go index 5f411c8da..1682274b3 100644 --- a/db/sqlc/batch_canonicality.sql.go +++ b/db/sqlc/batch_canonicality.sql.go @@ -67,7 +67,8 @@ WHERE batch_txid = $1 RETURNING batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint ` type BeginBatchCanonicalityReconcileParams struct { @@ -96,6 +97,7 @@ func (q *Queries) BeginBatchCanonicalityReconcile(ctx context.Context, arg Begin &i.CreatedAt, &i.UpdatedAt, &i.ConfirmationPkScript, + &i.WatchHeightHint, ) return i, err } @@ -220,7 +222,8 @@ const GetBatchCanonicality = `-- name: GetBatchCanonicality :one SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint FROM batch_canonicality WHERE batch_txid = $1 ` @@ -246,6 +249,7 @@ func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (B &i.CreatedAt, &i.UpdatedAt, &i.ConfirmationPkScript, + &i.WatchHeightHint, ) return i, err } @@ -383,7 +387,8 @@ const ListBatchCanonicalityByState = `-- name: ListBatchCanonicalityByState :man SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint FROM batch_canonicality WHERE state = $1 ` @@ -415,6 +420,7 @@ func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) &i.CreatedAt, &i.UpdatedAt, &i.ConfirmationPkScript, + &i.WatchHeightHint, ); err != nil { return nil, err } @@ -869,10 +875,11 @@ INSERT INTO batch_canonicality ( batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15 + $15, $16 ) ON CONFLICT (batch_txid) DO UPDATE SET batch_tx = EXCLUDED.batch_tx, @@ -887,6 +894,7 @@ ON CONFLICT (batch_txid) DO UPDATE SET csv_expiry_delta = EXCLUDED.csv_expiry_delta, policy_state = EXCLUDED.policy_state, confirmation_pk_script = EXCLUDED.confirmation_pk_script, + watch_height_hint = EXCLUDED.watch_height_hint, updated_at = EXCLUDED.updated_at ` @@ -906,6 +914,7 @@ type UpsertBatchCanonicalityParams struct { CreatedAt int64 UpdatedAt int64 ConfirmationPkScript []byte + WatchHeightHint int64 } // Batch canonicality queries. @@ -933,6 +942,7 @@ func (q *Queries) UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCa arg.CreatedAt, arg.UpdatedAt, arg.ConfirmationPkScript, + arg.WatchHeightHint, ) return err } diff --git a/db/sqlc/migrations/000016_batch_canonicality.up.sql b/db/sqlc/migrations/000016_batch_canonicality.up.sql index 07f9b3caf..9905e6039 100644 --- a/db/sqlc/migrations/000016_batch_canonicality.up.sql +++ b/db/sqlc/migrations/000016_batch_canonicality.up.sql @@ -98,6 +98,13 @@ CREATE TABLE IF NOT EXISTS batch_canonicality ( -- the generated model column order matches the query/store code. confirmation_pk_script BLOB, + -- watch_height_hint is captured before the batch can confirm. Reusing + -- it after delayed registration or restart prevents light-client + -- backends from starting their historical scan above an already-mined + -- confirmation. + watch_height_hint BIGINT NOT NULL DEFAULT 0 + CHECK (watch_height_hint >= 0), + CHECK ((batch_tx IS NULL AND batch_output_index IS NULL) OR (batch_tx IS NOT NULL AND batch_output_index IS NOT NULL)), diff --git a/db/sqlc/models.go b/db/sqlc/models.go index ebdd3d0d7..9e1e93bb4 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -77,6 +77,7 @@ type BatchCanonicality struct { CreatedAt int64 UpdatedAt int64 ConfirmationPkScript []byte + WatchHeightHint int64 } type BatchConsumedInput struct { diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql index a06f96db8..3106c7bba 100644 --- a/db/sqlc/queries/batch_canonicality.sql +++ b/db/sqlc/queries/batch_canonicality.sql @@ -12,10 +12,11 @@ INSERT INTO batch_canonicality ( batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15 + $15, $16 ) ON CONFLICT (batch_txid) DO UPDATE SET batch_tx = EXCLUDED.batch_tx, @@ -30,6 +31,7 @@ ON CONFLICT (batch_txid) DO UPDATE SET csv_expiry_delta = EXCLUDED.csv_expiry_delta, policy_state = EXCLUDED.policy_state, confirmation_pk_script = EXCLUDED.confirmation_pk_script, + watch_height_hint = EXCLUDED.watch_height_hint, updated_at = EXCLUDED.updated_at; -- name: GetBatchCanonicality :one @@ -38,7 +40,8 @@ ON CONFLICT (batch_txid) DO UPDATE SET SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint FROM batch_canonicality WHERE batch_txid = $1; @@ -48,7 +51,8 @@ WHERE batch_txid = $1; SELECT batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint FROM batch_canonicality WHERE state = $1; @@ -65,7 +69,8 @@ WHERE batch_txid = $1 RETURNING batch_txid, batch_tx, batch_output_index, state, registration_stage, observation_generation, ready_generation, revision, confirmation_height, confirmation_block_hash, csv_expiry_delta, - policy_state, created_at, updated_at, confirmation_pk_script; + policy_state, created_at, updated_at, confirmation_pk_script, + watch_height_hint; -- name: MarkBatchCanonicalityReady :execrows -- MarkBatchCanonicalityReady opens admission only for the generation whose diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index fa4ce74cd..967b328d2 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -178,6 +178,13 @@ CREATE TABLE batch_canonicality ( -- the generated model column order matches the query/store code. confirmation_pk_script BLOB, + -- watch_height_hint is captured before the batch can confirm. Reusing + -- it after delayed registration or restart prevents light-client + -- backends from starting their historical scan above an already-mined + -- confirmation. + watch_height_hint BIGINT NOT NULL DEFAULT 0 + CHECK (watch_height_hint >= 0), + CHECK ((batch_tx IS NULL AND batch_output_index IS NULL) OR (batch_tx IS NOT NULL AND batch_output_index IS NOT NULL)), From 2a7bc9338a3c44e2593433f27971249974f33784 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 30 Jul 2026 11:38:59 -0700 Subject: [PATCH 15/16] batchcanon: Retain conservative watch hint Allow repeated registrations to report a different observation height. A reconfirmation can legitimately move that value, while retaining the original lower scan point remains safe and avoids quarantining otherwise identical evidence. Clamp the hint to a floor of 1 before arming any watch. A chain notifier rejects a height hint of 0 ("a height hint greater than 0 must be provided"), and a persisted hint can legitimately resolve to 0 on a node whose round FSM was created at genesis height on a fresh chain. Without the floor the confirmation and spend watches never arm, so a fail-closed batch never becomes usable and round confirmation stalls. --- batchcanon/manager.go | 16 ++++++++++++++++ db/batch_canonicality_store.go | 4 ---- db/batch_canonicality_store_test.go | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/batchcanon/manager.go b/batchcanon/manager.go index 230325c42..72ceeec9d 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -30,6 +30,17 @@ var ManagerServiceKey = actor.NewServiceKey[ManagerMsg, ManagerResp]( // signalled separately by chainsource's Done event at its FinalityDepth. const usabilityConfs uint32 = 1 +// minWatchHeightHint is the lowest block-height hint a chain notifier will +// accept: a registration with a hint of 0 is rejected outright ("a height hint +// greater than 0 must be provided"), which would leave the batch permanently +// unwatched and, because admission is fail-closed, never usable. A batch's +// persisted WatchHeightHint can legitimately resolve to 0 (for example a round +// FSM created at genesis height on a fresh regtest chain), so every watch +// registration clamps its hint to this floor. A lower hint only widens the +// notifier's scan window; it never skips the confirmation the watch exists to +// observe, so clamping up is always safe. +const minWatchHeightHint uint32 = 1 + // confState is the manager's in-memory view of a batch tx's confirmation // observation, distinct from any input-conflict view. type confState int @@ -447,7 +458,12 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, } } + // Clamp the persisted hint to the notifier's accepted floor so a 0 hint + // can never abort watch arming (see minWatchHeightHint). heightHint := w.heightHint + if heightHint < minWatchHeightHint { + heightHint = minWatchHeightHint + } confReq := &chainsource.RegisterConfRequest{ CallerID: confCallerID(w.txid), diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index 0d80ef7cb..de1c07150 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -546,10 +546,6 @@ func registrationMatches(existing, next *batchcanon.Record) error { return fmt.Errorf("%w: confirmation script changed", batchcanon.ErrRegistrationConflict) - case existing.WatchHeightHint != next.WatchHeightHint: - return fmt.Errorf("%w: watch height hint changed", - batchcanon.ErrRegistrationConflict) - case existing.CSVExpiryDelta != next.CSVExpiryDelta: return fmt.Errorf("%w: csv expiry changed", batchcanon.ErrRegistrationConflict) diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go index aad549b04..42b2eaf46 100644 --- a/db/batch_canonicality_store_test.go +++ b/db/batch_canonicality_store_test.go @@ -695,6 +695,7 @@ func TestBatchRegistrationIsAtomicAndImmutable(t *testing.T) { ObservationGeneration: 1, State: batchcanon.StateUnseen, CSVExpiryDelta: 144, + WatchHeightHint: 90, ConfirmationPkScript: []byte{ 0x51, 0x20, @@ -724,6 +725,10 @@ func TestBatchRegistrationIsAtomicAndImmutable(t *testing.T) { // An idempotent retry may add dependents and consumer edges, but it // carries the same immutable output/input evidence. retry := *record + // A reconfirmed indexer observation can report a later height. The + // original lower scan point remains conservative and must not turn this + // otherwise-identical registration into contradictory evidence. + retry.WatchHeightHint = 105 retry.DependentVTXOs = []wire.OutPoint{secondDependent} require.NoError( t, @@ -737,6 +742,7 @@ func TestBatchRegistrationIsAtomicAndImmutable(t *testing.T) { got, err := store.GetBatch(ctx, txid) require.NoError(t, err) require.True(t, inputFlags(t, got, input.Outpoint).Conflicting) + require.Equal(t, uint32(90), got.WatchHeightHint) require.ElementsMatch( t, []wire.OutPoint{firstDependent, secondDependent}, got.DependentVTXOs, From 6b3483a75899054a1a8339894e945fcaad38697a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 5 Aug 2026 12:10:17 -0700 Subject: [PATCH 16/16] db: renumber batch-canonicality migration after main's sweep-delay main added 000016_round_sweep_delay while this stack was open, which collides with this stack's 000016_batch_canonicality. Renumber the latter to 000017 so migration versions stay unique. The consolidated schema is unchanged: the two migrations touch independent tables. --- db/migrations.go | 2 +- ...canonicality.down.sql => 000017_batch_canonicality.down.sql} | 0 ...tch_canonicality.up.sql => 000017_batch_canonicality.up.sql} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename db/sqlc/migrations/{000016_batch_canonicality.down.sql => 000017_batch_canonicality.down.sql} (100%) rename db/sqlc/migrations/{000016_batch_canonicality.up.sql => 000017_batch_canonicality.up.sql} (100%) diff --git a/db/migrations.go b/db/migrations.go index b9c7f13b7..4eb44a37c 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -10,7 +10,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion uint = 16 + LatestMigrationVersion uint = 17 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/db/sqlc/migrations/000016_batch_canonicality.down.sql b/db/sqlc/migrations/000017_batch_canonicality.down.sql similarity index 100% rename from db/sqlc/migrations/000016_batch_canonicality.down.sql rename to db/sqlc/migrations/000017_batch_canonicality.down.sql diff --git a/db/sqlc/migrations/000016_batch_canonicality.up.sql b/db/sqlc/migrations/000017_batch_canonicality.up.sql similarity index 100% rename from db/sqlc/migrations/000016_batch_canonicality.up.sql rename to db/sqlc/migrations/000017_batch_canonicality.up.sql