|
| 1 | +package bump |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/hex" |
| 6 | + "net/http" |
| 7 | + "net/http/httptest" |
| 8 | + "strings" |
| 9 | + "testing" |
| 10 | + |
| 11 | + "github.com/bsv-blockchain/go-sdk/chainhash" |
| 12 | + "github.com/bsv-blockchain/go-sdk/transaction" |
| 13 | + "github.com/bsv-blockchain/go-sdk/util" |
| 14 | + "go.uber.org/zap" |
| 15 | +) |
| 16 | + |
| 17 | +func decodeHex(t *testing.T, s string) []byte { |
| 18 | + t.Helper() |
| 19 | + b, err := hex.DecodeString(strings.ReplaceAll(strings.TrimSpace(s), "\n", "")) |
| 20 | + if err != nil { |
| 21 | + t.Fatalf("decode hex: %v", err) |
| 22 | + } |
| 23 | + return b |
| 24 | +} |
| 25 | + |
| 26 | +// TestCoinbaseBUMPReconciles_RealData exercises the new fetch-time check with |
| 27 | +// the real coinbase BUMPs captured from production for block 950675: the |
| 28 | +// consistent one reconciles to the header root, the inconsistent one (served by |
| 29 | +// a stale peer) does not. |
| 30 | +func TestCoinbaseBUMPReconciles_RealData(t *testing.T) { |
| 31 | + good := decodeHex(t, issue167CoinbaseBUMPHex) |
| 32 | + bad := decodeHex(t, issue167BadCoinbaseBUMPHex) |
| 33 | + headerRoot, _ := chainhash.NewHashFromHex(issue167HeaderMerkleRoot) |
| 34 | + |
| 35 | + if err := coinbaseBUMPReconciles(good, headerRoot); err != nil { |
| 36 | + t.Errorf("good coinbase BUMP should reconcile, got: %v", err) |
| 37 | + } |
| 38 | + if err := coinbaseBUMPReconciles(bad, headerRoot); err == nil { |
| 39 | + t.Error("inconsistent coinbase BUMP should be rejected, got nil") |
| 40 | + } |
| 41 | + // Guard rails: nothing to check => nil. |
| 42 | + if err := coinbaseBUMPReconciles(nil, headerRoot); err != nil { |
| 43 | + t.Errorf("empty coinbase BUMP should be a no-op, got: %v", err) |
| 44 | + } |
| 45 | + if err := coinbaseBUMPReconciles(good, nil); err != nil { |
| 46 | + t.Errorf("nil header root should be a no-op, got: %v", err) |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +// blockWithCoinbaseBUMP builds a datahub block-binary response carrying one |
| 51 | +// subtree hash plus a coinbase transaction and coinbase BUMP, matching the |
| 52 | +// format parseBlockBinary expects. |
| 53 | +func blockWithCoinbaseBUMP(t *testing.T, headerMerkleRoot chainhash.Hash, coinbaseBUMP []byte) []byte { |
| 54 | + t.Helper() |
| 55 | + out := make([]byte, 0, 256+len(coinbaseBUMP)) |
| 56 | + header := make([]byte, 80) |
| 57 | + copy(header[36:68], headerMerkleRoot[:]) // internal byte order |
| 58 | + out = append(out, header...) |
| 59 | + out = append(out, 0x00, 0x00) // txCount=0, sizeBytes=0 |
| 60 | + out = append(out, 0x01) // subtreeCount=1 |
| 61 | + subtreeHash := make([]byte, 32) |
| 62 | + for i := range subtreeHash { |
| 63 | + subtreeHash[i] = 0xAB |
| 64 | + } |
| 65 | + out = append(out, subtreeHash...) |
| 66 | + // coinbase tx (skipped by the parser, but must be parseable) |
| 67 | + cbTx := transaction.NewTransaction().Bytes() |
| 68 | + out = append(out, cbTx...) |
| 69 | + out = append(out, util.VarInt(950675).Bytes()...) // blockHeight |
| 70 | + out = append(out, util.VarInt(uint64(len(coinbaseBUMP))).Bytes()...) // coinbaseBUMP length |
| 71 | + out = append(out, coinbaseBUMP...) |
| 72 | + return out |
| 73 | +} |
| 74 | + |
| 75 | +// TestFetchBlockData_SkipsInconsistentCoinbaseEndpoint reproduces the block |
| 76 | +// 950675 production scenario: one datahub peer serves a coinbase BUMP that does |
| 77 | +// not reconcile to the (correct) header root it reports, while another serves a |
| 78 | +// consistent one. The fetch loop must skip the bad peer and return the good |
| 79 | +// peer's data, rather than deterministically selecting the bad peer on every |
| 80 | +// fetch and blocking the block from ever building a valid BUMP. |
| 81 | +func TestFetchBlockData_SkipsInconsistentCoinbaseEndpoint(t *testing.T) { |
| 82 | + headerRoot, _ := chainhash.NewHashFromHex(issue167HeaderMerkleRoot) |
| 83 | + good := decodeHex(t, issue167CoinbaseBUMPHex) |
| 84 | + bad := decodeHex(t, issue167BadCoinbaseBUMPHex) |
| 85 | + |
| 86 | + // Sanity: the helper produces a body the parser reads a coinbase BUMP from. |
| 87 | + if _, cb, _, perr := parseBlockBinary(blockWithCoinbaseBUMP(t, *headerRoot, good)); perr != nil || len(cb) == 0 { |
| 88 | + t.Fatalf("test fixture invalid: parse err=%v cbLen=%d", perr, len(cb)) |
| 89 | + } |
| 90 | + |
| 91 | + badBody := blockWithCoinbaseBUMP(t, *headerRoot, bad) |
| 92 | + goodBody := blockWithCoinbaseBUMP(t, *headerRoot, good) |
| 93 | + |
| 94 | + badSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 95 | + _, _ = w.Write(badBody) |
| 96 | + })) |
| 97 | + t.Cleanup(badSrv.Close) |
| 98 | + goodSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 99 | + _, _ = w.Write(goodBody) |
| 100 | + })) |
| 101 | + t.Cleanup(goodSrv.Close) |
| 102 | + |
| 103 | + _, cbBUMP, root, err := FetchBlockDataForBUMPWithOptions( |
| 104 | + context.Background(), |
| 105 | + []string{badSrv.URL, goodSrv.URL}, // bad peer sorts first |
| 106 | + "00000000000000000eb6ea115a5cb140ffeae7b673b4c5b6f3fac62e0adae3c5", |
| 107 | + 1<<20, nil, zap.NewNop(), |
| 108 | + ) |
| 109 | + if err != nil { |
| 110 | + t.Fatalf("expected fall-through to the consistent peer, got: %v", err) |
| 111 | + } |
| 112 | + if root == nil || !root.IsEqual(headerRoot) { |
| 113 | + t.Fatalf("unexpected header root: %v", root) |
| 114 | + } |
| 115 | + if !bytesEqual(cbBUMP, good) { |
| 116 | + t.Fatal("expected the good peer's coinbase BUMP to be selected") |
| 117 | + } |
| 118 | + |
| 119 | + // And if ONLY the bad peer exists, the loop surfaces the mismatch error. |
| 120 | + _, _, _, err = FetchBlockDataForBUMPWithOptions( |
| 121 | + context.Background(), |
| 122 | + []string{badSrv.URL}, |
| 123 | + "00000000000000000eb6ea115a5cb140ffeae7b673b4c5b6f3fac62e0adae3c5", |
| 124 | + 1<<20, nil, zap.NewNop(), |
| 125 | + ) |
| 126 | + if err == nil || !strings.Contains(err.Error(), "coinbase BUMP mismatch") { |
| 127 | + t.Fatalf("expected coinbase BUMP mismatch error, got: %v", err) |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +func bytesEqual(a, b []byte) bool { |
| 132 | + if len(a) != len(b) { |
| 133 | + return false |
| 134 | + } |
| 135 | + for i := range a { |
| 136 | + if a[i] != b[i] { |
| 137 | + return false |
| 138 | + } |
| 139 | + } |
| 140 | + return true |
| 141 | +} |
0 commit comments