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
IsUnspendable — https://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
- Confirm post-Genesis bare-
OP_RETURN spend semantics and the meaning of SV Node's era (spend-time vs creation-time).
- Confirm whether a pre-Genesis
OP_RETURN output can become spendable post-Genesis (Genesis UTXO-set transition rules).
- Check whether BSV mainnet has any 0-value bare-
OP_RETURN outputs that were spent (decides whether this is theoretical or live).
- 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.
- 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
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'sIsUnspendableis 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 (theblockHeightparam it already takes is currently unused/dead).SV Node reference
IsUnspendable— https://github.com/bitcoin-sv/bitcoin-sv/blob/dev/src/script/script.h#L265-L281 (line anchors drift; snippet pasted for durability):Teranode's current definition
isCoinbaseandblockHeightare both unused. So Teranode excludes from the UTXO set exactly:satoshis == 0AND (bareOP_RETURNOROP_FALSE OP_RETURN).Divergences
OP_FALSE OP_RETURN, 0 valueOP_FALSE OP_RETURN, value > 0OP_RETURN, 0 value, post-GenesisSpendcan't find the parent -> catchup error / forkOP_RETURN, 0 value, pre-GenesisWhy 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_RETURNfrom unconditional-fail to "terminate, top-of-stack decides", so a bare-OP_RETURNoutput can be spendable once evaluated post-Genesis. Teranode's era-blind rule treats bareOP_RETURNas 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
OP_RETURNspend semantics and the meaning of SV Node'sera(spend-time vs creation-time).OP_RETURNoutput can become spendable post-Genesis (Genesis UTXO-set transition rules).OP_RETURNoutputs that were spent (decides whether this is theoretical or live).ShouldStoreOutputAsUTXOera-aware to match SV Node: post-Genesis onlyOP_FALSE OP_RETURNis provably unspendable; pre-Genesis also bareOP_RETURNand scripts overMAX_SCRIPT_SIZE_BEFORE_GENESIS.OP_FALSE OP_RETURNas 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:832stores/utxo/sql/sql.go:615services/utxopersister/UTXOSet.go:309Related
ShouldStoreOutputAsUTXO(they act only when a tx has zero spendable outputs by this definition). They inherit any over-exclusion here; fixing the predicate fixes them too.