Skip to content

consensus(utxo): align ShouldStoreOutputAsUTXO with SV Node IsUnspendable (era-aware unspendable detection) #1114

@oskarszoon

Description

@oskarszoon

Summary

Teranode's ShouldStoreOutputAsUTXO (stores/utxo/utils.go:158) decides whether an output is kept in the UTXO set. It is era-agnostic and value-gated. SV Node's IsUnspendable is era-aware and value-agnostic. They diverge, and one divergence is a (low-probability) consensus risk in the over-exclusion direction. Investigate aligning Teranode with SV Node, ideally making the predicate era-aware (the blockHeight param it already takes is currently unused/dead).

SV Node reference

IsUnspendablehttps://github.com/bitcoin-sv/bitcoin-sv/blob/dev/src/script/script.h#L265-L281 (line anchors drift; snippet pasted for durability):

bool IsUnspendable(ProtocolEra era) const
{
    if(IsProtocolActive(era, ProtocolName::Genesis))
    {
        // Genesis restored OP_RETURN functionality. It no longer unconditionally fails execution.
        // The top stack value determines if execution succeeds, and an OP_RETURN lock script
        // might be spendable if the unlock script pushes a non-0 value to the stack.
        // We currently only detect OP_FALSE OP_RETURN as provably unspendable.
        return (size() > 1 && *begin() == OP_FALSE && *(begin() + 1) == OP_RETURN);
    }
    else
    {
        return (size() > 0 && *begin() == OP_RETURN) ||
               (size() > 1 && *begin() == OP_FALSE && *(begin() + 1) == OP_RETURN) ||
               (size() > MAX_SCRIPT_SIZE_BEFORE_GENESIS);
    }
}

Teranode's current definition

func ShouldStoreOutputAsUTXO(isCoinbase bool, output *bt.Output, blockHeight uint32) bool {
    if output.Satoshis > 0 {
        return true
    }
    b := []byte(*output.LockingScript)
    opReturn := len(b) > 0 && b[0] == bscript.OpRETURN
    opFalseOpReturn := len(b) > 1 && b[0] == bscript.OpFALSE && b[1] == bscript.OpRETURN
    return !(opReturn || opFalseOpReturn)
}

isCoinbase and blockHeight are both unused. So Teranode excludes from the UTXO set exactly: satoshis == 0 AND (bare OP_RETURN OR OP_FALSE OP_RETURN).

Divergences

case SV Node Teranode impact
OP_FALSE OP_RETURN, 0 value unspendable unspendable match
OP_FALSE OP_RETURN, value > 0 unspendable (value-agnostic) stored (value gate) over-retention — safe (provably burned, never spent), but supply/UTXO-count diverges
bare OP_RETURN, 0 value, post-Genesis spendable (Genesis restored OP_RETURN) excluded over-exclusion — consensus risk: a spent such output would not be in Teranode's set -> Spend can't find the parent -> catchup error / fork
bare OP_RETURN, 0 value, pre-Genesis unspendable unspendable match
oversized script, pre-Genesis unspendable stored over-retention — safe

Why it matters (and why checkpoints don't fix it)

Quick-validation trusting a block does not make the UTXO set correct — the set must still contain exactly the spendable outputs so later spends resolve. An over-excluded spendable output breaks its later spend regardless of how the block was validated.

Subtlety: spendability is governed by the spend-time era, not the creation era. Genesis flipped bare OP_RETURN from unconditional-fail to "terminate, top-of-stack decides", so a bare-OP_RETURN output can be spendable once evaluated post-Genesis. Teranode's era-blind rule treats bare OP_RETURN as unspendable everywhere.

Practical blast radius is probably tiny: real-world data outputs use OP_FALSE OP_RETURN, which both implementations exclude identically. The only divergent-and-dangerous case is a 0-value, bare-OP_RETURN, actually-spent output on mainnet — an empirical chain question.

Investigation / fix

  1. Confirm post-Genesis bare-OP_RETURN spend semantics and the meaning of SV Node's era (spend-time vs creation-time).
  2. Confirm whether a pre-Genesis OP_RETURN output can become spendable post-Genesis (Genesis UTXO-set transition rules).
  3. Check whether BSV mainnet has any 0-value bare-OP_RETURN outputs that were spent (decides whether this is theoretical or live).
  4. If needed, make ShouldStoreOutputAsUTXO era-aware to match SV Node: post-Genesis only OP_FALSE OP_RETURN is provably unspendable; pre-Genesis also bare OP_RETURN and scripts over MAX_SCRIPT_SIZE_BEFORE_GENESIS.
  5. Separately decide the value gate (SV Node excludes value-bearing OP_FALSE OP_RETURN as burned; Teranode keeps them — supply-accounting tradeoff, not a correctness fix).

This is consensus-critical — needs a bitcoin-expert review against the actual Genesis rules before any change.

Consumers of this predicate

  • stores/utxo/utils.go:184 (HasNoSpendableOutputs)
  • stores/utxo/aerospike/create.go:832
  • stores/utxo/sql/sql.go:615
  • services/utxopersister/UTXOSet.go:309

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions