From 8674e8b3abbd513fd034768ec6d89f39514ec463 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Thu, 18 Jun 2026 14:00:27 +0200 Subject: [PATCH] fix(nitronode): trim blockhash, set it to null in DB --- ...0260618000000_make_block_hash_nullable.sql | 16 ++++++ nitronode/store/database/contract_event.go | 4 +- .../store/database/contract_event_test.go | 52 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 nitronode/config/migrations/postgres/20260618000000_make_block_hash_nullable.sql diff --git a/nitronode/config/migrations/postgres/20260618000000_make_block_hash_nullable.sql b/nitronode/config/migrations/postgres/20260618000000_make_block_hash_nullable.sql new file mode 100644 index 000000000..679285b9c --- /dev/null +++ b/nitronode/config/migrations/postgres/20260618000000_make_block_hash_nullable.sql @@ -0,0 +1,16 @@ +-- +goose Up + +-- block_hash was added as CHAR(66) NOT NULL DEFAULT '', so rows predating the +-- column (and any default insert) hold a space-padded empty string rather than a +-- real hash. Drop the NOT NULL constraint and the empty default, then normalize +-- those padded empties to NULL so the reconciler's empty-hash guard recognizes +-- "no recorded hash" instead of treating it as a reorged-out block. +ALTER TABLE contract_events ALTER COLUMN block_hash DROP DEFAULT; +ALTER TABLE contract_events ALTER COLUMN block_hash DROP NOT NULL; +UPDATE contract_events SET block_hash = NULL WHERE TRIM(block_hash) = ''; + +-- +goose Down + +UPDATE contract_events SET block_hash = '' WHERE block_hash IS NULL; +ALTER TABLE contract_events ALTER COLUMN block_hash SET DEFAULT ''; +ALTER TABLE contract_events ALTER COLUMN block_hash SET NOT NULL; diff --git a/nitronode/store/database/contract_event.go b/nitronode/store/database/contract_event.go index 5c6b8fa64..e44246045 100644 --- a/nitronode/store/database/contract_event.go +++ b/nitronode/store/database/contract_event.go @@ -86,7 +86,7 @@ func (s *DBStore) GetLatestContractEventBlockHashAndNumber(contractAddress strin if err != nil { return 0, "", err } - return ev.BlockNumber, ev.BlockHash, nil + return ev.BlockNumber, strings.TrimSpace(ev.BlockHash), nil } // GetPreviousDistinctBlockHash returns the block_number and block_hash of the highest @@ -104,5 +104,5 @@ func (s *DBStore) GetPreviousDistinctBlockHash(contractAddress string, blockchai if err != nil { return 0, "", err } - return ev.BlockNumber, ev.BlockHash, nil + return ev.BlockNumber, strings.TrimSpace(ev.BlockHash), nil } diff --git a/nitronode/store/database/contract_event_test.go b/nitronode/store/database/contract_event_test.go index 1e14e5127..f52bc9816 100644 --- a/nitronode/store/database/contract_event_test.go +++ b/nitronode/store/database/contract_event_test.go @@ -1,6 +1,7 @@ package database import ( + "strings" "testing" "github.com/layer-3/nitrolite/pkg/core" @@ -82,6 +83,57 @@ func TestGetLatestContractEventBlockNumber(t *testing.T) { }) } +// TestGetContractEventBlockHash_TrimsPaddedEmpty is the regression guard for the +// reorg-reconciliation bug: block_hash was a CHAR(66) NOT NULL DEFAULT '' column, +// so legacy/pre-migration rows read back as a 66-space-padded string rather than "". +// The reconciler's empty-hash guard compares against "", so an untrimmed padded +// value slipped through, got fed to common.HexToHash (-> zero hash), and made every +// stored block look reorged on every chain. The getters now TrimSpace so a padded +// empty collapses to "" the guard recognizes, while real 0x… hashes pass through. +func TestGetContractEventBlockHash_TrimsPaddedEmpty(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + contractAddress := "0x1234567890123456789012345678901234567890" + blockchainID := uint64(1) + paddedEmpty := strings.Repeat(" ", 66) // mimics CHAR(66) padding of '' + realHash := "0x" + strings.Repeat("ab", 32) + + t.Run("latest padded-empty hash trims to empty string", func(t *testing.T) { + require.NoError(t, store.StoreContractEvent(core.BlockchainEvent{ + ContractAddress: contractAddress, BlockchainID: blockchainID, Name: "E1", + BlockNumber: 100, BlockHash: paddedEmpty, TransactionHash: "0xaaa", LogIndex: 0, + })) + + num, hash, err := store.GetLatestContractEventBlockHashAndNumber(contractAddress, blockchainID) + require.NoError(t, err) + assert.Equal(t, uint64(100), num) + assert.Equal(t, "", hash, "padded-empty block_hash must trim to \"\" so the reconciler guard fires") + }) + + t.Run("previous padded-empty hash trims to empty string", func(t *testing.T) { + // A newer row sits above the padded-empty one so the "below" query returns the legacy row. + require.NoError(t, store.StoreContractEvent(core.BlockchainEvent{ + ContractAddress: contractAddress, BlockchainID: blockchainID, Name: "E2", + BlockNumber: 200, BlockHash: realHash, TransactionHash: "0xbbb", LogIndex: 0, + })) + + num, hash, err := store.GetPreviousDistinctBlockHash(contractAddress, blockchainID, 200) + require.NoError(t, err) + assert.Equal(t, uint64(100), num) + assert.Equal(t, "", hash, "padded-empty block_hash must trim to \"\" mid-walk") + }) + + t.Run("real hash is preserved (not over-trimmed)", func(t *testing.T) { + num, hash, err := store.GetLatestContractEventBlockHashAndNumber(contractAddress, blockchainID) + require.NoError(t, err) + assert.Equal(t, uint64(200), num) + assert.Equal(t, realHash, hash) + }) +} + func TestIsContractEventProcessed(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup()