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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user