Skip to content

Commit c112774

Browse files
authored
fix(bump_builder): preserve block height when publishing mined status (#87) (#123)
* fix(bump_builder): preserve block height when publishing mined status (#87) The mined-status update path was dropping blockHeight between InsertBUMP and SetMinedByTxIDs (or the subsequent publish), so downstream consumers received MINED statuses with a zero or unset height. Block height is now threaded through the whole call chain and asserted in tests. Closes F-029. * test(api_server): authenticate the unknown-txid callback test PR #121 (issue #91) added TestHandleCallback_UnknownTxid_NoPhantomRow without the bearer header that PR #112 (issue #76) made mandatory on the callback endpoint, so the test was failing on every PR rebased onto main. Use the existing authedCallbackRequest helper.
1 parent 8a68959 commit c112774

8 files changed

Lines changed: 309 additions & 50 deletions

File tree

services/api_server/handlers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (m *mockStore) SetStatusByBlockHash(context.Context, string, models.Status)
8686
}
8787
func (m *mockStore) InsertBUMP(context.Context, string, uint64, []byte) error { return nil }
8888
func (m *mockStore) GetBUMP(context.Context, string) (uint64, []byte, error) { return 0, nil, nil }
89-
func (m *mockStore) SetMinedByTxIDs(context.Context, string, []string) ([]*models.TransactionStatus, error) {
89+
func (m *mockStore) SetMinedByTxIDs(context.Context, string, uint64, []string) ([]*models.TransactionStatus, error) {
9090
return nil, nil
9191
}
9292

services/bump_builder/builder.go

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,35 @@ func (b *Builder) publishStatus(ctx context.Context, status *models.TransactionS
6363
}
6464
}
6565

66+
// markMinedAndPublish moves the txids to MINED and fans the resulting status
67+
// updates out to the events Publisher. blockHeight is required so each
68+
// published status carries the block-height anchor that downstream SSE /
69+
// webhook / BUMP-dedup consumers depend on (issue #87 / F-029). If a backend
70+
// regresses and returns a status with BlockHeight == 0, the publish path
71+
// repairs it from the compound BUMP's height before fanning out so a
72+
// half-applied revert can never reintroduce the original bug.
73+
func (b *Builder) markMinedAndPublish(ctx context.Context, logger *zap.Logger, blockHash string, blockHeight uint64, txids []string) {
74+
mined, err := b.store.SetMinedByTxIDs(ctx, blockHash, blockHeight, txids)
75+
if err != nil {
76+
logger.Error("failed to set mined status", zap.Error(err))
77+
return
78+
}
79+
logger.Info("set transactions to MINED",
80+
zap.Int("count", len(mined)),
81+
zap.Uint64("block_height", blockHeight),
82+
)
83+
// SetMinedByTxIDs returns full status objects only for the rows it
84+
// actually updated — silently skipping txids without an existing record.
85+
// Publish the rich rows directly so SSE clients receive blockHash /
86+
// blockHeight / merklePath in their status updates.
87+
for _, st := range mined {
88+
if st.BlockHeight == 0 {
89+
st.BlockHeight = blockHeight
90+
}
91+
b.publishStatus(ctx, st)
92+
}
93+
}
94+
6695
func (b *Builder) Name() string { return "bump-builder" }
6796

6897
func (b *Builder) Start(ctx context.Context) error {
@@ -208,24 +237,13 @@ func (b *Builder) handleMessage(ctx context.Context, msg *kafka.Message) error {
208237
return fmt.Errorf("storing BUMP: %w", err)
209238
}
210239

211-
// 6. Set tracked transactions to MINED
240+
// 6. Set tracked transactions to MINED.
241+
// blockHeight is threaded through here (and asserted on the returned
242+
// statuses below) because downstream SSE/webhook consumers and the
243+
// dedup path in BUMP-build rely on the height to anchor each MINED
244+
// status to a specific block — a zero/missing height triggered F-029.
212245
if len(txids) > 0 {
213-
mined, err := b.store.SetMinedByTxIDs(ctx, blockHash, txids)
214-
if err != nil {
215-
logger.Error("failed to set mined status", zap.Error(err))
216-
} else {
217-
logger.Info("set transactions to MINED",
218-
zap.Int("count", len(mined)),
219-
)
220-
// SetMinedByTxIDs returns full status objects only for the rows
221-
// it actually updated — silently skipping txids without an
222-
// existing record. Publish the rich rows directly so SSE
223-
// clients receive blockHash / blockHeight / merklePath in their
224-
// status updates.
225-
for _, st := range mined {
226-
b.publishStatus(ctx, st)
227-
}
228-
}
246+
b.markMinedAndPublish(ctx, logger, blockHash, blockHeight, txids)
229247
}
230248

231249
// 7. Prune STUMPs

services/bump_builder/builder_test.go

Lines changed: 229 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import (
2525
"github.com/bsv-blockchain/arcade/teranode"
2626
)
2727

28+
// testBlockHash is a synthetic block hash reused across the table-style tests
29+
// in this file. Lifted to a constant to satisfy goconst now that the
30+
// regression tests for issue #87 push the literal count past the threshold.
31+
const testBlockHash = "aabbccdd00000000000000000000000000000000000000000000000000000000"
32+
2833
// --- Mock Store ---
2934

3035
type mockStore struct {
@@ -43,8 +48,9 @@ type mockStore struct {
4348
}
4449

4550
type minedCall struct {
46-
blockHash string
47-
txids []string
51+
blockHash string
52+
blockHeight uint64
53+
txids []string
4854
}
4955

5056
func newMockStore() *mockStore {
@@ -85,20 +91,21 @@ func (m *mockStore) InsertBUMP(_ context.Context, blockHash string, _ uint64, bu
8591
return nil
8692
}
8793

88-
func (m *mockStore) SetMinedByTxIDs(_ context.Context, blockHash string, txids []string) ([]*models.TransactionStatus, error) {
94+
func (m *mockStore) SetMinedByTxIDs(_ context.Context, blockHash string, blockHeight uint64, txids []string) ([]*models.TransactionStatus, error) {
8995
m.mu.Lock()
9096
defer m.mu.Unlock()
9197
if m.setMinedErr != nil {
9298
return nil, m.setMinedErr
9399
}
94-
m.minedCalls = append(m.minedCalls, minedCall{blockHash, txids})
100+
m.minedCalls = append(m.minedCalls, minedCall{blockHash, blockHeight, txids})
95101
var statuses []*models.TransactionStatus
96102
for _, txid := range txids {
97103
statuses = append(statuses, &models.TransactionStatus{
98-
TxID: txid,
99-
Status: models.StatusMined,
100-
BlockHash: blockHash,
101-
Timestamp: time.Now(),
104+
TxID: txid,
105+
Status: models.StatusMined,
106+
BlockHash: blockHash,
107+
BlockHeight: blockHeight,
108+
Timestamp: time.Now(),
102109
})
103110
}
104111
return statuses, nil
@@ -757,3 +764,217 @@ func searchStr(s, substr string) bool {
757764
}
758765
return false
759766
}
767+
768+
// --- Publisher mock + block-height regression tests for issue #87 / F-029 ---
769+
770+
// recordingPublisher captures every TransactionStatus the builder publishes
771+
// downstream so tests can assert on what SSE / webhook subscribers would see.
772+
type recordingPublisher struct {
773+
mu sync.Mutex
774+
published []*models.TransactionStatus
775+
}
776+
777+
func (p *recordingPublisher) Publish(_ context.Context, status *models.TransactionStatus) error {
778+
p.mu.Lock()
779+
defer p.mu.Unlock()
780+
// Copy so later mutation by the builder (or the store) cannot retroactively
781+
// repair a height we want to assert was missing at publish time.
782+
cp := *status
783+
p.published = append(p.published, &cp)
784+
return nil
785+
}
786+
787+
func (p *recordingPublisher) Subscribe(context.Context) (<-chan *models.TransactionStatus, error) {
788+
return nil, errors.New("recordingPublisher: Subscribe not used in tests")
789+
}
790+
791+
func (p *recordingPublisher) Close() error { return nil }
792+
793+
func (p *recordingPublisher) snapshot() []*models.TransactionStatus {
794+
p.mu.Lock()
795+
defer p.mu.Unlock()
796+
out := make([]*models.TransactionStatus, len(p.published))
797+
copy(out, p.published)
798+
return out
799+
}
800+
801+
// makeMinimalSTUMPAtHeight builds a single-leaf STUMP for txidHex at the given
802+
// block height. Uses go-sdk's MerklePath.Bytes() so the height is encoded as a
803+
// proper BRC-74 varint (the hand-rolled makeMinimalSTUMP only supports heights
804+
// < 0xfd because it writes a single raw byte).
805+
func makeMinimalSTUMPAtHeight(t *testing.T, txidHex string, blockHeight uint32) []byte {
806+
t.Helper()
807+
txHash := mustHash(t, txidHex)
808+
isTxid := true
809+
mp := transaction.NewMerklePath(blockHeight, [][]*transaction.PathElement{
810+
{{Offset: 0, Hash: &txHash, Txid: &isTxid}},
811+
})
812+
return mp.Bytes()
813+
}
814+
815+
// TestBuilder_HandleMessage_PublishesMinedStatusWithBlockHeight is the
816+
// regression test for issue #87 / F-029: the builder must thread the
817+
// compound BUMP's block height all the way through SetMinedByTxIDs and
818+
// onto the TransactionStatus that gets published. A previous code path
819+
// dropped the height before publish, leaving downstream SSE/webhook
820+
// consumers with BlockHash but BlockHeight=0.
821+
func TestBuilder_HandleMessage_PublishesMinedStatusWithBlockHeight(t *testing.T) {
822+
const wantHeight uint32 = 850123
823+
ms := newMockStore()
824+
pub := &recordingPublisher{}
825+
826+
blockHash := testBlockHash
827+
txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
828+
829+
stumpData := makeMinimalSTUMPAtHeight(t, txidHex, wantHeight)
830+
ms.addStump(blockHash, 0, stumpData)
831+
832+
subtreeHash := mustHash(t, txidHex)
833+
root := expectedCompoundRoot(t,
834+
[]*models.Stump{{BlockHash: blockHash, SubtreeIndex: 0, StumpData: stumpData}},
835+
[]chainhash.Hash{subtreeHash}, nil)
836+
datahub := newDatahubServer(root, []chainhash.Hash{subtreeHash})
837+
defer datahub.Close()
838+
839+
b := newTestBuilder(ms, datahub.URL)
840+
b.publisher = pub
841+
842+
if err := b.handleMessage(context.Background(), makeBlockProcessedMsg(blockHash)); err != nil {
843+
t.Fatalf("handleMessage: %v", err)
844+
}
845+
846+
// 1) The store call itself must receive the height — without this, no
847+
// backend can persist it even if the publish path were correct.
848+
ms.mu.Lock()
849+
if len(ms.minedCalls) != 1 {
850+
ms.mu.Unlock()
851+
t.Fatalf("expected 1 SetMinedByTxIDs call, got %d", len(ms.minedCalls))
852+
}
853+
got := ms.minedCalls[0]
854+
ms.mu.Unlock()
855+
if got.blockHeight != uint64(wantHeight) {
856+
t.Errorf("SetMinedByTxIDs got blockHeight=%d, want %d", got.blockHeight, wantHeight)
857+
}
858+
if got.blockHash != blockHash {
859+
t.Errorf("SetMinedByTxIDs got blockHash=%q, want %q", got.blockHash, blockHash)
860+
}
861+
862+
// 2) The published TransactionStatus must carry both fields. This is the
863+
// contract SSE / webhook subscribers see; F-029 was that BlockHeight
864+
// was zero here even though BlockHash was set.
865+
emitted := pub.snapshot()
866+
if len(emitted) != 1 {
867+
t.Fatalf("expected 1 published status, got %d", len(emitted))
868+
}
869+
st := emitted[0]
870+
if st.Status != models.StatusMined {
871+
t.Errorf("published status = %q, want MINED", st.Status)
872+
}
873+
if st.BlockHash != blockHash {
874+
t.Errorf("published BlockHash=%q, want %q", st.BlockHash, blockHash)
875+
}
876+
if st.BlockHeight != uint64(wantHeight) {
877+
t.Errorf("published BlockHeight=%d, want %d", st.BlockHeight, wantHeight)
878+
}
879+
}
880+
881+
// TestBuilder_HandleMessage_PublishedHeightIsNeverZero is the narrow
882+
// regression guard: anyone refactoring SetMinedByTxIDs or the publish
883+
// loop and zero-valuing BlockHeight will fail this test even if every
884+
// other assertion happens to pass (e.g. a future test that compares
885+
// published statuses to mock-returned statuses without checking height).
886+
func TestBuilder_HandleMessage_PublishedHeightIsNeverZero(t *testing.T) {
887+
ms := newMockStore()
888+
pub := &recordingPublisher{}
889+
890+
blockHash := testBlockHash
891+
txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
892+
893+
// Use the existing 0x01 minimal STUMP — it encodes blockHeight=1, which is
894+
// non-zero, so any code path that zero-values the height will be caught.
895+
stumpData := makeMinimalSTUMP(txidHex)
896+
ms.addStump(blockHash, 0, stumpData)
897+
898+
subtreeHash := mustHash(t, txidHex)
899+
root := expectedCompoundRoot(t,
900+
[]*models.Stump{{BlockHash: blockHash, SubtreeIndex: 0, StumpData: stumpData}},
901+
[]chainhash.Hash{subtreeHash}, nil)
902+
datahub := newDatahubServer(root, []chainhash.Hash{subtreeHash})
903+
defer datahub.Close()
904+
905+
b := newTestBuilder(ms, datahub.URL)
906+
b.publisher = pub
907+
908+
if err := b.handleMessage(context.Background(), makeBlockProcessedMsg(blockHash)); err != nil {
909+
t.Fatalf("handleMessage: %v", err)
910+
}
911+
912+
emitted := pub.snapshot()
913+
if len(emitted) == 0 {
914+
t.Fatal("expected at least one published status")
915+
}
916+
for i, st := range emitted {
917+
if st.BlockHeight == 0 {
918+
t.Errorf("published status %d has BlockHeight=0; F-029 regression: %+v", i, st)
919+
}
920+
}
921+
}
922+
923+
// TestBuilder_HandleMessage_DefensivelyRestoresHeightIfStoreDropsIt
924+
// asserts the safety net the builder added on top of SetMinedByTxIDs:
925+
// if a backend regresses and forgets to populate BlockHeight on its
926+
// returned status, the publish path repairs it from the compound BUMP's
927+
// height before fanning out. This guards against a partial revert that
928+
// undoes only the store change.
929+
func TestBuilder_HandleMessage_DefensivelyRestoresHeightIfStoreDropsIt(t *testing.T) {
930+
ms := newMockStore()
931+
pub := &recordingPublisher{}
932+
933+
blockHash := testBlockHash
934+
txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
935+
936+
stumpData := makeMinimalSTUMP(txidHex)
937+
ms.addStump(blockHash, 0, stumpData)
938+
939+
subtreeHash := mustHash(t, txidHex)
940+
root := expectedCompoundRoot(t,
941+
[]*models.Stump{{BlockHash: blockHash, SubtreeIndex: 0, StumpData: stumpData}},
942+
[]chainhash.Hash{subtreeHash}, nil)
943+
datahub := newDatahubServer(root, []chainhash.Hash{subtreeHash})
944+
defer datahub.Close()
945+
946+
b := newTestBuilder(ms, datahub.URL)
947+
b.publisher = pub
948+
949+
// Wrap the mock so SetMinedByTxIDs returns BlockHeight=0 — simulating a
950+
// backend that regresses on the persistence side. The builder must still
951+
// publish a non-zero height by falling back to the compound's height.
952+
heightDroppingStore := &heightDroppingMockStore{mockStore: ms}
953+
b.store = heightDroppingStore
954+
955+
if err := b.handleMessage(context.Background(), makeBlockProcessedMsg(blockHash)); err != nil {
956+
t.Fatalf("handleMessage: %v", err)
957+
}
958+
959+
emitted := pub.snapshot()
960+
if len(emitted) != 1 {
961+
t.Fatalf("expected 1 published status, got %d", len(emitted))
962+
}
963+
if emitted[0].BlockHeight == 0 {
964+
t.Errorf("publish path failed to defensively restore BlockHeight: %+v", emitted[0])
965+
}
966+
}
967+
968+
// heightDroppingMockStore wraps mockStore and zeroes BlockHeight on every
969+
// returned status to simulate a buggy backend.
970+
type heightDroppingMockStore struct {
971+
*mockStore
972+
}
973+
974+
func (h *heightDroppingMockStore) SetMinedByTxIDs(ctx context.Context, blockHash string, blockHeight uint64, txids []string) ([]*models.TransactionStatus, error) {
975+
statuses, err := h.mockStore.SetMinedByTxIDs(ctx, blockHash, blockHeight, txids)
976+
for _, s := range statuses {
977+
s.BlockHeight = 0
978+
}
979+
return statuses, err
980+
}

services/webhook/service_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (s *fakeStore) SetStatusByBlockHash(context.Context, string, models.Status)
9494
}
9595
func (s *fakeStore) InsertBUMP(context.Context, string, uint64, []byte) error { return nil }
9696
func (s *fakeStore) GetBUMP(context.Context, string) (uint64, []byte, error) { return 0, nil, nil }
97-
func (s *fakeStore) SetMinedByTxIDs(context.Context, string, []string) ([]*models.TransactionStatus, error) {
97+
func (s *fakeStore) SetMinedByTxIDs(context.Context, string, uint64, []string) ([]*models.TransactionStatus, error) {
9898
return nil, nil
9999
}
100100
func (s *fakeStore) InsertSubmission(context.Context, *models.Submission) error { return nil }

store/aerospike/aerospike.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,11 @@ func (s *Store) ClearRetryState(ctx context.Context, txid string, finalStatus mo
694694
return nil
695695
}
696696

697-
func (s *Store) SetMinedByTxIDs(ctx context.Context, blockHash string, txids []string) ([]*models.TransactionStatus, error) {
697+
// SetMinedByTxIDs writes a MINED status batch keyed by blockHash + blockHeight.
698+
// blockHeight is persisted as the block_height bin and echoed back on each
699+
// returned TransactionStatus so SSE/webhook consumers always see the height
700+
// alongside the hash (issue #87 / F-029).
701+
func (s *Store) SetMinedByTxIDs(ctx context.Context, blockHash string, blockHeight uint64, txids []string) ([]*models.TransactionStatus, error) {
698702
now := time.Now()
699703
var statuses []*models.TransactionStatus
700704

@@ -720,6 +724,7 @@ func (s *Store) SetMinedByTxIDs(ctx context.Context, blockHash string, txids []s
720724
ops := []*aero.Operation{
721725
aero.PutOp(aero.NewBin("status", string(models.StatusMined))),
722726
aero.PutOp(aero.NewBin("block_hash", blockHash)),
727+
aero.PutOp(aero.NewBin("block_height", int(blockHeight))), //nolint:gosec // block height fits in int on 64-bit platforms
723728
aero.PutOp(aero.NewBin("timestamp", now.UnixMilli())),
724729
}
725730
records[j] = aero.NewBatchWrite(bwp, key, ops...)
@@ -732,10 +737,11 @@ func (s *Store) SetMinedByTxIDs(ctx context.Context, blockHash string, txids []s
732737
for j, txid := range batch {
733738
if records[j] != nil && records[j].BatchRec().Err == nil {
734739
statuses = append(statuses, &models.TransactionStatus{
735-
TxID: txid,
736-
Status: models.StatusMined,
737-
BlockHash: blockHash,
738-
Timestamp: now,
740+
TxID: txid,
741+
Status: models.StatusMined,
742+
BlockHash: blockHash,
743+
BlockHeight: blockHeight,
744+
Timestamp: now,
739745
})
740746
}
741747
}

0 commit comments

Comments
 (0)