Fix cross-network block detection and remove false orphan artifacts

- Change OrphanedAt from time.Time to *time.Time so JSON omitempty
  correctly omits zero values (Go serializes zero time.Time as
  "0001-01-01T00:00:00Z" which JS treats as truthy)
- Rewrite reconcileOnce with 4-pass architecture: enrichment, reorg
  detection via GetBlock verification, false-positive un-orphaning,
  and legacy block stamping
- Use inferOtherChain() to stamp cross-network blocks with the correct
  chain identifier (testnet4 reports as "testnet4", not "test")
- Move tailer startup after aggregator's first refresh so blocks are
  always stamped with the current chain
- Add StampChain store method, debug-blocks admin endpoint, and
  comprehensive reconcile tests for cross-network scenarios
This commit is contained in:
satoshi
2026-05-10 17:22:56 +03:00
parent 92fcb70d93
commit 0787f092ff
5 changed files with 391 additions and 62 deletions
+70 -7
View File
@@ -108,10 +108,15 @@ func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
t.Fatal(err)
}
// Mock RPC returns a DIFFERENT hash for height 100 — mainnet canonical.
// Mock RPC: getblockhash returns a different mainnet hash; getblock
// for our testnet hash returns "Block not found" — it doesn't exist
// on mainnet at all, confirming it's cross-network not a reorg.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
switch method {
case "getblockhash":
return "mainnet_hash_bbb", nil
case "getblock":
return nil, &rpcErrResp{Code: -5, Message: "Block not found"}
}
return nil, nil
})
@@ -150,9 +155,17 @@ func TestReconcileOnce_GenuineReorg(t *testing.T) {
t.Fatal(err)
}
// getblockhash returns a different canonical hash (reorg happened).
// getblock for our hash SUCCEEDS — the block still exists in bitcoind's
// block store after a reorg, it's just no longer on the active chain.
// This distinguishes a real reorg from a cross-network block.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
return "canonical_bbbb", nil // different — reorg happened
switch method {
case "getblockhash":
return "canonical_bbbb", nil
case "getblock":
// Block exists (reorged but still in store)
return map[string]any{"hash": "our_hash_aaaa", "height": 200, "confirmations": -1}, nil
}
return nil, nil
})
@@ -215,7 +228,7 @@ func TestReconcileOnce_RPCError_NoOrphan(t *testing.T) {
}
}
// TestReconcileOnce_LegacyBlock_StillChecked verifies that a legacy block
// TestReconcileOnce_LegacyBlock_GenuineReorg verifies that a legacy block
// (Chain == "") is still checked for reorgs on the current chain, preserving
// backward compatibility for installs that existed before chain was tracked.
func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
@@ -230,10 +243,15 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
Chain: "", // legacy — no chain recorded
})
// Mainnet canonical hash differs: should orphan even though Chain is empty.
// Canonical hash differs AND getblock succeeds (block exists on this
// chain but was reorged out). Should still orphan legacy blocks.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
if method == "getblockhash" {
switch method {
case "getblockhash":
return "different_canonical", nil
case "getblock":
// Block exists in this chain's store — genuine reorg
return map[string]any{"hash": "legacy_hash", "height": 400, "confirmations": -1}, nil
}
return nil, nil
})
@@ -251,6 +269,51 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
}
}
// TestReconcileOnce_LegacyBlock_CrossNetwork verifies that a legacy block
// (Chain == "") whose hash doesn't exist on the current chain is identified
// as cross-network and stamped rather than orphaned.
func TestReconcileOnce_LegacyBlock_CrossNetwork(t *testing.T) {
st := openTempStore(t)
st.InsertBlock(store.Block{
Height: 401,
Hash: "other_network_hash",
RewardBT: 50,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
})
// Canonical hash differs AND getblock fails (block doesn't exist on
// this network at all). Should NOT orphan — should stamp as "other".
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
switch method {
case "getblockhash":
return "mainnet_canonical", nil
case "getblock":
return nil, &rpcErrResp{Code: -5, Message: "Block not found"}
}
return nil, nil
})
agg := newAgg(st, rpc)
setChain(agg, "main")
agg.reconcileOnce(context.Background())
blocks, _ := st.Recent(10)
for _, b := range blocks {
if b.Height == 401 {
if !b.OrphanedAt.IsZero() {
t.Error("cross-network legacy block was falsely orphaned")
}
if b.Chain != "testnet4" {
t.Errorf("expected chain stamped as 'testnet4' (inferred from current=main), got %q", b.Chain)
}
}
}
}
// ---- IngestBlockEvents tests ----
// TestIngestBlockEvents_NewBlock verifies that a fresh block event is