Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fd00991
fix(blockassembly): mark only moved-back txs not on the longest chain…
icellan Oct 26, 2026
26a1e3f
fix(blockvalidation): repair overtaken tips on the setMined worker
icellan Oct 26, 2026
116eed1
fix(test): data race in the memory blob store and three order- or tim…
icellan Oct 26, 2026
401a1c7
test(settings): count the tipRepairSliceSize row in the blockvalidati…
icellan Oct 27, 2026
aa08cf6
fix(blockvalidation): repair a fork block whose setTxMined failed par…
icellan Oct 27, 2026
70ab68c
fix(blockvalidation): harden the overtaken-tip repair's subtree reads…
icellan Oct 27, 2026
d8fb981
fix(blockvalidation): repair partially written blocks, retry transien…
icellan Oct 27, 2026
34e140f
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 27, 2026
fcc3997
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 28, 2026
a4cfefa
fix(blockvalidation): retry a subtree whose reads stay short instead …
icellan Oct 28, 2026
8bc6322
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 28, 2026
728aa73
fix(blockvalidation): skip a subtree that stays corrupt after max fai…
icellan Oct 28, 2026
bbff774
test(blockvalidation): a corrupt subtree does not block the overtaken…
icellan Oct 28, 2026
ad209f0
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 28, 2026
8c5f363
fix(blockvalidation): set an unreadable subtree aside for a later rep…
icellan Oct 28, 2026
e8bbca9
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 28, 2026
f7899cb
test(blockvalidation): retry a SetMinedMulti the sqlitememory engine …
icellan Oct 28, 2026
928aadf
fix(utxo/sql): retry a SetMinedMulti chunk the database refuses with …
icellan Oct 28, 2026
6bb7935
fix(blockvalidation): back off by best height between passes over set…
icellan Oct 28, 2026
57de33a
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 28, 2026
34df1ef
fix(utxo/sql): recognise a wrapped PostgreSQL deadlock, test the SetM…
icellan Oct 29, 2026
a3b25e9
fix(blockvalidation): back off by time, not best height, between pass…
icellan Oct 29, 2026
c719b54
fix(blockvalidation): pass over set-aside subtrees on every new block…
icellan Oct 29, 2026
b5c4c63
fix(blockvalidation): never treat a storage error as a missing subtre…
icellan Oct 29, 2026
e9d2b81
fix(blockvalidation): clear deferred when a block is back on the chai…
icellan Oct 29, 2026
42fbb9a
fix(blockvalidation): repair finished overtaken blocks again after a …
icellan Oct 29, 2026
1c212f2
refactor(utxo/sql): one lock-error retry helper for reads and writes;…
icellan Oct 29, 2026
aa96a33
fix(blockvalidation): track blocks a reorg brings onto the chain, bac…
icellan Oct 29, 2026
268b5fd
fix(blockvalidation): track the main chain in the same read that cach…
icellan Oct 29, 2026
e4b5743
fix(blockvalidation): end the back-off when an unmark pass finishes t…
icellan Oct 29, 2026
e6edb00
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 29, 2026
373f16d
fix(blockvalidation): finish the due overtaken-tip repair before sett…
icellan Oct 29, 2026
fa5a02f
fix(blockvalidation): page the restart seed past full heights, keep a…
icellan Oct 29, 2026
e89203c
Merge remote-tracking branch 'origin/main' into claude-pr-2065
icellan Oct 29, 2026
1b79483
test(settings): count the blockvalidation settings row main and this …
icellan Oct 29, 2026
835b93b
fix(pruner): hold all deletion while an overtaken-tip repair is unfin…
icellan Oct 29, 2026
9e2f02c
fix(pruner): hold for a gone subtree too, re-check the hold before ph…
icellan Oct 29, 2026
ec1f90f
fix(blockvalidation): give up a subtree that is gone instead of holdi…
icellan Oct 29, 2026
f4f7b73
fix(model): never write an empty overtaken-tip repair hold, it reache…
icellan Oct 29, 2026
4c65819
fix(blockchain): return a not-found error for a missing state key, so…
icellan Oct 29, 2026
33bbf8c
docs: correct what the CHANGELOG says about gone blocks and the not-f…
icellan Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ Upgrading from v0.16.0 changes nothing for them; upgrading from an earlier relea

### Breaking — behaviour changes on upgrade

- **A reorg no longer re-marks block assembly's whole mempool.** Block assembly marks
only the transactions it moved back and that are not mined on the new chain. Block
validation now repairs a block it marked mined that a competing chain later overtakes,
on its setMined worker, in slices of `blockvalidation_tipRepairSliceSize` transactions
(new setting, default 250000). Each slice can delay the next block's mined status by
one slice. The repair assumes a single block validation instance writes mined status
for the chain, and its state is in memory: fork blocks more than 10 below the tip at a
restart are not repaired. A deployment that runs more than one block validation
replica loses no data, but transactions can stay flagged unmined until block
assembly's next unmined load. No action needed for a single-instance deployment. (#2065)
- **Catch-up quick validation waits for each block's `.subtree` writes before it commits
the block, and a shutdown waits for a block's in-flight commit.** A block was stored with
`subtrees_set` while its `.subtree` files could still be in flight, so a failed write
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ loader (0, false and false), so for these keys the two agree. The
| MaxBlocksBehindBlockAssembly | int | 20 | blockvalidation_maxBlocksBehindBlockAssembly | **CRITICAL** - Max blocks behind block assembly |
| PeriodicProcessingInterval | time.Duration | 1m | blockvalidation_periodic_processing_interval | Periodic processing interval |
| RecentBlockIDsLimit | uint64 | 50000 | blockvalidation_recentBlockIDsLimit | **CRITICAL** - Fast-path double-spend checking window |
| TipRepairSliceSize | int | 250000 | blockvalidation_tipRepairSliceSize | Transactions per overtaken-tip repair step on the setMined worker; bounds how long a step delays the next block's mined status |
| MaxParallelForks | int | 4 | blockvalidation_max_parallel_forks | Maximum parallel fork processing |
| MaxTrackedForks | int | 1000 | blockvalidation_max_tracked_forks | Maximum total forks tracked |
| NearForkThreshold | int | 0 | blockvalidation_near_fork_threshold | Near fork detection (0=coinbase maturity/2) |
Expand Down
4 changes: 3 additions & 1 deletion docs/references/stores/utxo_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,9 @@ type Store interface {
// MarkTransactionsOnLongestChain marks transactions as being on the longest chain or not.
// When onLongestChain is true, the unminedSince field is unset (transaction is mined).
// When onLongestChain is false, the unminedSince field is set to the current block height.
MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool) error
// WithSkipMissing() makes a record missing from the store a logged skip instead of an
// error for onLongestChain=false.
MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool, opts ...MarkLongestChainOption) error

// SetBlockHeight updates the current block height in the store.
//
Expand Down
4 changes: 2 additions & 2 deletions docs/topics/stores/utxo.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,8 +632,8 @@ The current writers, re-derived directly from the code (not from the settings/de
| Service | File(s) | Writes | Why |
|---|---|---|---|
| **Validator** | `services/validator/Validator.go` | `SpendAndCreate` (creates the tx's outputs, spends its inputs), `SetLocked` | Core transaction validation: on accepting a new transaction it atomically creates its UTXOs and spends the ones it consumes. `SetLocked` completes a two-phase commit on the tx's own hash: when the tx is queued for block assembly its outputs are created locked (`WithLocked(true)`), and unlocked only once it has been added successfully (`twoPhaseCommitTransaction`) — a crash in between leaves them unspendable rather than spendable. |
| **Block Validation** | `services/blockvalidation/BlockValidation.go`, `services/blockvalidation/quick_validate.go` | `SpendAndCreate` (`WithCreateOnly`/`WithSpendOnly`, quick-validate replay), `SetMinedMulti` (steady state, via `model.UpdateTxMinedStatus` in `setTxMined`), `utxo.SetMinedMultiChunked` (→ `SetMinedMulti`, quick-validate replay), `SetLocked` | Validates incoming blocks: creates/spends UTXOs for transactions not already known, marks the block's transactions as mined (records `BlockID`/`SubtreeIdx`), then — unless `quickValidateSkipsUtxoLock` applies to this block — unlocks the block's own subtree transactions at the end of quick-validate (`unlockSubtreeTransactions`). |
| **Block Assembly** | `services/blockassembly/BlockAssembler.go`, `services/blockassembly/subtreeprocessor/SubtreeProcessor.go` | `SpendAndCreate` (`WithCreateOnly` — creates the coinbase UTXOs, `processCoinbaseUtxos`), `Delete` (coinbase and its child spends on reorg, `removeCoinbaseUtxos` — Block Assembly is the only service that calls `Delete` directly), `MarkTransactionsOnLongestChain`, `SetLocked` (unlocks unmined transactions reloaded at startup; unlocks conflict-locked parents when healing a stale conflict intent), `utxo.ProcessConflicting` / `utxo.ReverseProcessConflicting` (→ `SetConflicting`, `Unspend`, `SpendAndCreate`, `BeginConflictIntent`/`CompleteConflictIntent`) | Owns chain-tip bookkeeping during block building and reorgs: creates the coinbase UTXO and deletes it (with its child spends) on reorg, flags which transactions sit on the currently-longest chain, unlocks unmined transactions reloaded at startup and conflict-locked parents when healing a stale intent, and drives the conflicting-transaction state machine (marking/unmarking double-spends and reversing that on reorg). |
| **Block Validation** | `services/blockvalidation/BlockValidation.go`, `services/blockvalidation/quick_validate.go`, `services/blockvalidation/overtaken_tip_repair.go` | `SpendAndCreate` (`WithCreateOnly`/`WithSpendOnly`, quick-validate replay), `SetMinedMulti` (steady state, via `model.UpdateTxMinedStatus` in `setTxMined`), `utxo.SetMinedMultiChunked` (→ `SetMinedMulti`, quick-validate replay), `SetLocked`, `MarkTransactionsOnLongestChain` (overtaken-tip repair, `overtaken_tip_repair.go`) | Validates incoming blocks: creates/spends UTXOs for transactions not already known, marks the block's transactions as mined (records `BlockID`/`SubtreeIdx`), re-marks the transactions of a block it marked mined that has since left the longest chain, then — unless `quickValidateSkipsUtxoLock` applies to this block — unlocks the block's own subtree transactions at the end of quick-validate (`unlockSubtreeTransactions`). |
| **Block Assembly** | `services/blockassembly/BlockAssembler.go`, `services/blockassembly/subtreeprocessor/SubtreeProcessor.go` | `SpendAndCreate` (`WithCreateOnly` — creates the coinbase UTXOs, `processCoinbaseUtxos`), `Delete` (coinbase and its child spends on reorg, `removeCoinbaseUtxos` — Block Assembly is the only service that calls `Delete` directly), `MarkTransactionsOnLongestChain`, `SetLocked` (unlocks unmined transactions reloaded at startup; unlocks conflict-locked parents when healing a stale conflict intent), `utxo.ProcessConflicting` / `utxo.ReverseProcessConflicting` (→ `SetConflicting`, `Unspend`, `SpendAndCreate`, `BeginConflictIntent`/`CompleteConflictIntent`) | Owns chain-tip bookkeeping during block building and reorgs: creates the coinbase UTXO and deletes it (with its child spends) on reorg, flags which transactions sit on the currently-longest chain (on a reorg, only the transactions it moved, not its whole mempool), unlocks unmined transactions reloaded at startup and conflict-locked parents when healing a stale intent, and drives the conflicting-transaction state machine (marking/unmarking double-spends and reversing that on reorg). |
| **Legacy (p2p sync)** | `services/legacy/netsync/handle_block.go` | `SpendAndCreate`, `utxo.SetMinedMultiChunked` (→ `SetMinedMulti`) | The legacy Bitcoin-protocol block-sync path validates and applies blocks received from legacy peers the same way Block Validation does for the native protocol — same create/spend/set-mined writes, different ingestion path. |
| **Alert System** | `services/alert/node.go` | `FreezeUTXOs`, `UnFreezeUTXOs`, `ReAssignUTXO` | Executes alert-network directives (court orders / network alerts) to freeze, unfreeze, or reassign specific UTXOs. |
| **RPC** | `services/rpc/handlers.go` | `FreezeUTXOs`, `UnFreezeUTXOs`, `ReAssignUTXO` | Exposes the same freeze/unfreeze/reassign operations as operator-facing RPC calls (e.g. for manual intervention), independent of the Alert System's automated path. |
Expand Down
200 changes: 200 additions & 0 deletions model/filter-not-on-longest-chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package model

import (
"context"
"fmt"
"slices"

"github.com/bsv-blockchain/go-bt/v2/chainhash"
"github.com/bsv-blockchain/teranode/errors"
"github.com/bsv-blockchain/teranode/settings"
"github.com/bsv-blockchain/teranode/stores/utxo"
"github.com/bsv-blockchain/teranode/stores/utxo/fields"
"github.com/bsv-blockchain/teranode/ulogger"
"github.com/bsv-blockchain/teranode/util"
"golang.org/x/sync/errgroup"
)

// filterNotOnLongestChainBatchSize is how many records the filters here read per
// BatchDecorate call.
const filterNotOnLongestChainBatchSize = 1024

// txMetaBatchReader is the slice of the UTXO store FilterTxsNotOnLongestChain reads from.
type txMetaBatchReader interface {
BatchDecorate(ctx context.Context, unresolvedMetaDataSlice []*utxo.UnresolvedMetaData, fields ...fields.FieldName) error
}

// longestChainClient is the slice of the blockchain client FilterTxsNotOnLongestChain needs.
type longestChainClient interface {
CheckBlockIsInCurrentChain(ctx context.Context, blockIDs []uint32) (bool, error)
}

// recentChainHeadersClient is the slice of the blockchain client RecentChainBlockIDs needs.
type recentChainHeadersClient interface {
GetBlockHeaders(ctx context.Context, blockHash *chainhash.Hash, numberOfHeaders uint64) ([]*BlockHeader, []*BlockHeaderMeta, error)
}

// RecentChainBlockIDs returns the ids of the n blocks ending at blockHash, for
// FilterTxsNotOnLongestChain's fast path when blockHash is on the longest chain.
func RecentChainBlockIDs(ctx context.Context, blockchainClient recentChainHeadersClient, blockHash *chainhash.Hash, n uint64) (map[uint32]bool, error) {
_, metas, err := blockchainClient.GetBlockHeaders(ctx, blockHash, n)
if err != nil {
return nil, errors.NewProcessingError("[RecentChainBlockIDs] error getting last block headers", err)
}

ids := make(map[uint32]bool, len(metas))
for _, meta := range metas {
ids[meta.ID] = true
}

return ids, nil
}

// FilterTxsNotOnLongestChain returns the subset of hashes that still look mined
// (unmined_since absent) but are not mined in any block on the current longest chain:
// exactly the txs whose MarkTransactionsOnLongestChain(false) would change something.
//
// It exists for blocks that left the longest chain after their txs were written as mined.
// A tx the block shares with the longest chain is mined there and must keep its cleared
// unmined_since; a tx that already has unmined_since needs no write at all.
//
// - offChainBlockID is a block the caller has established is off the longest chain. A tx
// whose only block it is gets returned without a chain lookup. The caller owns that
// claim: if the chain can move back onto the block, re-check before marking.
// - recentMainChainIDs is a set of block ids on the longest chain, typically the most
// recent ones (RecentChainBlockIDs). A tx in any of them is on the longest chain without
// a lookup. Other block id sets are resolved with CheckBlockIsInCurrentChain, once per
// distinct set: the txs of one block share a handful of them.
//
// A hash missing from the UTXO store is an error, unless skipMissing is set: a caller
// re-marking an old block's txs passes it, since a tx pruned since then has nothing to mark.
func FilterTxsNotOnLongestChain(ctx context.Context, logger ulogger.Logger, tSettings *settings.Settings, utxoStore txMetaBatchReader,
blockchainClient longestChainClient, recentMainChainIDs map[uint32]bool, offChainBlockID uint32, hashes []chainhash.Hash, skipMissing bool) ([]chainhash.Hash, error) {
notOnLongestChain := make([]chainhash.Hash, 0, len(hashes))
inCurrentChain := make(map[string]bool) // CheckBlockIsInCurrentChain answers, by block id set

items, err := batchReadTxMeta(ctx, logger, tSettings, utxoStore, hashes, fields.BlockIDs, fields.UnminedSince)
if err != nil {
return nil, errors.NewProcessingError("[FilterTxsNotOnLongestChain] error getting transactions from utxo store", err)
}

for _, item := range items {
if item.Err != nil {
if skipMissing && errors.Is(item.Err, errors.ErrTxNotFound) {
continue
}

return nil, errors.NewProcessingError("[FilterTxsNotOnLongestChain] error getting transaction %s from utxo store", item.Hash.String(), item.Err)
}

txMeta := item.Data
if txMeta == nil {
if skipMissing {
continue
}

// transaction not found, not OK
return nil, errors.NewProcessingError("[FilterTxsNotOnLongestChain] transaction %s not found on longest chain", item.Hash.String())
}

if txMeta.UnminedSince != 0 {
// already unmined: marking it would change nothing (issue 1768 keeps an existing value)
continue
}

if len(txMeta.BlockIDs) == 1 && txMeta.BlockIDs[0] == offChainBlockID {
// the transaction is only in the off-chain block, so it is definitely not on the longest chain
notOnLongestChain = append(notOnLongestChain, item.Hash)
continue
}

foundInRecentBlock := false
for _, blockID := range txMeta.BlockIDs {
if recentMainChainIDs[blockID] {
// the transaction is still in one of the last 1000 blocks, so it is still on the longest chain
foundInRecentBlock = true
break
}
}

if foundInRecentBlock {
continue
}

// check BlockIDs to see if the transaction is still on the longest chain
key := fmt.Sprint(txMeta.BlockIDs)

onLongestChain, known := inCurrentChain[key]
if !known {
var err error
if onLongestChain, err = blockchainClient.CheckBlockIsInCurrentChain(ctx, txMeta.BlockIDs); err != nil {
return nil, errors.NewProcessingError("[FilterTxsNotOnLongestChain] error checking if transaction is on longest chain", err)
}

inCurrentChain[key] = onLongestChain
}

if !onLongestChain {
notOnLongestChain = append(notOnLongestChain, item.Hash)
}
}

return notOnLongestChain, nil
}

// FilterTxsUnmined returns the subset of hashes whose unmined_since is set and that are
// mined in blockID: the txs of that block a MarkTransactionsOnLongestChain(true) would
// change. Hashes missing from the UTXO store are skipped.
func FilterTxsUnmined(ctx context.Context, logger ulogger.Logger, tSettings *settings.Settings, utxoStore txMetaBatchReader, blockID uint32, hashes []chainhash.Hash) ([]chainhash.Hash, error) {
items, err := batchReadTxMeta(ctx, logger, tSettings, utxoStore, hashes, fields.UnminedSince, fields.BlockIDs)
if err != nil {
return nil, errors.NewProcessingError("[FilterTxsUnmined] error getting transactions from utxo store", err)
}

unmined := make([]chainhash.Hash, 0, len(hashes))

for _, item := range items {
if item.Err != nil {
if errors.Is(item.Err, errors.ErrTxNotFound) {
continue
}

return nil, errors.NewProcessingError("[FilterTxsUnmined] error getting transaction %s from utxo store", item.Hash.String(), item.Err)
}

if item.Data != nil && item.Data.UnminedSince != 0 && slices.Contains(item.Data.BlockIDs, blockID) {
unmined = append(unmined, item.Hash)
}
}

return unmined, nil
}

// batchReadTxMeta reads the given fields of hashes in concurrent BatchDecorate batches. The
// per-item result, including a per-item error, is on each returned item.
func batchReadTxMeta(ctx context.Context, logger ulogger.Logger, tSettings *settings.Settings, utxoStore txMetaBatchReader,
hashes []chainhash.Hash, readFields ...fields.FieldName) ([]*utxo.UnresolvedMetaData, error) {
items := make([]*utxo.UnresolvedMetaData, len(hashes))
for idx, hash := range hashes {
items[idx] = &utxo.UnresolvedMetaData{Hash: hash, Idx: idx}
}

g, gCtx := errgroup.WithContext(ctx)
// SafeSetLimit panics loudly on a zero limit (MaxMinedRoutines and GetBatcherSize both 0)
// rather than letting g.Go deadlock on a zero-capacity semaphore.
util.SafeSetLimit(logger, g, max(tSettings.UtxoStore.MaxMinedRoutines, tSettings.UtxoStore.GetBatcherSize*2))

for start := 0; start < len(items); start += filterNotOnLongestChainBatchSize {
batch := items[start:min(start+filterNotOnLongestChainBatchSize, len(items))]

g.Go(func() error {
return utxoStore.BatchDecorate(gCtx, batch, readFields...)
})
}

if err := g.Wait(); err != nil {
return nil, err
}

return items, nil
}
4 changes: 2 additions & 2 deletions services/blockassembly/liveness_reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ type probeUtxoStore struct {
scanIterator utxo.ConsistencyScanIterator
}

func (s *probeUtxoStore) MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool) error {
func (s *probeUtxoStore) MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool, opts ...utxo.MarkLongestChainOption) error {
if !onLongestChain && s.onMarkUnmined != nil {
s.onMarkUnmined()
}

return s.Store.MarkTransactionsOnLongestChain(ctx, txHashes, onLongestChain)
return s.Store.MarkTransactionsOnLongestChain(ctx, txHashes, onLongestChain, opts...)
}

// ScanInconsistentUnminedTxs stands in the synthetic scan when one is set. The
Expand Down
4 changes: 2 additions & 2 deletions services/blockassembly/reset_pruned_moveback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ type markRecorderStore struct {
itemErrs map[chainhash.Hash]error
}

func (s *markRecorderStore) MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool) error {
func (s *markRecorderStore) MarkTransactionsOnLongestChain(ctx context.Context, txHashes []chainhash.Hash, onLongestChain bool, opts ...utxo.MarkLongestChainOption) error {
if !onLongestChain {
s.mu.Lock()
s.markedFalse = append(s.markedFalse, txHashes...)
s.mu.Unlock()
}

return s.Store.MarkTransactionsOnLongestChain(ctx, txHashes, onLongestChain)
return s.Store.MarkTransactionsOnLongestChain(ctx, txHashes, onLongestChain, opts...)
}

func (s *markRecorderStore) BatchDecorate(ctx context.Context, items []*utxo.UnresolvedMetaData, f ...fields.FieldName) error {
Expand Down
Loading
Loading