|
| 1 | +package syncing |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + crand "crypto/rand" |
| 6 | + "errors" |
| 7 | + "testing" |
| 8 | + "time" |
| 9 | + |
| 10 | + ds "github.com/ipfs/go-datastore" |
| 11 | + dssync "github.com/ipfs/go-datastore/sync" |
| 12 | + "github.com/libp2p/go-libp2p/core/crypto" |
| 13 | + "github.com/rs/zerolog" |
| 14 | + "github.com/stretchr/testify/require" |
| 15 | + |
| 16 | + "github.com/evstack/ev-node/block/internal/cache" |
| 17 | + "github.com/evstack/ev-node/block/internal/common" |
| 18 | + "github.com/evstack/ev-node/pkg/config" |
| 19 | + "github.com/evstack/ev-node/pkg/genesis" |
| 20 | + signerpkg "github.com/evstack/ev-node/pkg/signer" |
| 21 | + "github.com/evstack/ev-node/pkg/signer/noop" |
| 22 | + "github.com/evstack/ev-node/pkg/store" |
| 23 | + extmocks "github.com/evstack/ev-node/test/mocks/external" |
| 24 | + "github.com/evstack/ev-node/types" |
| 25 | +) |
| 26 | + |
| 27 | +// buildTestSigner returns an address, pubkey and signer suitable for tests |
| 28 | +func buildTestSigner(t *testing.T) ([]byte, crypto.PubKey, signerpkg.Signer) { |
| 29 | + t.Helper() |
| 30 | + priv, _, err := crypto.GenerateEd25519Key(crand.Reader) |
| 31 | + require.NoError(t, err, "failed to generate ed25519 key for test signer") |
| 32 | + n, err := noop.NewNoopSigner(priv) |
| 33 | + require.NoError(t, err, "failed to create noop signer from private key") |
| 34 | + a, err := n.GetAddress() |
| 35 | + require.NoError(t, err, "failed to derive address from signer") |
| 36 | + p, err := n.GetPublic() |
| 37 | + require.NoError(t, err, "failed to derive public key from signer") |
| 38 | + return a, p, n |
| 39 | +} |
| 40 | + |
| 41 | +// p2pMakeSignedHeader creates a minimally valid SignedHeader for P2P tests |
| 42 | +func p2pMakeSignedHeader(t *testing.T, chainID string, height uint64, proposer []byte, pub crypto.PubKey, signer signerpkg.Signer) *types.SignedHeader { |
| 43 | + t.Helper() |
| 44 | + hdr := &types.SignedHeader{ |
| 45 | + Header: types.Header{ |
| 46 | + BaseHeader: types.BaseHeader{ChainID: chainID, Height: height, Time: uint64(time.Now().UnixNano())}, |
| 47 | + ProposerAddress: proposer, |
| 48 | + }, |
| 49 | + Signer: types.Signer{PubKey: pub, Address: proposer}, |
| 50 | + } |
| 51 | + bz, err := types.DefaultAggregatorNodeSignatureBytesProvider(&hdr.Header) |
| 52 | + require.NoError(t, err, "failed to get signature bytes for header") |
| 53 | + sig, err := signer.Sign(bz) |
| 54 | + require.NoError(t, err, "failed to sign header bytes") |
| 55 | + hdr.Signature = sig |
| 56 | + return hdr |
| 57 | +} |
| 58 | + |
| 59 | +// p2pMakeData creates Data with the given number of txs |
| 60 | +func p2pMakeData(t *testing.T, chainID string, height uint64, txs int) *types.Data { |
| 61 | + t.Helper() |
| 62 | + d := &types.Data{Metadata: &types.Metadata{ChainID: chainID, Height: height, Time: uint64(time.Now().UnixNano())}} |
| 63 | + if txs > 0 { |
| 64 | + d.Txs = make(types.Txs, txs) |
| 65 | + for i := 0; i < txs; i++ { |
| 66 | + d.Txs[i] = []byte{byte(height), byte(i)} |
| 67 | + } |
| 68 | + } |
| 69 | + return d |
| 70 | +} |
| 71 | + |
| 72 | +// P2PTestData aggregates all dependencies used by P2P handler tests. |
| 73 | +type P2PTestData struct { |
| 74 | + Handler *P2PHandler |
| 75 | + HeaderStore *extmocks.MockStore[*types.SignedHeader] |
| 76 | + DataStore *extmocks.MockStore[*types.Data] |
| 77 | + Cache cache.Manager |
| 78 | + Genesis genesis.Genesis |
| 79 | + ProposerAddr []byte |
| 80 | + ProposerPub crypto.PubKey |
| 81 | + Signer signerpkg.Signer |
| 82 | +} |
| 83 | + |
| 84 | +// setupP2P constructs a P2PHandler with mocked go-header stores and in-memory cache/store |
| 85 | +func setupP2P(t *testing.T) *P2PTestData { |
| 86 | + t.Helper() |
| 87 | + datastore := dssync.MutexWrap(ds.NewMapDatastore()) |
| 88 | + stateStore := store.New(datastore) |
| 89 | + cacheManager, err := cache.NewManager(config.DefaultConfig, stateStore, zerolog.Nop()) |
| 90 | + require.NoError(t, err, "failed to create cache manager") |
| 91 | + |
| 92 | + proposerAddr, proposerPub, signer := buildTestSigner(t) |
| 93 | + |
| 94 | + gen := genesis.Genesis{ChainID: "p2p-test", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: proposerAddr} |
| 95 | + |
| 96 | + headerStoreMock := extmocks.NewMockStore[*types.SignedHeader](t) |
| 97 | + dataStoreMock := extmocks.NewMockStore[*types.Data](t) |
| 98 | + |
| 99 | + handler := NewP2PHandler(headerStoreMock, dataStoreMock, cacheManager, gen, common.DefaultBlockOptions(), zerolog.Nop()) |
| 100 | + return &P2PTestData{ |
| 101 | + Handler: handler, |
| 102 | + HeaderStore: headerStoreMock, |
| 103 | + DataStore: dataStoreMock, |
| 104 | + Cache: cacheManager, |
| 105 | + Genesis: gen, |
| 106 | + ProposerAddr: proposerAddr, |
| 107 | + ProposerPub: proposerPub, |
| 108 | + Signer: signer, |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +func TestP2PHandler_ProcessHeaderRange_HeaderAndDataHappyPath(t *testing.T) { |
| 113 | + p2pData := setupP2P(t) |
| 114 | + ctx := context.Background() |
| 115 | + |
| 116 | + // Signed header at height 5 with non-empty data |
| 117 | + require.Equal(t, string(p2pData.Genesis.ProposerAddress), string(p2pData.ProposerAddr), "test signer must match genesis proposer for P2P validation") |
| 118 | + signedHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 5, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 119 | + blockData := p2pMakeData(t, p2pData.Genesis.ChainID, 5, 1) |
| 120 | + signedHeader.DataHash = blockData.DACommitment() |
| 121 | + |
| 122 | + // Re-sign after setting DataHash so signature matches header bytes |
| 123 | + bz, err := types.DefaultAggregatorNodeSignatureBytesProvider(&signedHeader.Header) |
| 124 | + require.NoError(t, err, "failed to get signature bytes after setting DataHash") |
| 125 | + sig, err := p2pData.Signer.Sign(bz) |
| 126 | + require.NoError(t, err, "failed to re-sign header after setting DataHash") |
| 127 | + signedHeader.Signature = sig |
| 128 | + |
| 129 | + // Sanity: header should validate with data using default sync verifier |
| 130 | + require.NoError(t, signedHeader.ValidateBasicWithData(blockData), "header+data must validate before handler processes them") |
| 131 | + |
| 132 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(5)).Return(signedHeader, nil).Once() |
| 133 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(5)).Return(blockData, nil).Once() |
| 134 | + |
| 135 | + events := p2pData.Handler.ProcessHeaderRange(ctx, 5, 5) |
| 136 | + require.Len(t, events, 1, "expected one event for the provided header/data height") |
| 137 | + require.Equal(t, uint64(5), events[0].Header.Height()) |
| 138 | + require.NotNil(t, events[0].Data) |
| 139 | + require.Equal(t, uint64(5), events[0].Data.Height()) |
| 140 | +} |
| 141 | + |
| 142 | +func TestP2PHandler_ProcessHeaderRange_MissingData_NonEmptyHash(t *testing.T) { |
| 143 | + p2pData := setupP2P(t) |
| 144 | + ctx := context.Background() |
| 145 | + |
| 146 | + require.Equal(t, string(p2pData.Genesis.ProposerAddress), string(p2pData.ProposerAddr), "test signer must match genesis proposer for P2P validation") |
| 147 | + signedHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 7, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 148 | + |
| 149 | + // Non-empty data: set header.DataHash to a commitment; expect data store lookup to fail and event skipped |
| 150 | + blockData := p2pMakeData(t, p2pData.Genesis.ChainID, 7, 1) |
| 151 | + signedHeader.DataHash = blockData.DACommitment() |
| 152 | + |
| 153 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(7)).Return(signedHeader, nil).Once() |
| 154 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(7)).Return(nil, errors.New("not found")).Once() |
| 155 | + |
| 156 | + events := p2pData.Handler.ProcessHeaderRange(ctx, 7, 7) |
| 157 | + require.Len(t, events, 0) |
| 158 | +} |
| 159 | + |
| 160 | +func TestP2PHandler_ProcessDataRange_HeaderMissing(t *testing.T) { |
| 161 | + p2pData := setupP2P(t) |
| 162 | + ctx := context.Background() |
| 163 | + |
| 164 | + blockData := p2pMakeData(t, p2pData.Genesis.ChainID, 9, 1) |
| 165 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(9)).Return(blockData, nil).Once() |
| 166 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(9)).Return(nil, errors.New("no header")).Once() |
| 167 | + |
| 168 | + events := p2pData.Handler.ProcessDataRange(ctx, 9, 9) |
| 169 | + require.Len(t, events, 0) |
| 170 | +} |
| 171 | + |
| 172 | +func TestP2PHandler_ProposerMismatch_Rejected(t *testing.T) { |
| 173 | + p2pData := setupP2P(t) |
| 174 | + ctx := context.Background() |
| 175 | + |
| 176 | + // Build a header with a different proposer |
| 177 | + badAddr, pub, signer := buildTestSigner(t) |
| 178 | + require.NotEqual(t, string(p2pData.Genesis.ProposerAddress), string(badAddr), "negative test requires mismatched proposer") |
| 179 | + signedHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 4, badAddr, pub, signer) |
| 180 | + signedHeader.DataHash = common.DataHashForEmptyTxs |
| 181 | + |
| 182 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(4)).Return(signedHeader, nil).Once() |
| 183 | + |
| 184 | + events := p2pData.Handler.ProcessHeaderRange(ctx, 4, 4) |
| 185 | + require.Len(t, events, 0) |
| 186 | +} |
| 187 | + |
| 188 | +func TestP2PHandler_CreateEmptyDataForHeader_UsesPreviousDataHash(t *testing.T) { |
| 189 | + p2pData := setupP2P(t) |
| 190 | + ctx := context.Background() |
| 191 | + |
| 192 | + // Prepare a header at height 10 |
| 193 | + signedHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 10, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 194 | + signedHeader.DataHash = common.DataHashForEmptyTxs |
| 195 | + |
| 196 | + // Mock previous data at height 9 so handler can propagate its hash |
| 197 | + previousData := p2pMakeData(t, p2pData.Genesis.ChainID, 9, 1) |
| 198 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(9)).Return(previousData, nil).Once() |
| 199 | + |
| 200 | + emptyData := p2pData.Handler.createEmptyDataForHeader(ctx, signedHeader) |
| 201 | + require.NotNil(t, emptyData, "handler should synthesize empty data when header declares empty data hash") |
| 202 | + require.Equal(t, p2pData.Genesis.ChainID, emptyData.ChainID(), "synthesized data should carry header chain ID") |
| 203 | + require.Equal(t, uint64(10), emptyData.Height(), "synthesized data should carry header height") |
| 204 | + require.Equal(t, signedHeader.BaseHeader.Time, emptyData.Metadata.Time, "synthesized data should carry header time") |
| 205 | + require.Equal(t, previousData.Hash(), emptyData.LastDataHash, "synthesized data should propagate previous data hash") |
| 206 | +} |
| 207 | + |
| 208 | +func TestP2PHandler_CreateEmptyDataForHeader_NoPreviousData(t *testing.T) { |
| 209 | + p2pData := setupP2P(t) |
| 210 | + ctx := context.Background() |
| 211 | + |
| 212 | + // Prepare a header at height 2 (previous height exists but will return error) |
| 213 | + signedHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 2, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 214 | + signedHeader.DataHash = common.DataHashForEmptyTxs |
| 215 | + |
| 216 | + // Mock previous data fetch failure |
| 217 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(1)).Return(nil, errors.New("not available")).Once() |
| 218 | + |
| 219 | + emptyData := p2pData.Handler.createEmptyDataForHeader(ctx, signedHeader) |
| 220 | + require.NotNil(t, emptyData, "handler should synthesize empty data even when previous data is unavailable") |
| 221 | + require.Equal(t, p2pData.Genesis.ChainID, emptyData.ChainID(), "synthesized data should carry header chain ID") |
| 222 | + require.Equal(t, uint64(2), emptyData.Height(), "synthesized data should carry header height") |
| 223 | + require.Equal(t, signedHeader.BaseHeader.Time, emptyData.Metadata.Time, "synthesized data should carry header time") |
| 224 | + // When no previous data is available, LastDataHash should be zero value |
| 225 | + require.Equal(t, (types.Hash)(nil), emptyData.LastDataHash, "last data hash should be empty when previous data is not available") |
| 226 | +} |
| 227 | + |
| 228 | +func TestP2PHandler_ProcessHeaderRange_MultipleHeightsHappyPath(t *testing.T) { |
| 229 | + p2pData := setupP2P(t) |
| 230 | + ctx := context.Background() |
| 231 | + |
| 232 | + // Build two consecutive heights with valid headers and data |
| 233 | + // Height 5 |
| 234 | + header5 := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 5, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 235 | + data5 := p2pMakeData(t, p2pData.Genesis.ChainID, 5, 1) |
| 236 | + header5.DataHash = data5.DACommitment() |
| 237 | + // Re-sign after setting DataHash to keep signature valid |
| 238 | + bz5, err := types.DefaultAggregatorNodeSignatureBytesProvider(&header5.Header) |
| 239 | + require.NoError(t, err, "failed to get signature bytes for height 5") |
| 240 | + sig5, err := p2pData.Signer.Sign(bz5) |
| 241 | + require.NoError(t, err, "failed to sign header for height 5") |
| 242 | + header5.Signature = sig5 |
| 243 | + require.NoError(t, header5.ValidateBasicWithData(data5), "header/data invalid for height 5") |
| 244 | + |
| 245 | + // Height 6 |
| 246 | + header6 := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 6, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 247 | + data6 := p2pMakeData(t, p2pData.Genesis.ChainID, 6, 2) |
| 248 | + header6.DataHash = data6.DACommitment() |
| 249 | + bz6, err := types.DefaultAggregatorNodeSignatureBytesProvider(&header6.Header) |
| 250 | + require.NoError(t, err, "failed to get signature bytes for height 6") |
| 251 | + sig6, err := p2pData.Signer.Sign(bz6) |
| 252 | + require.NoError(t, err, "failed to sign header for height 6") |
| 253 | + header6.Signature = sig6 |
| 254 | + require.NoError(t, header6.ValidateBasicWithData(data6), "header/data invalid for height 6") |
| 255 | + |
| 256 | + // Expectations for both heights |
| 257 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(5)).Return(header5, nil).Once() |
| 258 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(5)).Return(data5, nil).Once() |
| 259 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(6)).Return(header6, nil).Once() |
| 260 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(6)).Return(data6, nil).Once() |
| 261 | + |
| 262 | + events := p2pData.Handler.ProcessHeaderRange(ctx, 5, 6) |
| 263 | + require.Len(t, events, 2, "expected two events for heights 5 and 6") |
| 264 | + require.Equal(t, uint64(5), events[0].Header.Height(), "first event should be height 5") |
| 265 | + require.Equal(t, uint64(6), events[1].Header.Height(), "second event should be height 6") |
| 266 | + require.NotNil(t, events[0].Data, "event for height 5 must include data") |
| 267 | + require.NotNil(t, events[1].Data, "event for height 6 must include data") |
| 268 | +} |
| 269 | + |
| 270 | +func TestP2PHandler_ProcessDataRange_HeaderValidateHeaderFails(t *testing.T) { |
| 271 | + p2pData := setupP2P(t) |
| 272 | + ctx := context.Background() |
| 273 | + |
| 274 | + // Data exists at height 3 |
| 275 | + blockData := p2pMakeData(t, p2pData.Genesis.ChainID, 3, 1) |
| 276 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(3)).Return(blockData, nil).Once() |
| 277 | + |
| 278 | + // Header proposer does not match genesis -> validateHeader should fail |
| 279 | + badAddr, pub, signer := buildTestSigner(t) |
| 280 | + require.NotEqual(t, string(p2pData.Genesis.ProposerAddress), string(badAddr), "negative test requires mismatched proposer") |
| 281 | + badHeader := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 3, badAddr, pub, signer) |
| 282 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(3)).Return(badHeader, nil).Once() |
| 283 | + |
| 284 | + events := p2pData.Handler.ProcessDataRange(ctx, 3, 3) |
| 285 | + require.Len(t, events, 0, "validateHeader failure should drop event") |
| 286 | +} |
| 287 | + |
| 288 | +func TestP2PHandler_ProcessDataRange_ValidateBasicWithDataFails(t *testing.T) { |
| 289 | + p2pData := setupP2P(t) |
| 290 | + ctx := context.Background() |
| 291 | + |
| 292 | + // Data exists at height 4 |
| 293 | + blockData := p2pMakeData(t, p2pData.Genesis.ChainID, 4, 1) |
| 294 | + p2pData.DataStore.EXPECT().GetByHeight(ctx, uint64(4)).Return(blockData, nil).Once() |
| 295 | + |
| 296 | + // Header proposer matches genesis, but signature is empty -> ValidateBasicWithData should fail |
| 297 | + header := p2pMakeSignedHeader(t, p2pData.Genesis.ChainID, 4, p2pData.ProposerAddr, p2pData.ProposerPub, p2pData.Signer) |
| 298 | + header.Signature = nil // force signature validation failure |
| 299 | + p2pData.HeaderStore.EXPECT().GetByHeight(ctx, uint64(4)).Return(header, nil).Once() |
| 300 | + |
| 301 | + events := p2pData.Handler.ProcessDataRange(ctx, 4, 4) |
| 302 | + require.Len(t, events, 0, "ValidateBasicWithData failure should drop event") |
| 303 | +} |
0 commit comments