diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go new file mode 100644 index 000000000..f3db28812 --- /dev/null +++ b/bridgeservice/block_finalized_test.go @@ -0,0 +1,300 @@ +package bridgeservice + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/agglayer/aggkit/bridgeservice/mocks" + bridgetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + claimsynctypes "github.com/agglayer/aggkit/claimsync/types" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/l2gersync" + tree "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func boolPtr(v bool) *bool { return &v } + +func clearClaimerIsBlockFinalized(claimer *mocks.Claimer) { + expected := claimer.ExpectedCalls[:0] + for _, call := range claimer.ExpectedCalls { + if call.Method != "IsBlockFinalized" { + expected = append(expected, call) + } + } + claimer.ExpectedCalls = expected +} + +func TestBlockFinalizedInResponses(t *testing.T) { + var ( + blockHash = common.HexToHash("0xb10c") + otherBlockHash = common.HexToHash("0xdead") + ) + + t.Run("bridges", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + clearIsBlockFinalized(bridgeMocks.bridgeL1) + bridgeMocks.bridgeL1.EXPECT().IsBlockFinalized(mock.Anything, uint64(10), blockHash).Return(true, true, nil).Maybe() + bridgeMocks.bridgeL1.EXPECT().IsBlockFinalized(mock.Anything, uint64(11), blockHash).Return(false, true, nil).Maybe() + bridgeMocks.bridgeL1.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{ + {BlockNum: 10, BlockHash: blockHash, Amount: common.Big0}, + {BlockNum: 11, BlockHash: blockHash, Amount: common.Big0}, + }, 2, nil) + bridgeMocks.upgradeQuerier.EXPECT().GetUpgradeBlock(mock.Anything, mock.Anything).Return(uint64(0)) + + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/bridges?%s=%d", BridgeV1Prefix, networkIDParam, mainnetNetworkID)) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.BridgesResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Bridges, 2) + require.Equal(t, boolPtr(true), response.Bridges[0].BlockFinalized) + require.Equal(t, boolPtr(false), response.Bridges[1].BlockFinalized) + }) + + t.Run("bridges on L2 ask the L2 syncer, with the hash it served", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + // the L1 syncer would say it's finalized: it must not be asked + expectBridgeBlockFinalized(bridgeMocks.bridgeL1, 10, blockHash, true) + expectBridgeBlockFinalized(bridgeMocks.bridgeL2, 10, blockHash, false) + + bridgeMocks.bridgeL2.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{{BlockNum: 10, BlockHash: blockHash, Amount: common.Big0}}, 1, nil) + bridgeMocks.upgradeQuerier.EXPECT().GetUpgradeBlock(mock.Anything, mock.Anything).Return(uint64(0)) + + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/bridges?%s=%d", BridgeV1Prefix, networkIDParam, l2NetworkID)) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.BridgesResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Bridges, 1) + require.Equal(t, boolPtr(false), response.Bridges[0].BlockFinalized) + }) + + t.Run("bridges omit block_finalized when the syncer can't tell", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + // finalized only for another hash of the same block number + expectBridgeBlockFinalized(bridgeMocks.bridgeL1, 10, otherBlockHash, true) + bridgeMocks.bridgeL1.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{{BlockNum: 10, BlockHash: blockHash, Amount: common.Big0}}, 1, nil) + bridgeMocks.upgradeQuerier.EXPECT().GetUpgradeBlock(mock.Anything, mock.Anything).Return(uint64(0)) + + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/bridges?%s=%d", BridgeV1Prefix, networkIDParam, mainnetNetworkID)) + require.Equal(t, http.StatusOK, w.Code) + require.NotContains(t, w.Body.String(), "block_finalized") + }) + + t.Run("bridges omit block_finalized when the syncer fails", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + clearIsBlockFinalized(bridgeMocks.bridgeL1) + bridgeMocks.bridgeL1.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything). + Return(false, false, errors.New("db down")) + bridgeMocks.bridgeL1.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{{BlockNum: 10, BlockHash: blockHash, Amount: common.Big0}}, 1, nil) + bridgeMocks.upgradeQuerier.EXPECT().GetUpgradeBlock(mock.Anything, mock.Anything).Return(uint64(0)) + + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/bridges?%s=%d", BridgeV1Prefix, networkIDParam, mainnetNetworkID)) + require.Equal(t, http.StatusOK, w.Code) + require.NotContains(t, w.Body.String(), "block_finalized") + }) + + t.Run("claims ask the claim syncer of the network, with the hash it served", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + clearIsBlockFinalized(bridgeMocks.bridgeL2) // the bridge syncer must not be asked + clearClaimerIsBlockFinalized(bridgeMocks.claimL2) + claimer := bridgeMocks.claimL2 + claimer.EXPECT().IsBlockFinalized(mock.Anything, uint64(10), blockHash).Return(true, true, nil).Maybe() + claimer.EXPECT().IsBlockFinalized(mock.Anything, uint64(11), blockHash).Return(false, true, nil).Maybe() + claimer.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + newClaim := func(blockNum uint64, hash common.Hash) *claimsynctypes.Claim { + return &claimsynctypes.Claim{BlockNum: blockNum, BlockHash: hash, Amount: common.Big0, GlobalIndex: common.Big0} + } + bridgeMocks.claimL2.EXPECT(). + GetClaimsPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*claimsynctypes.Claim{ + newClaim(10, blockHash), newClaim(11, blockHash), newClaim(12, blockHash), newClaim(10, common.Hash{}), + }, 4, nil) + + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/claims?%s=%d", BridgeV1Prefix, networkIDParam, l2NetworkID)) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.ClaimsResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Claims, 4) + require.Equal(t, boolPtr(true), response.Claims[0].BlockFinalized) + require.Equal(t, boolPtr(false), response.Claims[1].BlockFinalized) + require.Nil(t, response.Claims[2].BlockFinalized) + require.Nil(t, response.Claims[3].BlockFinalized) // no hash: unknown + }) + + t.Run("claims-by-ger", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + ger := common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") + clearClaimerIsBlockFinalized(bridgeMocks.claimL1) + bridgeMocks.claimL1.EXPECT().IsBlockFinalized(mock.Anything, uint64(10), blockHash).Return(true, true, nil).Maybe() + bridgeMocks.claimL1.EXPECT().GetClaimsByGER(mock.Anything, ger).Return( + []*claimsynctypes.Claim{{BlockNum: 10, BlockHash: blockHash, Amount: common.Big0, GlobalIndex: common.Big0}}, + nil) + + queryParams := url.Values{} + queryParams.Set(networkIDParam, "0") + queryParams.Set(globalExitRootParam, ger.Hex()) + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/claims-by-ger?%s", BridgeV1Prefix, queryParams.Encode())) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.ClaimsByGERResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Claims, 1) + require.Equal(t, boolPtr(true), response.Claims[0].BlockFinalized) + }) + + t.Run("root-by-ler", func(t *testing.T) { + ler := common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") + for _, expected := range []bool{true, false} { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectBridgeBlockFinalized(bridgeMocks.bridgeL2, 10, blockHash, expected) + bridgeMocks.bridgeL2.EXPECT(). + GetRootByLER(mock.Anything, ler). + Return(&tree.Root{Index: 1, BlockNum: 10, BlockHash: blockHash}, nil) + + queryParams := url.Values{} + queryParams.Set(networkIDParam, strconv.Itoa(int(l2NetworkID))) + queryParams.Set(lerParam, ler.Hex()) + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/root-by-ler?%s", BridgeV1Prefix, queryParams.Encode())) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.RootByLERResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, boolPtr(expected), response.BlockFinalized) + } + }) + + t.Run("settlements use the l1infotreesync finality", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{101: false, 100: true}) + bridgeMocks.l1InfoTree.EXPECT(). + GetVerifiedBatchesPaged(l2NetworkID, DefaultPage, DefaultPageSize). + Return([]*l1infotreesync.VerifiedBatchWithBlockHash{ + {BlockNumber: 101, BlockHash: &blockHash}, + {BlockNumber: 100, BlockHash: &blockHash}, + }, 2, nil) + + w := performRequest(t, bridgeMocks.router, fmt.Sprintf("%s/settlements", BridgeV1Prefix)) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.SettlementsResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Len(t, response.Settlements, 2) + require.Equal(t, boolPtr(false), response.Settlements[0].BlockFinalized) + require.Equal(t, boolPtr(true), response.Settlements[1].BlockFinalized) + }) + + t.Run("injected-l1-info-leaf on L1", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{101: false}) + bridgeMocks.l1InfoTree.EXPECT(). + GetInfoByIndex(mock.Anything, uint32(3)). + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 101, BlockHash: blockHash}, nil) + + queryParams := url.Values{} + queryParams.Set(networkIDParam, "0") + queryParams.Set(leafIndexParam, "3") + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/injected-l1-info-leaf?%s", BridgeV1Prefix, queryParams.Encode())) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.L1InfoTreeLeafResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, boolPtr(false), response.BlockFinalized) + }) + + t.Run("injected-l1-info-leaf omits block_finalized when l1infotreesync can't tell", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + bridgeMocks.l1InfoTree.EXPECT(). + GetInfoByIndex(mock.Anything, uint32(3)). + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 100}, nil) + + queryParams := url.Values{} + queryParams.Set(networkIDParam, "0") + queryParams.Set(leafIndexParam, "3") + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/injected-l1-info-leaf?%s", BridgeV1Prefix, queryParams.Encode())) + require.Equal(t, http.StatusOK, w.Code) + require.NotContains(t, w.Body.String(), "block_finalized") + }) + + t.Run("injected-l1-info-leaf on L2 needs the L1 leaf block and the L2 injection block", func(t *testing.T) { + const ( + l1Block = uint64(101) + l2Block = uint64(999) + ) + unknown := (*bool)(nil) + testCases := []struct { + name string + l1Finalized *bool + l2Finalized *bool + expected *bool + }{ + {"both finalized", boolPtr(true), boolPtr(true), boolPtr(true)}, + {"L2 not finalized", boolPtr(true), boolPtr(false), boolPtr(false)}, + {"L1 not finalized", boolPtr(false), boolPtr(true), boolPtr(false)}, + {"L2 unknown, L1 finalized", boolPtr(true), unknown, unknown}, + {"L2 unknown, L1 not finalized", boolPtr(false), unknown, boolPtr(false)}, + {"L1 unknown, L2 finalized", unknown, boolPtr(true), unknown}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + if tc.l1Finalized != nil { + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{l1Block: *tc.l1Finalized}) + } + bridgeMocks.injectedGERs.ExpectedCalls = nil + if tc.l2Finalized != nil { + bridgeMocks.injectedGERs.EXPECT().IsBlockFinalized(mock.Anything, l2Block, blockHash). + Return(*tc.l2Finalized, true, nil).Maybe() + } + bridgeMocks.injectedGERs.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything). + Return(false, false, nil).Maybe() + bridgeMocks.injectedGERs.EXPECT(). + GetFirstGERAfterL1InfoTreeIndex(mock.Anything, uint32(3)). + Return(l2gersync.GlobalExitRootInfo{ + L1InfoTreeIndex: 3, BlockNum: l2Block, BlockHash: blockHash, + }, nil) + bridgeMocks.l1InfoTree.EXPECT(). + GetInfoByIndex(mock.Anything, uint32(3)). + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: l1Block, BlockHash: blockHash}, nil) + + queryParams := url.Values{} + queryParams.Set(networkIDParam, strconv.Itoa(int(l2NetworkID))) + queryParams.Set(leafIndexParam, "3") + w := performRequest(t, bridgeMocks.router, + fmt.Sprintf("%s/injected-l1-info-leaf?%s", BridgeV1Prefix, queryParams.Encode())) + require.Equal(t, http.StatusOK, w.Code) + + var response bridgetypes.L1InfoTreeLeafResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, tc.expected, response.BlockFinalized) + }) + } + }) +} diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 7d38fd833..6a1a6fd9e 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -266,8 +266,10 @@ func (b *BridgeService) RegisterRoutes(router gin.IRouter) { // should inspect the SyncStatus/Details fields, not the HTTP status code. // // @Summary Get health status -// @Description Returns the health status, version information and summary bridge sync status of -// @Description the bridge service. Always responds 200 -- see SyncStatus/Details for sync health. +// @Description Returns the health status, version information, API revision and summary bridge sync +// @Description status of the bridge service. Always responds 200 -- see SyncStatus/Details for sync +// @Description health. api_revision is the bridge service wire contract version only, not the bridge +// @Description tracker's. // @Tags health // @Produce json // @Success 200 {object} types.HealthCheckResponse "Health status, version and sync status information" @@ -290,11 +292,12 @@ func (b *BridgeService) HealthCheckHandler(c *gin.Context) { c.JSON(http.StatusOK, types.HealthCheckResponse{ - Status: "ok", - Time: startTime.UTC(), - Version: version.Version, - SyncStatus: syncStatus, - Details: details, + Status: "ok", + Time: startTime.UTC(), + Version: version.Version, + APIRevision: CurrentAPIRevision, + SyncStatus: syncStatus, + Details: details, }) reportMetrics(metrics.GetHealthCheckReq, http.StatusOK, startTime) @@ -432,8 +435,11 @@ func (b *BridgeService) GetBridgesHandler(c *gin.Context) { b.logger.Debugf("successfully retrieved %d bridges for network %d", count, networkID) bridgeResponses := make([]*types.BridgeResponse, 0, len(bridges)) + bridger := b.bridgerForNetwork(networkID) for _, bridge := range bridges { - bridgeResponses = append(bridgeResponses, NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block)) + bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, bridger, bridge.BlockNum, bridge.BlockHash) + bridgeResponses = append(bridgeResponses, bridgeResponse) } c.JSON(statusCode, @@ -557,8 +563,10 @@ func (b *BridgeService) GetClaimsHandler(c *gin.Context) { // Use conditional function to create claim responses claimResponses := make([]*types.ClaimResponse, len(claims)) + claimer := b.claimerForNetwork(networkID) for i, claim := range claims { claimResponses[i] = NewClaimResponse(claim, includeAllFieldsFlag) + claimResponses[i].BlockFinalized = b.blockFinalized(ctx, claimer, claim.BlockNum, claim.BlockHash) } c.JSON(statusCode, @@ -1003,6 +1011,8 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { // actual block on the destination network where the GER got injected, and when), left nil for // the L1 (network_id=0) case var injectedL2BlockNumber, injectedL2Timestamp *uint64 + // injectedL2Finalized is whether that L2 injection block is finalized (nil if unknown) + var injectedL2Finalized *bool switch networkID { case mainnetNetworkID: @@ -1034,6 +1044,7 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { return } injectedL2BlockNumber = &e.BlockNum + injectedL2Finalized = b.blockFinalized(ctx, b.injectedGERs, e.BlockNum, e.BlockHash) injectedL2Timestamp = e.Timestamp default: b.logger.Warnf(errNetworkID, networkID) @@ -1053,6 +1064,11 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { resp := NewL1InfoTreeLeafResponse(l1InfoLeaf) resp.InjectedL2BlockNumber = injectedL2BlockNumber resp.InjectedL2BlockTimestamp = injectedL2Timestamp + // the leaf's L1 block and, if injected on an L2, that L2 block must both be finalized + resp.BlockFinalized = b.l1BlockFinalized(ctx, l1InfoLeaf.BlockNumber, l1InfoLeaf.BlockHash) + if injectedL2BlockNumber != nil { + resp.BlockFinalized = andFinalized(resp.BlockFinalized, injectedL2Finalized) + } statusCode = http.StatusOK c.JSON(statusCode, resp) @@ -2425,8 +2441,11 @@ func (b *BridgeService) GetClaimsByGERHandler(c *gin.Context) { } claimResponses := make([]*types.ClaimResponse, 0, len(claims)) + claimer := b.claimerForNetwork(networkID) for _, claim := range claims { - claimResponses = append(claimResponses, NewClaimResponse(claim, false)) + claimResponse := NewClaimResponse(claim, false) + claimResponse.BlockFinalized = b.blockFinalized(ctx, claimer, claim.BlockNum, claim.BlockHash) + claimResponses = append(claimResponses, claimResponse) } c.JSON(statusCode, types.ClaimsByGERResult{ Claims: claimResponses, @@ -2521,7 +2540,10 @@ func (b *BridgeService) GetBridgeByDepositCountHandler(c *gin.Context) { } etrogUpgradeL1Block := b.agglayerManagerUpgradeQuery.GetUpgradeBlock(ctx, etrogVersionID) - c.JSON(statusCode, NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block)) + bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, b.bridgerForNetwork(networkID), + bridge.BlockNum, bridge.BlockHash) + c.JSON(statusCode, bridgeResponse) } // GetBridgesByContentHandler retrieves bridges matching the given content fields for the specified network. @@ -2671,8 +2693,11 @@ func (b *BridgeService) GetBridgesByContentHandler(c *gin.Context) { etrogUpgradeL1Block := b.agglayerManagerUpgradeQuery.GetUpgradeBlock(ctx, etrogVersionID) bridgeResponses := make([]*types.BridgeResponse, 0, len(bridges)) + bridger := b.bridgerForNetwork(networkID) for _, bridge := range bridges { - bridgeResponses = append(bridgeResponses, NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block)) + bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, bridger, bridge.BlockNum, bridge.BlockHash) + bridgeResponses = append(bridgeResponses, bridgeResponse) } c.JSON(statusCode, types.BridgesByContentResult{ Bridges: bridgeResponses, @@ -2764,9 +2789,10 @@ func (b *BridgeService) RootByLERHandler(c *gin.Context) { } c.JSON(statusCode, types.RootByLERResponse{ - Index: root.Index, - BlockNum: root.BlockNum, - BlockPosition: root.BlockPosition, + Index: root.Index, + BlockNum: root.BlockNum, + BlockPosition: root.BlockPosition, + BlockFinalized: b.blockFinalized(ctx, bridger, root.BlockNum, root.BlockHash), }) } @@ -2800,7 +2826,7 @@ func (b *BridgeService) GetSettlementsHandler(c *gin.Context) { reportMetrics(metrics.GetSettlementsReq, statusCode, startTime) }() - _, cancel, pageNumber, pageSize, err := b.setupRequest(c) + ctx, cancel, pageNumber, pageSize, err := b.setupRequest(c) if err != nil { b.logger.Warnf(errSetupRequest, err) statusCode = http.StatusBadRequest @@ -2825,10 +2851,15 @@ func (b *BridgeService) GetSettlementsHandler(c *gin.Context) { settlements := make([]*types.SettlementResponse, 0, len(verified)) for _, v := range verified { + var blockHash common.Hash + if v.BlockHash != nil { + blockHash = *v.BlockHash + } settlement := &types.SettlementResponse{ NewLocalExitRoot: types.Hash(v.ExitRoot.Hex()), BlockNumber: v.BlockNumber, BlockTimestamp: v.BlockTimestamp, + BlockFinalized: b.l1BlockFinalized(ctx, v.BlockNumber, blockHash), } if v.TxHash != nil { txHash := types.Hash(v.TxHash.Hex()) diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index 8cfe4e93e..46258e752 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -26,6 +26,9 @@ type Bridger interface { GetContractDepositCount(ctx context.Context) (uint32, error) GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) GetLatestNetworkBlock(ctx context.Context) (uint64, error) + // IsBlockFinalized reports whether the block, as read from the syncer with the given hash, is + // finalized (can't be reorged). found is false when it can't be determined + IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (finalized, found bool, err error) IsActive(ctx context.Context) bool GetBridgeByDepositCount(ctx context.Context, depositCount uint32) (*bridgesync.Bridge, error) GetBridgesByContent(ctx context.Context, leafType uint8, originAddress common.Address, @@ -44,6 +47,9 @@ type Claimer interface { // GetLastProcessedBlock returns the last processed block and whether any block has been // processed yet. GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) + // IsBlockFinalized reports whether the block, as read from the syncer with the given hash, is + // finalized (can't be reorged). found is false when it can't be determined + IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (finalized, found bool, err error) } type L2GERSyncer interface { @@ -55,6 +61,9 @@ type L2GERSyncer interface { ) ([]*l2gersync.RemoveGEREvent, error) GetLastProcessedBlock(ctx context.Context) (uint64, error) GetLastGER(ctx context.Context) (l2gersync.GlobalExitRootInfo, error) + // IsBlockFinalized reports whether the block, as read from the syncer with the given hash, is + // finalized (can't be reorged). found is false when it can't be determined + IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (finalized, found bool, err error) } type L1InfoTreeSyncer interface { @@ -78,4 +87,7 @@ type L1InfoTreeSyncer interface { // GetLastProcessedBlock returns the last processed L1 block; 0 (nil error) if none yet. // Returns sync.ErrInconsistentState while the processor is halted. GetLastProcessedBlock(ctx context.Context) (uint64, error) + // IsBlockFinalized reports whether the L1 block, as read from the syncer with the given hash, is + // finalized (can't be reorged). found is false when it can't be determined + IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (finalized, found bool, err error) } diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index 451f9a307..e4a712d06 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -73,6 +73,14 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { WriteTimeout: 0, NetworkID: networkID, } + // by default the finality is unknown, so block_finalized is omitted from responses; + // tests covering it override this with expectFinalizedBlock + b.bridgeL1.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + b.bridgeL2.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + b.claimL1.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + b.claimL2.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + b.injectedGERs.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + expectL1BlocksFinalized(b.l1InfoTree, common.Hash{}, nil) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) b.router = gin.New() @@ -80,6 +88,40 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { return b } +// expectBridgeBlockFinalized replaces any IsBlockFinalized expectation of the bridger: the block with the +// given hash gets that finality, any other block is unknown (found=false). +func expectBridgeBlockFinalized(bridger *mocks.Bridger, blockNum uint64, blockHash common.Hash, finalized bool) { + clearIsBlockFinalized(bridger) + bridger.EXPECT().IsBlockFinalized(mock.Anything, blockNum, blockHash).Return(finalized, true, nil).Maybe() + bridger.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() +} + +func clearIsBlockFinalized(bridger *mocks.Bridger) { + expected := bridger.ExpectedCalls[:0] + for _, call := range bridger.ExpectedCalls { + if call.Method != "IsBlockFinalized" { + expected = append(expected, call) + } + } + bridger.ExpectedCalls = expected +} + +// expectL1BlocksFinalized replaces any IsBlockFinalized expectation of the l1infotreesync mock: the +// blocks in finality (with the given hash) get that finality, any other block is unknown (found=false). +func expectL1BlocksFinalized(l1InfoTree *mocks.L1InfoTreeSyncer, blockHash common.Hash, finality map[uint64]bool) { + expected := l1InfoTree.ExpectedCalls[:0] + for _, call := range l1InfoTree.ExpectedCalls { + if call.Method != "IsBlockFinalized" { + expected = append(expected, call) + } + } + l1InfoTree.ExpectedCalls = expected + for blockNum, finalized := range finality { + l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, blockNum, blockHash).Return(finalized, true, nil).Maybe() + } + l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() +} + // newBridgeWithMocksAndHealthCacheTTL is newBridgeWithMocks with an explicit // Config.HealthCheckCacheTTL, for tests that exercise HealthCheckHandler's cache directly // (cache-hit collapsing concurrent/repeated calls into one computation, and cache expiry @@ -3949,6 +3991,8 @@ func TestHealthCheckHandler(t *testing.T) { require.Equal(t, "ok", response.Status) require.NotEmpty(t, response.Time) require.NotEmpty(t, response.Version) + require.Equal(t, CurrentAPIRevision, response.APIRevision) + require.Contains(t, w.Body.String(), `"api_revision":`+strconv.Itoa(CurrentAPIRevision)) require.Equal(t, bridgetypes.HealthSyncStatusDone, response.SyncStatus) require.NotNil(t, response.Details.L1) @@ -4003,6 +4047,7 @@ func TestHealthCheckHandler_SyncStatusDerivation(t *testing.T) { var response bridgetypes.HealthCheckResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, CurrentAPIRevision, response.APIRevision, "api_revision is present in every sync_status") require.Equal(t, bridgetypes.HealthSyncStatusPending, response.SyncStatus) require.NotNil(t, response.Details.L1.IsSynced) @@ -4030,6 +4075,7 @@ func TestHealthCheckHandler_SyncStatusDerivation(t *testing.T) { var response bridgetypes.HealthCheckResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, CurrentAPIRevision, response.APIRevision, "api_revision is present in every sync_status") require.Equal(t, bridgetypes.HealthSyncStatusError, response.SyncStatus) require.NotNil(t, response.Details.L1) @@ -4066,6 +4112,7 @@ func TestHealthCheckHandler_SyncStatusDerivation(t *testing.T) { var response bridgetypes.HealthCheckResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, CurrentAPIRevision, response.APIRevision, "api_revision is present in every sync_status") require.Equal(t, bridgetypes.HealthSyncStatusError, response.SyncStatus) require.NotNil(t, response.Details.L1) @@ -4101,6 +4148,7 @@ func TestHealthCheckHandler_SyncStatusDerivation(t *testing.T) { var response bridgetypes.HealthCheckResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, CurrentAPIRevision, response.APIRevision, "api_revision is present in every sync_status") require.Equal(t, bridgetypes.HealthSyncStatusDone, response.SyncStatus) require.Nil(t, response.Details.L1, "nil-configured syncer must be omitted, not reported as inactive/error") diff --git a/bridgeservice/client/client_test.go b/bridgeservice/client/client_test.go index 7f3b861c5..59191b7fa 100644 --- a/bridgeservice/client/client_test.go +++ b/bridgeservice/client/client_test.go @@ -49,9 +49,10 @@ func TestNew(t *testing.T) { func TestHealthCheck(t *testing.T) { t.Run("successful health check", func(t *testing.T) { expectedResp := &types.HealthCheckResponse{ - Status: "OK", - Time: time.Now(), - Version: "1.0.0", + Status: "OK", + Time: time.Now(), + Version: "1.0.0", + APIRevision: 3, } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -70,6 +71,7 @@ func TestHealthCheck(t *testing.T) { require.NotNil(t, resp) require.Equal(t, expectedResp.Status, resp.Status) require.Equal(t, expectedResp.Version, resp.Version) + require.Equal(t, expectedResp.APIRevision, resp.APIRevision) }) t.Run("handles server error", func(t *testing.T) { diff --git a/bridgeservice/docs/API_REVISIONS.md b/bridgeservice/docs/API_REVISIONS.md new file mode 100644 index 000000000..499cf9693 --- /dev/null +++ b/bridgeservice/docs/API_REVISIONS.md @@ -0,0 +1,59 @@ +# Bridge service API revisions + +The bridge service reports the version of its wire API contract as `api_revision` in the health endpoint +(`GET /` and `GET /health`), backed by `bridgeservice.CurrentAPIRevision` (`bridgeservice/revision.go`): + +```bash +curl -s /health | jq .api_revision +``` + +It lets a client tell which contract an instance speaks without diffing response bodies, and pick the right +behaviour per instance. It is purely informational: the service never rejects or alters its behaviour based on +it. The field is always present, whatever the `sync_status`. + +This is the bridge service's own counter, independent of the bridge tracker's `api_revision` +(`GET /tracker/v1/health`, see `bridgetracker/api/revision.go`). The two numbers are unrelated. + +For the full description of every endpoint see the swagger files in this directory +(`swagger.json` / `swagger.yaml`) and [docs/bridge_service.md](../../docs/bridge_service.md). + +## When to bump the revision + +Bump `CurrentAPIRevision` by one, in the same PR, whenever a change could break an existing client: + +- a response field added, removed or renamed, even an optional one: it is how a client tells that an instance + has the field; +- the set of values of an enum-like field changed; +- a query parameter whose absence changes the response shape, or whose default changed; +- an endpoint added, removed or renamed; +- any behaviour change an existing client could trip over (ordering, pagination, filtering semantics, HTTP + status codes...). + +Then add an entry for the new revision to this file **and** to the revision history in +`bridgeservice/revision.go`. `TestCurrentAPIRevisionHasHistoryEntry` fails if the constant, the history in +`revision.go` and this file get out of sync. + +Revisions before `api_revision` existed are not back-filled: revision 1 is the contract of the release that +introduced the field. + +## Revision history + +Newest first. Each entry lists what changed with respect to the previous revision and what a client should +do about it. + +### Revision 1 + +The contract of the release that introduced `api_revision`; there is nothing to compare it with. Instances +older than this release don't return `api_revision` at all, so for a client: + +- no `api_revision` in `GET /` / `GET /health`: an instance from before this contract, which has none of the + fields below; +- `api_revision` >= 1: the instance has everything listed here. + +What revision 1 contains that older instances don't: + +- `GET /` and `GET /health` return `api_revision`. +- Responses tied to a block carry an optional `block_finalized` (`true`, `false` or absent): `/bridges`, + `/bridge-by-deposit-count`, `/bridges-by-content`, `/claims`, `/claims-by-ger`, `/root-by-ler`, + `/settlements` and `/injected-l1-info-leaf`. It is absent when the syncer that served the item can't + determine it, so an absent value must be read as "unknown", not as `false`. diff --git a/bridgeservice/docs/docs.go b/bridgeservice/docs/docs.go index 4b173cd5c..759ada249 100644 --- a/bridgeservice/docs/docs.go +++ b/bridgeservice/docs/docs.go @@ -24,7 +24,7 @@ const docTemplate = `{ "paths": { "/": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -620,7 +620,7 @@ const docTemplate = `{ }, "/health": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -1506,6 +1506,11 @@ const docTemplate = `{ "description": "Contains basic health‐check information for the bridge service", "type": "object", "properties": { + "api_revision": { + "description": "APIRevision is the bridge service's wire API contract version (see\nbridgeservice.CurrentAPIRevision), bumped whenever a change could break an existing client, so a\nclient can tell which contract shape the responding instance speaks. It is independent of the\nbridge tracker's own api_revision. Purely informational, and always present, whatever the\nSyncStatus.", + "type": "integer", + "example": 1 + }, "details": { "description": "Details gives a compact per-component breakdown backing SyncStatus. It intentionally\ndoes not duplicate the full SyncStatus (deposit counts, block numbers) payload -- callers\nwanting that detail should use GET /bridge/v1/sync-status.", "allOf": [ @@ -1542,6 +1547,11 @@ const docTemplate = `{ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1707,6 +1717,11 @@ const docTemplate = `{ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1936,6 +1951,11 @@ const docTemplate = `{ "types.L1InfoTreeLeafResponse": { "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block(s) this leaf depends on are finalized (reorg-safe): the L1 block\nof the leaf and, for an L2 lookup, the L2 block where the GER was injected too. Only set by the\ninjected-l1-info-leaf endpoint; omitted when the syncer that served the leaf cannot determine it (e.g. L2\nchains synced by l2gersync in Legacy mode, where the injection block is only approximate).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2273,6 +2293,11 @@ const docTemplate = `{ "description": "The deposit-count position of a local exit root in its network's local exit tree", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "BlockNum is the block in which the leaf that produced this root was added", "type": "integer", @@ -2343,6 +2368,11 @@ const docTemplate = `{ "description": "A single L1 settlement event for this network's local exit root", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_hash": { "description": "L1 block's hash, when available (nil if the block table has no recorded hash for it).", "type": "string", diff --git a/bridgeservice/docs/swagger.json b/bridgeservice/docs/swagger.json index 347a0d991..c84ddf22c 100644 --- a/bridgeservice/docs/swagger.json +++ b/bridgeservice/docs/swagger.json @@ -17,7 +17,7 @@ "paths": { "/": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -613,7 +613,7 @@ }, "/health": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -1499,6 +1499,11 @@ "description": "Contains basic health‐check information for the bridge service", "type": "object", "properties": { + "api_revision": { + "description": "APIRevision is the bridge service's wire API contract version (see\nbridgeservice.CurrentAPIRevision), bumped whenever a change could break an existing client, so a\nclient can tell which contract shape the responding instance speaks. It is independent of the\nbridge tracker's own api_revision. Purely informational, and always present, whatever the\nSyncStatus.", + "type": "integer", + "example": 1 + }, "details": { "description": "Details gives a compact per-component breakdown backing SyncStatus. It intentionally\ndoes not duplicate the full SyncStatus (deposit counts, block numbers) payload -- callers\nwanting that detail should use GET /bridge/v1/sync-status.", "allOf": [ @@ -1535,6 +1540,11 @@ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1700,6 +1710,11 @@ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1929,6 +1944,11 @@ "types.L1InfoTreeLeafResponse": { "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block(s) this leaf depends on are finalized (reorg-safe): the L1 block\nof the leaf and, for an L2 lookup, the L2 block where the GER was injected too. Only set by the\ninjected-l1-info-leaf endpoint; omitted when the syncer that served the leaf cannot determine it (e.g. L2\nchains synced by l2gersync in Legacy mode, where the injection block is only approximate).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2266,6 +2286,11 @@ "description": "The deposit-count position of a local exit root in its network's local exit tree", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "BlockNum is the block in which the leaf that produced this root was added", "type": "integer", @@ -2336,6 +2361,11 @@ "description": "A single L1 settlement event for this network's local exit root", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_hash": { "description": "L1 block's hash, when available (nil if the block table has no recorded hash for it).", "type": "string", diff --git a/bridgeservice/docs/swagger.yaml b/bridgeservice/docs/swagger.yaml index 4ad0e98a7..24ca5ec43 100644 --- a/bridgeservice/docs/swagger.yaml +++ b/bridgeservice/docs/swagger.yaml @@ -196,6 +196,15 @@ definitions: github_com_agglayer_aggkit_bridgeservice_types.HealthCheckResponse: description: Contains basic health‐check information for the bridge service properties: + api_revision: + description: |- + APIRevision is the bridge service's wire API contract version (see + bridgeservice.CurrentAPIRevision), bumped whenever a change could break an existing client, so a + client can tell which contract shape the responding instance speaks. It is independent of the + bridge tracker's own api_revision. Purely informational, and always present, whatever the + SyncStatus. + example: 1 + type: integer details: allOf: - $ref: '#/definitions/types.HealthCheckDetails' @@ -225,6 +234,12 @@ definitions: description: Amount of tokens being bridged example: "1000000000000000000" type: string + block_finalized: + description: |- + BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + It is omitted when the syncer that served the item cannot determine it. + example: true + type: boolean block_num: description: Block number where the bridge event was recorded example: 1234 @@ -353,6 +368,12 @@ definitions: description: Amount claimed example: "1000000000000000000" type: string + block_finalized: + description: |- + BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + It is omitted when the syncer that served the item cannot determine it. + example: true + type: boolean block_num: description: Block number where the claim was processed example: 1234 @@ -538,6 +559,14 @@ definitions: type: object types.L1InfoTreeLeafResponse: properties: + block_finalized: + description: |- + BlockFinalized reports whether the block(s) this leaf depends on are finalized (reorg-safe): the L1 block + of the leaf and, for an L2 lookup, the L2 block where the GER was injected too. Only set by the + injected-l1-info-leaf endpoint; omitted when the syncer that served the leaf cannot determine it (e.g. L2 + chains synced by l2gersync in Legacy mode, where the injection block is only approximate). + example: true + type: boolean block_num: description: Block number where the leaf was recorded example: 123456 @@ -821,6 +850,12 @@ definitions: description: The deposit-count position of a local exit root in its network's local exit tree properties: + block_finalized: + description: |- + BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + It is omitted when the syncer that served the item cannot determine it. + example: true + type: boolean block_num: description: BlockNum is the block in which the leaf that produced this root was added @@ -877,6 +912,12 @@ definitions: types.SettlementResponse: description: A single L1 settlement event for this network's local exit root properties: + block_finalized: + description: |- + BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + It is omitted when the syncer that served the item cannot determine it. + example: true + type: boolean block_hash: description: L1 block's hash, when available (nil if the block table has no recorded hash for it). @@ -1082,8 +1123,10 @@ paths: /: get: description: |- - Returns the health status, version information and summary bridge sync status of - the bridge service. Always responds 200 -- see SyncStatus/Details for sync health. + Returns the health status, version information, API revision and summary bridge sync + status of the bridge service. Always responds 200 -- see SyncStatus/Details for sync + health. api_revision is the bridge service wire contract version only, not the bridge + tracker's. produces: - application/json responses: @@ -1521,8 +1564,10 @@ paths: /health: get: description: |- - Returns the health status, version information and summary bridge sync status of - the bridge service. Always responds 200 -- see SyncStatus/Details for sync health. + Returns the health status, version information, API revision and summary bridge sync + status of the bridge service. Always responds 200 -- see SyncStatus/Details for sync + health. api_revision is the bridge service wire contract version only, not the bridge + tracker's. produces: - application/json responses: diff --git a/bridgeservice/mocks/mock_bridger.go b/bridgeservice/mocks/mock_bridger.go index bc837ea17..5a095c675 100644 --- a/bridgeservice/mocks/mock_bridger.go +++ b/bridgeservice/mocks/mock_bridger.go @@ -5,15 +5,12 @@ package mocks import ( big "math/big" - bridgesync "github.com/agglayer/aggkit/bridgesync" - - common "github.com/ethereum/go-ethereum/common" - context "context" - mock "github.com/stretchr/testify/mock" - + bridgesync "github.com/agglayer/aggkit/bridgesync" types "github.com/agglayer/aggkit/tree/types" + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" ) // Bridger is an autogenerated mock type for the Bridger type @@ -813,6 +810,71 @@ func (_c *Bridger_IsActive_Call) RunAndReturn(run func(context.Context) bool) *B return _c } +// IsBlockFinalized provides a mock function with given fields: ctx, blockNum, blockHash +func (_m *Bridger) IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (bool, bool, error) { + ret := _m.Called(ctx, blockNum, blockHash) + + if len(ret) == 0 { + panic("no return value specified for IsBlockFinalized") + } + + var r0 bool + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) (bool, bool, error)); ok { + return rf(ctx, blockNum, blockHash) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) bool); ok { + r0 = rf(ctx, blockNum, blockHash) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Hash) bool); ok { + r1 = rf(ctx, blockNum, blockHash) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Hash) error); ok { + r2 = rf(ctx, blockNum, blockHash) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Bridger_IsBlockFinalized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsBlockFinalized' +type Bridger_IsBlockFinalized_Call struct { + *mock.Call +} + +// IsBlockFinalized is a helper method to define mock.On call +// - ctx context.Context +// - blockNum uint64 +// - blockHash common.Hash +func (_e *Bridger_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}, blockHash interface{}) *Bridger_IsBlockFinalized_Call { + return &Bridger_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum, blockHash)} +} + +func (_c *Bridger_IsBlockFinalized_Call) Run(run func(ctx context.Context, blockNum uint64, blockHash common.Hash)) *Bridger_IsBlockFinalized_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Hash)) + }) + return _c +} + +func (_c *Bridger_IsBlockFinalized_Call) Return(finalized bool, found bool, err error) *Bridger_IsBlockFinalized_Call { + _c.Call.Return(finalized, found, err) + return _c +} + +func (_c *Bridger_IsBlockFinalized_Call) RunAndReturn(run func(context.Context, uint64, common.Hash) (bool, bool, error)) *Bridger_IsBlockFinalized_Call { + _c.Call.Return(run) + return _c +} + // NewBridger creates a new instance of Bridger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewBridger(t interface { diff --git a/bridgeservice/mocks/mock_claimer.go b/bridgeservice/mocks/mock_claimer.go index d8eb9bf64..3a05a9d3b 100644 --- a/bridgeservice/mocks/mock_claimer.go +++ b/bridgeservice/mocks/mock_claimer.go @@ -5,13 +5,11 @@ package mocks import ( big "math/big" - common "github.com/ethereum/go-ethereum/common" - context "context" - mock "github.com/stretchr/testify/mock" - types "github.com/agglayer/aggkit/claimsync/types" + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" ) // Claimer is an autogenerated mock type for the Claimer type @@ -354,6 +352,71 @@ func (_c *Claimer_GetUnsetClaimsPaged_Call) RunAndReturn(run func(context.Contex return _c } +// IsBlockFinalized provides a mock function with given fields: ctx, blockNum, blockHash +func (_m *Claimer) IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (bool, bool, error) { + ret := _m.Called(ctx, blockNum, blockHash) + + if len(ret) == 0 { + panic("no return value specified for IsBlockFinalized") + } + + var r0 bool + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) (bool, bool, error)); ok { + return rf(ctx, blockNum, blockHash) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) bool); ok { + r0 = rf(ctx, blockNum, blockHash) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Hash) bool); ok { + r1 = rf(ctx, blockNum, blockHash) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Hash) error); ok { + r2 = rf(ctx, blockNum, blockHash) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Claimer_IsBlockFinalized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsBlockFinalized' +type Claimer_IsBlockFinalized_Call struct { + *mock.Call +} + +// IsBlockFinalized is a helper method to define mock.On call +// - ctx context.Context +// - blockNum uint64 +// - blockHash common.Hash +func (_e *Claimer_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}, blockHash interface{}) *Claimer_IsBlockFinalized_Call { + return &Claimer_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum, blockHash)} +} + +func (_c *Claimer_IsBlockFinalized_Call) Run(run func(ctx context.Context, blockNum uint64, blockHash common.Hash)) *Claimer_IsBlockFinalized_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Hash)) + }) + return _c +} + +func (_c *Claimer_IsBlockFinalized_Call) Return(finalized bool, found bool, err error) *Claimer_IsBlockFinalized_Call { + _c.Call.Return(finalized, found, err) + return _c +} + +func (_c *Claimer_IsBlockFinalized_Call) RunAndReturn(run func(context.Context, uint64, common.Hash) (bool, bool, error)) *Claimer_IsBlockFinalized_Call { + _c.Call.Return(run) + return _c +} + // NewClaimer creates a new instance of Claimer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewClaimer(t interface { diff --git a/bridgeservice/mocks/mock_l1_info_tree_syncer.go b/bridgeservice/mocks/mock_l1_info_tree_syncer.go index 82441a45b..3eaf8a506 100644 --- a/bridgeservice/mocks/mock_l1_info_tree_syncer.go +++ b/bridgeservice/mocks/mock_l1_info_tree_syncer.go @@ -5,13 +5,10 @@ package mocks import ( context "context" - common "github.com/ethereum/go-ethereum/common" - l1infotreesync "github.com/agglayer/aggkit/l1infotreesync" - - mock "github.com/stretchr/testify/mock" - types "github.com/agglayer/aggkit/tree/types" + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" ) // L1InfoTreeSyncer is an autogenerated mock type for the L1InfoTreeSyncer type @@ -897,6 +894,71 @@ func (_c *L1InfoTreeSyncer_IsActive_Call) RunAndReturn(run func(context.Context) return _c } +// IsBlockFinalized provides a mock function with given fields: ctx, blockNum, blockHash +func (_m *L1InfoTreeSyncer) IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (bool, bool, error) { + ret := _m.Called(ctx, blockNum, blockHash) + + if len(ret) == 0 { + panic("no return value specified for IsBlockFinalized") + } + + var r0 bool + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) (bool, bool, error)); ok { + return rf(ctx, blockNum, blockHash) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) bool); ok { + r0 = rf(ctx, blockNum, blockHash) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Hash) bool); ok { + r1 = rf(ctx, blockNum, blockHash) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Hash) error); ok { + r2 = rf(ctx, blockNum, blockHash) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// L1InfoTreeSyncer_IsBlockFinalized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsBlockFinalized' +type L1InfoTreeSyncer_IsBlockFinalized_Call struct { + *mock.Call +} + +// IsBlockFinalized is a helper method to define mock.On call +// - ctx context.Context +// - blockNum uint64 +// - blockHash common.Hash +func (_e *L1InfoTreeSyncer_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}, blockHash interface{}) *L1InfoTreeSyncer_IsBlockFinalized_Call { + return &L1InfoTreeSyncer_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum, blockHash)} +} + +func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) Run(run func(ctx context.Context, blockNum uint64, blockHash common.Hash)) *L1InfoTreeSyncer_IsBlockFinalized_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Hash)) + }) + return _c +} + +func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) Return(finalized bool, found bool, err error) *L1InfoTreeSyncer_IsBlockFinalized_Call { + _c.Call.Return(finalized, found, err) + return _c +} + +func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) RunAndReturn(run func(context.Context, uint64, common.Hash) (bool, bool, error)) *L1InfoTreeSyncer_IsBlockFinalized_Call { + _c.Call.Return(run) + return _c +} + // NewL1InfoTreeSyncer creates a new instance of L1InfoTreeSyncer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewL1InfoTreeSyncer(t interface { diff --git a/bridgeservice/mocks/mock_l2_ger_syncer.go b/bridgeservice/mocks/mock_l2_ger_syncer.go index 01c4f02d9..9158d1fbb 100644 --- a/bridgeservice/mocks/mock_l2_ger_syncer.go +++ b/bridgeservice/mocks/mock_l2_ger_syncer.go @@ -5,10 +5,8 @@ package mocks import ( context "context" - common "github.com/ethereum/go-ethereum/common" - l2gersync "github.com/agglayer/aggkit/l2gersync" - + common "github.com/ethereum/go-ethereum/common" mock "github.com/stretchr/testify/mock" ) @@ -254,6 +252,71 @@ func (_c *L2GERSyncer_GetRemoveGEREvents_Call) RunAndReturn(run func(context.Con return _c } +// IsBlockFinalized provides a mock function with given fields: ctx, blockNum, blockHash +func (_m *L2GERSyncer) IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (bool, bool, error) { + ret := _m.Called(ctx, blockNum, blockHash) + + if len(ret) == 0 { + panic("no return value specified for IsBlockFinalized") + } + + var r0 bool + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) (bool, bool, error)); ok { + return rf(ctx, blockNum, blockHash) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, common.Hash) bool); ok { + r0 = rf(ctx, blockNum, blockHash) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, common.Hash) bool); ok { + r1 = rf(ctx, blockNum, blockHash) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Hash) error); ok { + r2 = rf(ctx, blockNum, blockHash) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// L2GERSyncer_IsBlockFinalized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsBlockFinalized' +type L2GERSyncer_IsBlockFinalized_Call struct { + *mock.Call +} + +// IsBlockFinalized is a helper method to define mock.On call +// - ctx context.Context +// - blockNum uint64 +// - blockHash common.Hash +func (_e *L2GERSyncer_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}, blockHash interface{}) *L2GERSyncer_IsBlockFinalized_Call { + return &L2GERSyncer_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum, blockHash)} +} + +func (_c *L2GERSyncer_IsBlockFinalized_Call) Run(run func(ctx context.Context, blockNum uint64, blockHash common.Hash)) *L2GERSyncer_IsBlockFinalized_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Hash)) + }) + return _c +} + +func (_c *L2GERSyncer_IsBlockFinalized_Call) Return(finalized bool, found bool, err error) *L2GERSyncer_IsBlockFinalized_Call { + _c.Call.Return(finalized, found, err) + return _c +} + +func (_c *L2GERSyncer_IsBlockFinalized_Call) RunAndReturn(run func(context.Context, uint64, common.Hash) (bool, bool, error)) *L2GERSyncer_IsBlockFinalized_Call { + _c.Call.Return(run) + return _c +} + // NewL2GERSyncer creates a new instance of L2GERSyncer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewL2GERSyncer(t interface { diff --git a/bridgeservice/revision.go b/bridgeservice/revision.go new file mode 100644 index 000000000..063a10024 --- /dev/null +++ b/bridgeservice/revision.go @@ -0,0 +1,21 @@ +package bridgeservice + +// CurrentAPIRevision is the bridge service's wire API contract version, returned by GET / and +// GET /health as HealthCheckResponse.APIRevision. Bump it by one whenever a change could break an +// existing client parsing the bridge service's responses — a field added/removed/renamed, an enum's +// value set changed, a query parameter whose absence changes the response shape, or any behavior +// change an existing client could trip over — so a client can tell which contract shape an instance +// speaks without diffing full response bodies against its own expectations. Purely informational: +// the bridge service itself never rejects or alters behavior based on it. +// +// It is independent of the bridge tracker's own revision (bridgetracker/api.CurrentAPIRevision): each +// service keeps its own counter, and this one only refers to the bridge service contract. +// +// Every revision is detailed in bridgeservice/docs/API_REVISIONS.md, which must get an entry together +// with the bump. +// +// Revision history: +// - 1: first revision, the contract as of the release that introduced api_revision, which also +// introduced the optional block_finalized field on the block-related responses. Earlier changes +// are not back-filled +const CurrentAPIRevision = 1 diff --git a/bridgeservice/revision_test.go b/bridgeservice/revision_test.go new file mode 100644 index 000000000..d08017e39 --- /dev/null +++ b/bridgeservice/revision_test.go @@ -0,0 +1,43 @@ +package bridgeservice + +import ( + "os" + "regexp" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +// latestRevision returns the highest revision number matched by the first capture group of re in the file +func latestRevision(t *testing.T, file string, re *regexp.Regexp) int { + t.Helper() + + content, err := os.ReadFile(file) + require.NoError(t, err) + + entries := re.FindAllStringSubmatch(string(content), -1) + require.NotEmpty(t, entries, "%s has no revision history entry", file) + + latest := 0 + for _, entry := range entries { + revision, err := strconv.Atoi(entry[1]) + require.NoError(t, err) + latest = max(latest, revision) + } + return latest +} + +// TestCurrentAPIRevisionHasHistoryEntry keeps CurrentAPIRevision in sync with the revision history of +// revision.go and with the detailed changes in docs/API_REVISIONS.md: the latest entry of each must be +// the current revision. +func TestCurrentAPIRevisionHasHistoryEntry(t *testing.T) { + const bumpHint = "CurrentAPIRevision changed without a matching entry in %s" + + require.Equal(t, CurrentAPIRevision, + latestRevision(t, "revision.go", regexp.MustCompile(`(?m)^// - (\d+):`)), + bumpHint, "the revision history of revision.go") + require.Equal(t, CurrentAPIRevision, + latestRevision(t, "docs/API_REVISIONS.md", regexp.MustCompile(`(?m)^### Revision (\d+)\s*$`)), + bumpHint, "docs/API_REVISIONS.md") +} diff --git a/bridgeservice/types/types.go b/bridgeservice/types/types.go index 0d7341aa0..5bb2bb8f1 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -173,6 +173,10 @@ type BridgeResponse struct { // Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address. ToAddress Address `json:"to_address" example:"0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0"` + + // BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + // It is omitted when the syncer that served the item cannot determine it. + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // ClaimsResult contains the list of claim records and the total count @@ -238,6 +242,10 @@ type ClaimResponse struct { // IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0) IsMessage bool `json:"is_message" example:"false"` + + // BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + // It is omitted when the syncer that served the item cannot determine it. + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // ClaimsByGERResult contains the claims matching a given global exit root and their count @@ -387,6 +395,12 @@ type L1InfoTreeLeafResponse struct { // on a later request. Always nil for L2 chains synced by l2gersync in Legacy mode, where // InjectedL2BlockNumber is only approximate and no accurate timestamp can be derived from it InjectedL2BlockTimestamp *uint64 `json:"injected_l2_block_timestamp,omitempty" example:"1684500123"` + + // BlockFinalized reports whether the block(s) this leaf depends on are finalized (reorg-safe): the L1 block + // of the leaf and, for an L2 lookup, the L2 block where the GER was injected too. Only set by the + // injected-l1-info-leaf endpoint; omitted when the syncer that served the leaf cannot determine it (e.g. L2 + // chains synced by l2gersync in Legacy mode, where the injection block is only approximate). + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // SyncStatus represents the bridge synchronization status for both L1 and L2 networks @@ -442,8 +456,8 @@ type SyncerSyncInfo struct { // HealthCheckResponse represents the JSON returned by HealthCheckHandler. // @Description Contains basic health‐check information for the bridge service -// including service status, current time, version, and a summary sync status. -// @example {"status":"ok","time":"2025-06-05T07:30:00Z","version":"v0.4.0-beta9", +// including service status, current time, version, API revision, and a summary sync status. +// @example {"status":"ok","time":"2025-06-05T07:30:00Z","version":"v0.4.0-beta9","api_revision":1, // "sync_status":"pending","details":{"l1":{"is_active":true,"is_synced":false,"is_halted":false}, // "l2":{"is_active":true,"is_synced":true,"is_halted":false},"l2_ger":{"is_active":true,"is_halted":false}, // "l1_info_tree":{"is_active":true,"is_halted":false}}} @@ -452,6 +466,13 @@ type HealthCheckResponse struct { Time time.Time `json:"time"` Version string `json:"version"` + // APIRevision is the bridge service's wire API contract version (see + // bridgeservice.CurrentAPIRevision), bumped whenever a change could break an existing client, so a + // client can tell which contract shape the responding instance speaks. It is independent of the + // bridge tracker's own api_revision. Purely informational, and always present, whatever the + // SyncStatus. + APIRevision int `json:"api_revision" example:"1"` + // SyncStatus summarizes bridge synchronization health as one of "done", "pending" or // "error". It never affects the HTTP status code of this endpoint (always 200) so that // liveness/routing probes never evict a healthy-but-lagging instance -- see @@ -559,6 +580,10 @@ type SettlementResponse struct { // L1 block's hash, when available (nil if the block table has no recorded hash for it). BlockHash *Hash `json:"block_hash,omitempty" example:"0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890a"` //nolint:lll + + // BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + // It is omitted when the syncer that served the item cannot determine it. + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // SettlementsResult contains a page of this network's L1 settlement history, most recent first, @@ -647,6 +672,10 @@ type RootByLERResponse struct { // BlockPosition orders same-block roots (multiple deposits in one block) BlockPosition uint64 `json:"block_position" example:"0"` + + // BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. + // It is omitted when the syncer that served the item cannot determine it. + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // PublicConfigResponse is a sanitized view of the bridge service configuration: only the diff --git a/bridgeservice/utils.go b/bridgeservice/utils.go index d186a9e61..e437a7c9e 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -1,6 +1,7 @@ package bridgeservice import ( + "context" "encoding/hex" "fmt" "math/big" @@ -10,6 +11,7 @@ import ( "github.com/agglayer/aggkit/bridgesync" claimsynctypes "github.com/agglayer/aggkit/claimsync/types" "github.com/agglayer/aggkit/l1infotreesync" + "github.com/ethereum/go-ethereum/common" "github.com/gin-gonic/gin" ) @@ -225,3 +227,72 @@ func NewL1InfoTreeLeafResponse(leaf *l1infotreesync.L1InfoTreeLeaf) *bridgetypes Hash: bridgetypes.Hash(leaf.Hash.Hex()), } } + +// bridgerForNetwork returns the bridge syncer of the given network (0 = L1, or this instance's L2), nil if none +func (b *BridgeService) bridgerForNetwork(networkID uint32) Bridger { + switch networkID { + case mainnetNetworkID: + return b.bridgeL1 + case b.networkID: + return b.bridgeL2 + } + return nil +} + +// claimerForNetwork returns the claim syncer of the given network (0 = L1, or this instance's L2), nil if none +func (b *BridgeService) claimerForNetwork(networkID uint32) Claimer { + switch networkID { + case mainnetNetworkID: + return b.claimL1 + case b.networkID: + return b.claimL2 + } + return nil +} + +// blockFinalitySource is implemented by the syncers that can tell if a block they served is finalized +type blockFinalitySource interface { + IsBlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) (finalized, found bool, err error) +} + +// blockFinalized asks the syncer whether a block it served (identified by number and hash) is finalized. +// It returns nil (unknown) if the syncer can't tell or fails, so the response omits block_finalized. +func (b *BridgeService) blockFinalized(ctx context.Context, syncer blockFinalitySource, blockNum uint64, + blockHash common.Hash) *bool { + if syncer == nil { + return nil + } + finalized, found, err := syncer.IsBlockFinalized(ctx, blockNum, blockHash) + if err != nil { + b.logger.Warnf("unable to get the finality of block %d, block_finalized will be omitted: %v", blockNum, err) + return nil + } + if !found { + return nil + } + return &finalized +} + +// l1BlockFinalized asks the l1infotreesync syncer whether an L1 block it served (identified by number and +// hash) is finalized. It returns nil (unknown) if the syncer can't tell or fails, so the response omits +// block_finalized. +func (b *BridgeService) l1BlockFinalized(ctx context.Context, blockNum uint64, blockHash common.Hash) *bool { + if b.l1InfoTree == nil { + return nil + } + return b.blockFinalized(ctx, b.l1InfoTree, blockNum, blockHash) +} + +// andFinalized combines the finality of the blocks a response depends on: false if any of them is not +// finalized, unknown (nil) if none is known to be not finalized but any is unknown, true if all are +func andFinalized(a, b *bool) *bool { + if (a != nil && !*a) || (b != nil && !*b) { + finalized := false + return &finalized + } + if a == nil || b == nil { + return nil + } + finalized := true + return &finalized +} diff --git a/bridgeservicefinder/health_body_test.go b/bridgeservicefinder/health_body_test.go new file mode 100644 index 000000000..64a5b90a0 --- /dev/null +++ b/bridgeservicefinder/health_body_test.go @@ -0,0 +1,49 @@ +package bridgeservicefinder + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/agglayer/aggkit/log" + "github.com/stretchr/testify/require" +) + +// TestHTTPHealthChecker_IgnoresResponseBody checks that the default health checker only looks at the +// status code, so new fields in the bridge service health response (e.g. api_revision) can't break it, +// and neither does an older instance that doesn't return them. +func TestHTTPHealthChecker_IgnoresResponseBody(t *testing.T) { + testCases := []struct { + name string + body string + }{ + { + name: "instance with api_revision", + body: `{"status":"ok","time":"2025-06-05T07:30:00Z","version":"v0.11.0","api_revision":1,` + + `"sync_status":"done","details":{}}`, + }, + { + name: "instance without api_revision", + body: `{"status":"ok","time":"2025-06-05T07:30:00Z","version":"v0.4.0","sync_status":"done","details":{}}`, + }, + { + name: "unknown future fields", + body: `{"status":"ok","api_revision":99,"something_new":{"a":1}}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/health", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + checker := newHTTPHealthChecker(nil, "/health", time.Second, log.WithFields("test", "health")) + require.True(t, checker.IsHealthy(t.Context(), server.URL)) + }) + } +} diff --git a/bridgesync/bridgesync.go b/bridgesync/bridgesync.go index 7372fdfd5..c95b094e0 100644 --- a/bridgesync/bridgesync.go +++ b/bridgesync/bridgesync.go @@ -71,11 +71,13 @@ type BridgeSync struct { driver *sync.EVMDriver downloader *sync.EVMDownloader - originNetwork uint32 - reorgDetector ReorgDetector - ethClient aggkittypes.EthClienter - agglayerBridge *agglayerbridge.Agglayerbridge - cfg Config + originNetwork uint32 + reorgDetector ReorgDetector + // reorgDetectorID is the id this syncer is subscribed with in the reorg detector + reorgDetectorID string + ethClient aggkittypes.EthClienter + agglayerBridge *agglayerbridge.Agglayerbridge + cfg Config } // NewL1 creates a bridge syncer that synchronizes the mainnet exit tree @@ -139,6 +141,8 @@ func newBridgeSync( initialLER common.Hash, ) (*BridgeSync, error) { logger := log.WithFields("module", syncerID.String()) + // the same id is used by the driver and the downloader to track blocks and by IsBlockFinalized to query them + reorgDetectorID := syncerID.String() agglayerBridge, err := agglayerbridge.NewAgglayerbridge(cfg.BridgeAddr, ethClient) if err != nil { @@ -189,8 +193,8 @@ func newBridgeSync( []common.Address{cfg.BridgeAddr}, rh, rd.GetFinalizedBlockType(), - rd, // reorgDetector - syncerID.String(), // reorgDetectorID + rd, // reorgDetector + reorgDetectorID, // reorgDetectorID ) if err != nil { return nil, err @@ -212,7 +216,7 @@ func newBridgeSync( }, processor) - driver, err := sync.NewEVMDriver(rd, processor, downloader, syncerID.String(), + driver, err := sync.NewEVMDriver(rd, processor, downloader, reorgDetectorID, downloadBufferSize, rh, compatibilityChecker) if err != nil { return nil, err @@ -246,14 +250,15 @@ func newBridgeSync( ) return &BridgeSync{ - processor: processor, - driver: driver, - downloader: downloader, - originNetwork: networkID, - reorgDetector: rd, - ethClient: ethClient, - agglayerBridge: agglayerBridge, - cfg: cfg, + processor: processor, + driver: driver, + downloader: downloader, + originNetwork: networkID, + reorgDetector: rd, + reorgDetectorID: reorgDetectorID, + ethClient: ethClient, + agglayerBridge: agglayerBridge, + cfg: cfg, }, nil } @@ -410,11 +415,25 @@ func (s *BridgeSync) GetRootByLER(ctx context.Context, ler common.Hash) (*tree.R if s.processor.isHalted() { return nil, sync.ErrInconsistentState } - root, err := s.processor.exitTree.GetRootByHash(ctx, ler) - if err != nil { - return root, err + // the root and the hash of its block are read in the same statement, so the hash can't be the one + // of a block that replaced the root's block after a reorg + return s.processor.getExitTreeRootByHash(ctx, ler) +} + +// IsBlockFinalized reports whether the block with the given number and hash, as read from this syncer, +// is finalized, i.e. it can no longer be reorged. The legacy syncer doesn't store a finality flag, but the +// reorg detector tracks every block that was not final when processed and only stops tracking it once its +// hash was checked against the canonical chain and it is below the finalized block. So a processed block +// that is not tracked anymore is a finalized one. +// found is false when it can't be determined: no hash was given, or the block is not stored with that +// same hash (unknown, no hash stored for it, or it was replaced after the caller read it). +func (s *BridgeSync) IsBlockFinalized(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + if s.processor.isHalted() { + return false, false, sync.ErrInconsistentState } - return root, nil + return reorgdetector.IsBlockFinalized(ctx, s.reorgDetector, s.reorgDetectorID, blockNum, blockHash, + s.processor.getBlockHash) } // GetExitRootByIndex returns the root of the exit tree at the moment the leaf with the given index was added diff --git a/bridgesync/is_block_finalized_test.go b/bridgesync/is_block_finalized_test.go new file mode 100644 index 000000000..81d93158e --- /dev/null +++ b/bridgesync/is_block_finalized_test.go @@ -0,0 +1,283 @@ +package bridgesync + +import ( + "context" + "errors" + "math/big" + "path" + "reflect" + "testing" + + "github.com/agglayer/aggkit/bridgesync/migrations" + mocksbridgesync "github.com/agglayer/aggkit/bridgesync/mocks" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/reorgdetector" + "github.com/agglayer/aggkit/sync" + tree "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/russross/meddler" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newBlockFinalityTestSyncer returns a syncer with blocks 1 (hash 0x1), 2 (hash 0x2) and 3 (no hash) +// stored, and one bridge in each of them +func newBlockFinalityTestSyncer(t *testing.T) (*BridgeSync, *mocksbridgesync.ReorgDetector) { + t.Helper() + + dbPath := path.Join(t.TempDir(), "bridgesyncIsBlockFinalized.sqlite") + require.NoError(t, migrations.RunMigrations(dbPath)) + p, err := newTestProcessor(dbPath, "bridge-syncer", log.WithFields("bridge-syncer", "foo"), dbQueryTimeout) + require.NoError(t, err) + + tx, err := p.db.BeginTx(context.Background(), nil) + require.NoError(t, err) + _, err = tx.Exec(`INSERT INTO block (num, hash) VALUES (1, $1), (2, $2)`, + common.HexToHash("0x1").String(), common.HexToHash("0x2").String()) + require.NoError(t, err) + _, err = tx.Exec(`INSERT INTO block (num) VALUES (3)`) + require.NoError(t, err) + for i := uint32(0); i < 3; i++ { + require.NoError(t, meddler.Insert(tx, "bridge", + &Bridge{DepositCount: i, BlockNum: uint64(i) + 1, Amount: big.NewInt(1)})) + } + require.NoError(t, tx.Commit()) + + rd := mocksbridgesync.NewReorgDetector(t) + return &BridgeSync{processor: p, reorgDetector: rd, reorgDetectorID: "bridge-syncer-id"}, rd +} + +func TestBridgeSyncIsBlockFinalized(t *testing.T) { + ctx := context.Background() + hash1, hash2 := common.HexToHash("0x1"), common.HexToHash("0x2") + + t.Run("stored and not tracked anymore: finalized", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(1)).Return(nil, db.ErrNotFound) + + finalized, found, err := s.IsBlockFinalized(ctx, 1, hash1) + require.NoError(t, err) + require.True(t, found) + require.True(t, finalized) + }) + + t.Run("still tracked by the reorg detector: not finalized", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(2)). + Return(&reorgdetector.Header{Num: 2, Hash: hash2}, nil) + + finalized, found, err := s.IsBlockFinalized(ctx, 2, hash2) + require.NoError(t, err) + require.True(t, found) + require.False(t, finalized) + }) + + t.Run("not tracked and hash differs from the stored one: unknown", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(1)).Return(nil, db.ErrNotFound) + + // the caller read the block, then it was replaced: nothing can be said about the old one + finalized, found, err := s.IsBlockFinalized(ctx, 1, hash2) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("zero hash: unknown without asking the reorg detector", func(t *testing.T) { + s, _ := newBlockFinalityTestSyncer(t) + + _, found, err := s.IsBlockFinalized(ctx, 1, common.Hash{}) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("not tracked, block without stored hash: unknown", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(3)).Return(nil, db.ErrNotFound) + + _, found, err := s.IsBlockFinalized(ctx, 3, hash1) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("not tracked, block not stored: unknown", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(99)).Return(nil, db.ErrNotFound) + + _, found, err := s.IsBlockFinalized(ctx, 99, hash1) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("unknown subscriber: error, never finalized", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber("bridge-syncer-id", uint64(1)). + Return(nil, reorgdetector.ErrSubscriberNotFound) + + finalized, found, err := s.IsBlockFinalized(ctx, 1, hash1) + require.ErrorIs(t, err, reorgdetector.ErrSubscriberNotFound) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("reorg detector error", func(t *testing.T) { + s, rd := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(mock.Anything, uint64(1)).Return(nil, errors.New("boom")) + + _, found, err := s.IsBlockFinalized(ctx, 1, hash1) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) + + t.Run("halted processor", func(t *testing.T) { + s, _ := newBlockFinalityTestSyncer(t) + s.processor.halted = true + + _, _, err := s.IsBlockFinalized(ctx, 1, hash1) + require.ErrorIs(t, err, sync.ErrInconsistentState) + }) +} + +func TestBridgeQueriesFillBlockHash(t *testing.T) { + ctx := context.Background() + hash1, hash2 := common.HexToHash("0x1"), common.HexToHash("0x2") + s, _ := newBlockFinalityTestSyncer(t) + + // bridges with metadata (block 2) and archived bridges: one in block 2, one in block 3 (no hash) and + // one in a block that is not stored anymore + metadata := []byte{0xca, 0xfe} + tx, err := s.processor.db.BeginTx(ctx, nil) + require.NoError(t, err) + require.NoError(t, meddler.Insert(tx, bridgeTableName, + &Bridge{DepositCount: 3, BlockNum: 2, BlockPos: 1, Amount: big.NewInt(1), Metadata: metadata})) + for _, archived := range []*Bridge{ + {DepositCount: 10, BlockNum: 2, BlockPos: 2, Amount: big.NewInt(1), Metadata: metadata}, + {DepositCount: 11, BlockNum: 3, BlockPos: 1, Amount: big.NewInt(1)}, + {DepositCount: 12, BlockNum: 99, Amount: big.NewInt(1)}, + } { + require.NoError(t, meddler.Insert(tx, bridgeArchiveTableName, archived)) + } + require.NoError(t, tx.Commit()) + + hashByDepositCount := func(bridges []*Bridge) map[uint32]common.Hash { + hashes := make(map[uint32]common.Hash, len(bridges)) + for _, b := range bridges { + hashes[b.DepositCount] = b.BlockHash + } + return hashes + } + + t.Run("GetBridgesPaged", func(t *testing.T) { + bridges, count, err := s.GetBridgesPaged(ctx, 1, 10, nil, nil, "") + require.NoError(t, err) + require.Equal(t, 4, count) + // block 3 has no stored hash + require.Equal(t, map[uint32]common.Hash{0: hash1, 1: hash2, 2: {}, 3: hash2}, hashByDepositCount(bridges)) + // the join keeps the order and the row data + require.Equal(t, []uint32{3, 2, 1, 0}, + []uint32{bridges[0].DepositCount, bridges[1].DepositCount, bridges[2].DepositCount, bridges[3].DepositCount}) + require.Equal(t, metadata, bridges[0].Metadata) + require.Equal(t, uint64(1), bridges[0].BlockPos) + + // filtered and paged + depositCount := uint64(1) + bridges, count, err = s.GetBridgesPaged(ctx, 1, 10, &depositCount, []uint32{0}, "") + require.NoError(t, err) + require.Equal(t, 1, count) + require.Equal(t, map[uint32]common.Hash{1: hash2}, hashByDepositCount(bridges)) + bridges, _, err = s.GetBridgesPaged(ctx, 2, 3, nil, nil, "") + require.NoError(t, err) + require.Equal(t, map[uint32]common.Hash{0: hash1}, hashByDepositCount(bridges)) + }) + + t.Run("GetBridgeByDepositCount", func(t *testing.T) { + for depositCount, expected := range map[uint32]common.Hash{ + 1: hash2, + 2: {}, // block without hash + 10: hash2, + 11: {}, // archived, block without hash + 12: {}, // archived, block not stored + } { + bridge, err := s.GetBridgeByDepositCount(ctx, depositCount) + require.NoError(t, err) + require.Equal(t, depositCount, bridge.DepositCount) + require.Equal(t, expected, bridge.BlockHash, "deposit count %d", depositCount) + } + + archived, err := s.GetBridgeByDepositCount(ctx, 10) + require.NoError(t, err) + require.Equal(t, metadata, archived.Metadata) + require.Equal(t, uint64(2), archived.BlockNum) + + _, err = s.GetBridgeByDepositCount(ctx, 1000) + require.ErrorIs(t, err, db.ErrNotFound) + }) + + t.Run("GetBridgesByContent without metadata", func(t *testing.T) { + bridges, err := s.GetBridgesByContent(ctx, 0, common.Address{}, 0, common.Address{}, big.NewInt(1), nil) + require.NoError(t, err) + require.Equal(t, map[uint32]common.Hash{0: hash1, 1: hash2, 2: {}, 11: {}, 12: {}}, + hashByDepositCount(bridges)) + }) + + t.Run("GetBridgesByContent with metadata", func(t *testing.T) { + bridges, err := s.GetBridgesByContent(ctx, 0, common.Address{}, 0, common.Address{}, big.NewInt(1), metadata) + require.NoError(t, err) + require.Equal(t, map[uint32]common.Hash{3: hash2, 10: hash2}, hashByDepositCount(bridges)) + }) + + t.Run("GetRootByLER", func(t *testing.T) { + roots := []*tree.Root{ + {Hash: common.HexToHash("0xa"), Index: 0, BlockNum: 1}, + {Hash: common.HexToHash("0xb"), Index: 1, BlockNum: 3, BlockPosition: 4}, + {Hash: common.HexToHash("0xc"), Index: 2, BlockNum: 99}, + } + for _, root := range roots { + require.NoError(t, meddler.Insert(s.processor.db, exitTreeDBPrefix+"root", root)) + } + for i, expected := range []common.Hash{hash1, {}, {}} { + root, err := s.GetRootByLER(ctx, roots[i].Hash) + require.NoError(t, err) + want := *roots[i] + want.BlockHash = expected + require.Equal(t, want, *root) + + // same root as the exit tree's own query, which doesn't read the block hash + treeRoot, err := s.processor.exitTree.GetRootByHash(ctx, roots[i].Hash) + require.NoError(t, err) + treeRoot.BlockHash = expected + require.Equal(t, treeRoot, root) + } + + _, err := s.GetRootByLER(ctx, common.HexToHash("0xdead")) + require.ErrorIs(t, err, db.ErrNotFound) + }) +} + +// TestBlockHashReadModels checks that the read models used to read rows together with their block hash +// map the same columns as the public types they are converted to, besides block_hash. Field names and +// types are already checked by the compiler, through the conversions. +func TestBlockHashReadModels(t *testing.T) { + requireSameMeddlerColumns(t, Bridge{}, bridgeWithBlockHash{}) + requireSameMeddlerColumns(t, tree.Root{}, rootWithBlockHash{}) +} + +// requireSameMeddlerColumns checks that public and readModel have the same meddler tags, except that +// public.BlockHash is not a column and readModel.BlockHash is the block_hash column +func requireSameMeddlerColumns(t *testing.T, public, readModel any) { + t.Helper() + + publicType, readModelType := reflect.TypeOf(public), reflect.TypeOf(readModel) + require.Equal(t, publicType.NumField(), readModelType.NumField()) + for i := range publicType.NumField() { + publicField, readModelField := publicType.Field(i), readModelType.Field(i) + require.Equal(t, publicField.Name, readModelField.Name) + if publicField.Name == "BlockHash" { + require.Equal(t, "-", publicField.Tag.Get("meddler")) + require.Equal(t, "block_hash,hash", readModelField.Tag.Get("meddler")) + continue + } + require.Equal(t, publicField.Tag.Get("meddler"), readModelField.Tag.Get("meddler"), publicField.Name) + } +} diff --git a/bridgesync/processor.go b/bridgesync/processor.go index 378bfd5b9..b73feac29 100644 --- a/bridgesync/processor.go +++ b/bridgesync/processor.go @@ -36,6 +36,12 @@ const ( // bridgeTableName is the name of the table that stores bridge events bridgeTableName = "bridge" + // bridgeArchiveTableName is the name of the table that stores the archived bridge events + bridgeArchiveTableName = "bridge_archive" + + // exitTreeDBPrefix is the table prefix of the local exit tree (see tree.NewAppendOnlyTree) + exitTreeDBPrefix = "" + // tokenMappingTableName is the name of the table that stores token mapping events tokenMappingTableName = "token_mapping" @@ -56,31 +62,51 @@ const ( // orderByBlockDesc is the default order by clause for block-based queries orderByBlockDesc = "block_num DESC, block_pos DESC" + // blockHashSelectSQL selects every column of the table aliased t plus the hash of the block the row + // belongs to, as block_hash, read in the same statement (see bridgeWithBlockHash). The block is + // LEFT JOINed so a row whose block is missing or has no hash is still returned, with an empty + // block_hash. Columns named like block's ones (num, hash) must be qualified in the rest of the + // statement; the statements below qualify every column anyway. + blockHashSelectSQL = "SELECT t.*, COALESCE(b.hash, '') AS block_hash FROM " + // blockHashJoinSQL goes right after the table name in a statement started with blockHashSelectSQL + blockHashJoinSQL = " t LEFT JOIN block b ON b.num = t.block_num" + // bridgeByDepositCountSQL is the query used by GetBridgeByDepositCount for the main bridge table. // deposit_count is a unique monotonic counter per bridge event in the contract, so no // additional origin_network filter is needed (it would incorrectly exclude L2-native tokens). - bridgeByDepositCountSQL = "SELECT * FROM " + bridgeTableName + - " WHERE deposit_count = $1 LIMIT 1" + bridgeByDepositCountSQL = blockHashSelectSQL + bridgeTableName + blockHashJoinSQL + + " WHERE t.deposit_count = $1 LIMIT 1" // archiveByDepositCountSQL is the query used by GetBridgeByDepositCount for bridge_archive. - archiveByDepositCountSQL = `SELECT * FROM bridge_archive WHERE deposit_count = $1 LIMIT 1` + archiveByDepositCountSQL = blockHashSelectSQL + bridgeArchiveTableName + blockHashJoinSQL + + " WHERE t.deposit_count = $1 LIMIT 1" // bridgesByContentWhereNoMeta is the WHERE clause for GetBridgesByContent without metadata. - bridgesByContentWhereNoMeta = "origin_network = 0 AND leaf_type = $1 AND origin_address = $2" + - " AND destination_network = $3 AND destination_address = $4 AND amount = $5" + - " AND (metadata IS NULL OR metadata = x'')" + bridgesByContentWhereNoMeta = "t.origin_network = 0 AND t.leaf_type = $1 AND t.origin_address = $2" + + " AND t.destination_network = $3 AND t.destination_address = $4 AND t.amount = $5" + + " AND (t.metadata IS NULL OR t.metadata = x'')" // bridgesByContentWhereWithMeta is the WHERE clause for GetBridgesByContent with metadata. - bridgesByContentWhereWithMeta = "origin_network = 0 AND leaf_type = $1 AND origin_address = $2" + - " AND destination_network = $3 AND destination_address = $4 AND amount = $5" + - " AND metadata = $6" + bridgesByContentWhereWithMeta = "t.origin_network = 0 AND t.leaf_type = $1 AND t.origin_address = $2" + + " AND t.destination_network = $3 AND t.destination_address = $4 AND t.amount = $5" + + " AND t.metadata = $6" // Precomputed full SELECT queries for GetBridgesByContent (bridge and bridge_archive tables, // with and without metadata filter). Using compile-time constants avoids dynamic SQL construction. - bridgeByContentNoMetaSQL = "SELECT * FROM " + bridgeTableName + " WHERE " + bridgesByContentWhereNoMeta - bridgeByContentWithMetaSQL = "SELECT * FROM " + bridgeTableName + " WHERE " + bridgesByContentWhereWithMeta - archiveByContentNoMetaSQL = "SELECT * FROM bridge_archive WHERE " + bridgesByContentWhereNoMeta - archiveByContentWithMetaSQL = "SELECT * FROM bridge_archive WHERE " + bridgesByContentWhereWithMeta + bridgeByContentNoMetaSQL = blockHashSelectSQL + bridgeTableName + blockHashJoinSQL + + " WHERE " + bridgesByContentWhereNoMeta + bridgeByContentWithMetaSQL = blockHashSelectSQL + bridgeTableName + blockHashJoinSQL + + " WHERE " + bridgesByContentWhereWithMeta + archiveByContentNoMetaSQL = blockHashSelectSQL + bridgeArchiveTableName + blockHashJoinSQL + + " WHERE " + bridgesByContentWhereNoMeta + archiveByContentWithMetaSQL = blockHashSelectSQL + bridgeArchiveTableName + blockHashJoinSQL + + " WHERE " + bridgesByContentWhereWithMeta + + // exitTreeRootByHashSQL is the query used by GetRootByLER: the exit tree root with the given hash + // plus the hash of its block, read in the same statement (see rootWithBlockHash). The exit tree + // is created with the exitTreeDBPrefix prefix, so its roots are in the exitTreeDBPrefix+"root" table + exitTreeRootByHashSQL = blockHashSelectSQL + exitTreeDBPrefix + "root" + blockHashJoinSQL + + " WHERE t.hash = $1" ) var ( @@ -124,6 +150,57 @@ type Bridge struct { TxnSender common.Address `meddler:"txn_sender,address"` Source BridgeSource `meddler:"source"` ToAddress common.Address `meddler:"to_address,address"` + // BlockHash is the hash of the block BlockNum, as stored in the block table. It is not a column of + // the bridge tables: the read queries that serve it (GetBridgesPaged, GetBridgeByDepositCount and + // GetBridgesByContent) join it from the block table in the same statement that reads the row, so it + // is the hash of the block the row belonged to when it was read. Zero if that block has no hash, or + // when the bridge was read by any other query. + BlockHash common.Hash `meddler:"-"` +} + +// bridgeWithBlockHash is the read model of a bridge row joined with the hash of its block (see +// blockHashSelectSQL). It can't be Bridge itself: meddler.Insert writes every meddler-tagged field and +// block_hash is not a column of the bridge tables, and meddler doesn't flatten embedded structs. It has +// exactly the fields of Bridge, in the same order, so that Bridge(row) compiles only while both match. +type bridgeWithBlockHash struct { + BlockNum uint64 `meddler:"block_num"` + BlockPos uint64 `meddler:"block_pos"` + FromAddress *common.Address `meddler:"from_address,address"` + TxHash common.Hash `meddler:"tx_hash,hash"` + BlockTimestamp uint64 `meddler:"block_timestamp"` + LeafType uint8 `meddler:"leaf_type"` + OriginNetwork uint32 `meddler:"origin_network"` + OriginAddress common.Address `meddler:"origin_address"` + DestinationNetwork uint32 `meddler:"destination_network"` + DestinationAddress common.Address `meddler:"destination_address"` + Amount *big.Int `meddler:"amount,bigint"` + Metadata []byte `meddler:"metadata"` + DepositCount uint32 `meddler:"deposit_count"` + TxnSender common.Address `meddler:"txn_sender,address"` + Source BridgeSource `meddler:"source"` + ToAddress common.Address `meddler:"to_address,address"` + BlockHash common.Hash `meddler:"block_hash,hash"` +} + +// toBridges converts the rows read with bridgeWithBlockHash to bridges +func toBridges(rows []*bridgeWithBlockHash) []*Bridge { + bridges := make([]*Bridge, len(rows)) + for i, row := range rows { + bridge := Bridge(*row) + bridges[i] = &bridge + } + return bridges +} + +// rootWithBlockHash is the read model of an exit tree root joined with the hash of its block (see +// exitTreeRootByHashSQL), for the same reasons as bridgeWithBlockHash. It has exactly the fields of +// tree.Root, in the same order, so that tree.Root(row) compiles only while both match. +type rootWithBlockHash struct { + Hash common.Hash `meddler:"hash,hash"` + Index uint32 `meddler:"position"` + BlockNum uint64 `meddler:"block_num"` + BlockPosition uint64 `meddler:"block_position"` + BlockHash common.Hash `meddler:"block_hash,hash"` } func (b *Bridge) String() string { @@ -454,7 +531,7 @@ func newProcessor( logger *log.Logger, dbQueryTimeout time.Duration, ) (*processor, error) { - exitTree := tree.NewAppendOnlyTree(database, "") + exitTree := tree.NewAppendOnlyTree(database, exitTreeDBPrefix) return &processor{ syncerID: syncerID, @@ -524,7 +601,7 @@ func (p *processor) GetBridgesPaged( return nil, 0, err } - rows, err := p.queryPagedWithParams(ctx, p.db, offset, pageSize, bridgeTableName, + rows, err := p.queryPagedWithBlockHash(ctx, p.db, offset, pageSize, bridgeTableName, orderByClause, whereClause, whereArgs) if err != nil { if errors.Is(err, db.ErrNotFound) { @@ -532,7 +609,7 @@ func (p *processor) GetBridgesPaged( pageNumber, pageSize, whereClause) return nil, bridgesCount, nil } - p.log.Errorf("GetBridgesPaged: queryPagedWithParams failed for pageNumber=%d, pageSize=%d: %v", + p.log.Errorf("GetBridgesPaged: queryPagedWithBlockHash failed for pageNumber=%d, pageSize=%d: %v", pageNumber, pageSize, err) return nil, 0, err } @@ -543,14 +620,14 @@ func (p *processor) GetBridgesPaged( } }() - bridges := []*Bridge{} - if err = meddler.ScanAll(rows, &bridges); err != nil { + bridgeRows := []*bridgeWithBlockHash{} + if err = meddler.ScanAll(rows, &bridgeRows); err != nil { p.log.Errorf("GetBridgesPaged: meddler.ScanAll failed for pageNumber=%d, pageSize=%d: %v", pageNumber, pageSize, err) return nil, 0, err } - return bridges, bridgesCount, nil + return toBridges(bridgeRows), bridgesCount, nil } // buildBridgesFilterClause builds the WHERE clause for the bridges table @@ -688,9 +765,12 @@ func (p *processor) queryPaged(ctx context.Context, tx dbtypes.Querier, return rows, nil } -// queryPagedWithParams returns a paged result from the given table with parameterized WHERE clause -// to prevent SQL injection attacks -func (p *processor) queryPagedWithParams(ctx context.Context, tx dbtypes.Querier, +// queryPagedWithBlockHash returns a paged result from the given table with parameterized WHERE clause +// to prevent SQL injection attacks. Each row is joined with the hash of its block (table rows must have +// block_num), read in the same statement: see blockHashSelectSQL. whereClause and orderByClause are +// applied to the joined rows, so they must not use the columns that the table shares with block +// (num and hash), unless qualified with the table alias t. +func (p *processor) queryPagedWithBlockHash(ctx context.Context, tx dbtypes.Querier, offset, pageSize uint32, table, orderByClause, whereClause string, whereArgs []interface{}, @@ -703,12 +783,11 @@ func (p *processor) queryPagedWithParams(ctx context.Context, tx dbtypes.Querier // We need to adjust LIMIT and OFFSET to use the next available placeholders nextParam := len(whereArgs) + 1 query := fmt.Sprintf(` - SELECT * - FROM %s + %s%s%s %s ORDER BY %s LIMIT $%d OFFSET $%d; - `, table, whereClause, orderByClause, nextParam, nextParam+1) + `, blockHashSelectSQL, table, blockHashJoinSQL, whereClause, orderByClause, nextParam, nextParam+1) // Combine WHERE args with pagination args args := make([]interface{}, 0, len(whereArgs)+2) //nolint:mnd @@ -1458,7 +1537,7 @@ func (p *processor) GetBridgeByDepositCount(ctx context.Context, depositCount ui for _, tq := range []struct{ name, query string }{ {bridgeTableName, bridgeByDepositCountSQL}, - {"bridge_archive", archiveByDepositCountSQL}, + {bridgeArchiveTableName, archiveByDepositCountSQL}, } { rows, err := p.db.QueryContext(dbCtx, tq.query, depositCount) if err != nil { @@ -1467,16 +1546,16 @@ func (p *processor) GetBridgeByDepositCount(ctx context.Context, depositCount ui } return nil, fmt.Errorf("GetBridgeByDepositCount (%s): %w", tq.name, err) } - bridges := []*Bridge{} - scanErr := meddler.ScanAll(rows, &bridges) + bridgeRows := []*bridgeWithBlockHash{} + scanErr := meddler.ScanAll(rows, &bridgeRows) if cerr := rows.Close(); cerr != nil { p.log.Errorf("error closing rows: %v", cerr) } if scanErr != nil { return nil, fmt.Errorf("GetBridgeByDepositCount (%s): scan: %w", tq.name, scanErr) } - if len(bridges) > 0 { - return bridges[0], nil + if len(bridgeRows) > 0 { + return toBridges(bridgeRows[:1])[0], nil } } return nil, db.ErrNotFound @@ -1524,7 +1603,7 @@ func (p *processor) GetBridgesByContent( var result []*Bridge for _, pair := range []struct{ name, query string }{ {bridgeTableName, tq.bridge}, - {"bridge_archive", tq.archive}, + {bridgeArchiveTableName, tq.archive}, } { rows, err := p.db.QueryContext(dbCtx, pair.query, queryArgs...) p.log.Infof("[GetBridgesByContent] table=%s", pair.name) @@ -1534,14 +1613,15 @@ func (p *processor) GetBridgesByContent( } return nil, fmt.Errorf("GetBridgesByContent (%s): %w", pair.name, err) } - var bridges []*Bridge - scanErr := meddler.ScanAll(rows, &bridges) + var bridgeRows []*bridgeWithBlockHash + scanErr := meddler.ScanAll(rows, &bridgeRows) if cerr := rows.Close(); cerr != nil { p.log.Errorf("error closing rows: %v", cerr) } if scanErr != nil { return nil, fmt.Errorf("GetBridgesByContent (%s): scan: %w", pair.name, scanErr) } + bridges := toBridges(bridgeRows) p.log.Infof("[GetBridgesByContent] table=%s found=%d", pair.name, len(bridges)) for i, b := range bridges { p.log.Infof("[GetBridgesByContent] bridge[%d]: deposit_count=%d origin_addr=%s dest_addr=%s amount=%s metadata=%x", @@ -1552,6 +1632,49 @@ func (p *processor) GetBridgesByContent( return result, nil } +// getExitTreeRootByHash returns the exit tree root with the given hash, with BlockHash set to the hash +// of its block read in the same statement (zero if that block has no hash). Returns db.ErrNotFound if +// there is no such root, like the exit tree's GetRootByHash. +func (p *processor) getExitTreeRootByHash(ctx context.Context, hash common.Hash) (*types.Root, error) { + dbCtx, cancel := p.withDatabaseTimeout(ctx) + defer cancel() + + rows, err := p.db.QueryContext(dbCtx, exitTreeRootByHashSQL, hash.Hex()) + if err != nil { + return nil, fmt.Errorf("failed to get the exit tree root %s: %w", hash.Hex(), err) + } + var row rootWithBlockHash + // meddler.ScanRow closes rows + if err = meddler.ScanRow(rows, &row); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, db.ErrNotFound + } + return nil, fmt.Errorf("failed to get the exit tree root %s: %w", hash.Hex(), err) + } + root := types.Root(row) + return &root, nil +} + +// getBlockHash returns the hash stored for the given block. found is false if the block is not stored +// or it has no hash (blocks synced before the hash column existed). +func (p *processor) getBlockHash(ctx context.Context, blockNum uint64) (hash common.Hash, found bool, err error) { + dbCtx, cancel := p.withDatabaseTimeout(ctx) + defer cancel() + + var hashStr sql.NullString + err = p.db.QueryRowContext(dbCtx, `SELECT hash FROM block WHERE num = $1`, blockNum).Scan(&hashStr) + if errors.Is(err, sql.ErrNoRows) { + return common.Hash{}, false, nil + } + if err != nil { + return common.Hash{}, false, fmt.Errorf("failed to get the hash of block %d: %w", blockNum, err) + } + if !hashStr.Valid || hashStr.String == "" { + return common.Hash{}, false, nil + } + return common.HexToHash(hashStr.String), true, nil +} + // GenerateGlobalIndexForNetworkID builds the "global index" used to identify bridges and claims. func GenerateGlobalIndexForNetworkID(networkID uint32, depositCount uint32) *big.Int { rollupIndex := uint32(0) diff --git a/claimsync/claimsync.go b/claimsync/claimsync.go index b34982561..555c3cc9e 100644 --- a/claimsync/claimsync.go +++ b/claimsync/claimsync.go @@ -15,6 +15,7 @@ import ( aggkitcommon "github.com/agglayer/aggkit/common" "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/reorgdetector" "github.com/agglayer/aggkit/sync" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum" @@ -31,6 +32,7 @@ type ClaimSync struct { processor *processor driver *sync.EVMDriver reader claimsynctypes.ClaimsReader + reorgDetector sync.ReorgDetector ethClient aggkittypes.EthClienter logger aggkitcommon.Logger originNetwork uint32 @@ -128,6 +130,7 @@ func NewClaimSync( processor: proc, driver: driver, reader: store, + reorgDetector: rd, ethClient: ethClient, logger: logger, originNetwork: originNetwork, @@ -368,3 +371,18 @@ func (c *ClaimSync) GetSetClaimsPaged(ctx context.Context, page, pageSize uint32 func (c *ClaimSync) GetClaimsByGER(ctx context.Context, globalExitRoot common.Hash) ([]*Claim, error) { return c.reader.GetClaimsByGER(ctx, nil, globalExitRoot) } + +// IsBlockFinalized reports whether the block with the given number and hash, as read from this syncer, +// is finalized, i.e. it can no longer be reorged. The syncer has no finality flag, but the reorg detector +// tracks every block that was not final when processed and only stops tracking it once its hash was +// checked against the canonical chain and it is below the finalized block. So a processed block with the +// given hash that is no longer tracked is finalized. +// found is false when it can't be determined: no hash was given, or the block is not stored with that +// same hash (unknown, no hash stored for it, or it was replaced after the caller read it). +func (c *ClaimSync) IsBlockFinalized(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + return reorgdetector.IsBlockFinalized(ctx, c.reorgDetector, c.syncerID.String(), blockNum, blockHash, + func(ctx context.Context, num uint64) (common.Hash, bool, error) { + return c.reader.GetBlockHash(ctx, nil, num) + }) +} diff --git a/claimsync/is_block_finalized_test.go b/claimsync/is_block_finalized_test.go new file mode 100644 index 000000000..cbcadd936 --- /dev/null +++ b/claimsync/is_block_finalized_test.go @@ -0,0 +1,113 @@ +package claimsync + +import ( + "errors" + "testing" + + claimsynctypes "github.com/agglayer/aggkit/claimsync/types" + claimmocks "github.com/agglayer/aggkit/claimsync/types/mocks" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/reorgdetector" + "github.com/agglayer/aggkit/sync" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestClaimSyncIsBlockFinalized(t *testing.T) { + hash := common.HexToHash("0x1") + otherHash := common.HexToHash("0x2") + trackerID := claimsynctypes.L2ClaimSyncer.String() + + newSyncer := func(t *testing.T) (*ClaimSync, *claimmocks.ClaimsReader, *sync.ReorgDetectorMock) { + t.Helper() + reader := claimmocks.NewClaimsReader(t) + rd := sync.NewReorgDetectorMock(t) + return &ClaimSync{ + reader: reader, + reorgDetector: rd, + syncerID: claimsynctypes.L2ClaimSyncer, + }, reader, rd + } + + t.Run("stored and not tracked anymore: finalized", func(t *testing.T) { + s, reader, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, db.ErrNotFound) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + + finalized, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.NoError(t, err) + require.True(t, found) + require.True(t, finalized) + }) + + t.Run("still tracked by the reorg detector: not finalized", func(t *testing.T) { + s, _, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)). + Return(&reorgdetector.Header{Num: 10, Hash: hash}, nil) + + finalized, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.NoError(t, err) + require.True(t, found) + require.False(t, finalized) + }) + + t.Run("not tracked and hash differs from the stored one: unknown", func(t *testing.T) { + s, reader, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, db.ErrNotFound) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(otherHash, true, nil) + + finalized, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("not tracked and block without stored hash: unknown", func(t *testing.T) { + s, reader, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, db.ErrNotFound) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(common.Hash{}, false, nil) + + _, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("zero hash: unknown", func(t *testing.T) { + s, _, _ := newSyncer(t) + + _, found, err := s.IsBlockFinalized(t.Context(), 10, common.Hash{}) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("storage error", func(t *testing.T) { + s, reader, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, db.ErrNotFound) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(common.Hash{}, false, errors.New("boom")) + + _, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) + + t.Run("reorg detector error", func(t *testing.T) { + s, _, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, errors.New("boom")) + + _, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) + + t.Run("unknown subscriber: error, never finalized", func(t *testing.T) { + s, _, rd := newSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)). + Return(nil, reorgdetector.ErrSubscriberNotFound) + + finalized, found, err := s.IsBlockFinalized(t.Context(), 10, hash) + require.ErrorIs(t, err, reorgdetector.ErrSubscriberNotFound) + require.False(t, found) + require.False(t, finalized) + }) +} diff --git a/claimsync/storage/storage.go b/claimsync/storage/storage.go index 3e47b4888..0d9af9d78 100644 --- a/claimsync/storage/storage.go +++ b/claimsync/storage/storage.go @@ -16,6 +16,7 @@ import ( "github.com/agglayer/aggkit/db/compatibility" dbtypes "github.com/agglayer/aggkit/db/types" aggsync "github.com/agglayer/aggkit/sync" + treetypes "github.com/agglayer/aggkit/tree/types" "github.com/ethereum/go-ethereum/common" sqlite "github.com/mattn/go-sqlite3" "github.com/russross/meddler" @@ -72,12 +73,56 @@ const ( o.block_timestamp, o.type` - // claimsByGERSQL is the query used by GetClaimsByGER. - claimsByGERSQL = "SELECT " + claimColumnsSQL + - " FROM claim WHERE global_exit_root = $1 AND type = $2" + - " ORDER BY block_num ASC, block_pos ASC" + // claimsByGERSQL is the query used by GetClaimsByGER. Each claim is joined with the hash of its + // block, read in the same statement (see claimWithBlockHash). The claim columns are unambiguous + // (block only has num and hash), the rest of the statement qualifies them anyway. + claimsByGERSQL = "SELECT " + claimColumnsSQL + ", " + blockHashColumnSQL + + " FROM claim c LEFT JOIN block b ON b.num = c.block_num" + + " WHERE c.global_exit_root = $1 AND c.type = $2" + + " ORDER BY c.block_num ASC, c.block_pos ASC" + + // blockHashColumnSQL is the block_hash column of the queries that join a claim with its block + // (aliased b): empty when the block is missing or has no hash + blockHashColumnSQL = "COALESCE(b.hash, '') AS block_hash" ) +// claimWithBlockHash is the read model of a claim row joined with the hash of its block. It can't be +// claimsynctypes.Claim itself: meddler.Insert writes every meddler-tagged field and block_hash is not a +// column of the claim table, and meddler doesn't flatten embedded structs. It has exactly the fields of +// claimsynctypes.Claim, in the same order, so that claimsynctypes.Claim(row) compiles only while both +// match. +type claimWithBlockHash struct { + BlockNum uint64 `meddler:"block_num"` + BlockPos uint64 `meddler:"block_pos"` + TxHash common.Hash `meddler:"tx_hash,hash"` + GlobalIndex *big.Int `meddler:"global_index,bigint"` + OriginNetwork uint32 `meddler:"origin_network"` + OriginAddress common.Address `meddler:"origin_address"` + DestinationAddress common.Address `meddler:"destination_address"` + Amount *big.Int `meddler:"amount,bigint"` + ProofLocalExitRoot treetypes.Proof `meddler:"proof_local_exit_root,merkleproof"` + ProofRollupExitRoot treetypes.Proof `meddler:"proof_rollup_exit_root,merkleproof"` + MainnetExitRoot common.Hash `meddler:"mainnet_exit_root,hash"` + RollupExitRoot common.Hash `meddler:"rollup_exit_root,hash"` + GlobalExitRoot common.Hash `meddler:"global_exit_root,hash"` + DestinationNetwork uint32 `meddler:"destination_network"` + Metadata []byte `meddler:"metadata"` + IsMessage bool `meddler:"is_message"` + BlockTimestamp uint64 `meddler:"block_timestamp"` + Type claimsynctypes.ClaimType `meddler:"type"` + BlockHash common.Hash `meddler:"block_hash,hash"` +} + +// toClaims converts the rows read with claimWithBlockHash to claims +func toClaims(rows []*claimWithBlockHash) []*claimsynctypes.Claim { + claims := make([]*claimsynctypes.Claim, len(rows)) + for i, row := range rows { + claim := claimsynctypes.Claim(*row) + claims[i] = &claim + } + return claims +} + type claimStorage struct { database *sql.DB compatStore compatibility.CompatibilityDataStorager[aggsync.RuntimeData] @@ -355,11 +400,32 @@ func (p *claimStorage) GetClaimsByGER( } }() - claims := []*claimsynctypes.Claim{} - if err = meddler.ScanAll(rows, &claims); err != nil { + claimRows := []*claimWithBlockHash{} + if err = meddler.ScanAll(rows, &claimRows); err != nil { return nil, fmt.Errorf("GetClaimsByGER: scan: %w", err) } - return claims, nil + return toClaims(claimRows), nil +} + +// GetBlockHash returns the hash stored for the given block. found is false if the block is not +// stored or it has no hash. +func (s *claimStorage) GetBlockHash(ctx context.Context, tx dbtypes.Querier, + blockNum uint64) (hash common.Hash, found bool, err error) { + dbCtx, cancel := s.withDatabaseTimeout(ctx) + defer cancel() + + var hashStr sql.NullString + err = s.getQuerier(tx).QueryRowContext(dbCtx, `SELECT hash FROM block WHERE num = $1`, blockNum).Scan(&hashStr) + if errors.Is(err, sql.ErrNoRows) { + return common.Hash{}, false, nil + } + if err != nil { + return common.Hash{}, false, fmt.Errorf("GetBlockHash %d: %w", blockNum, err) + } + if !hashStr.Valid || hashStr.String == "" { + return common.Hash{}, false, nil + } + return common.HexToHash(hashStr.String), true, nil } // DeleteBlocksFrom deletes all blocks with num >= firstBlock and returns the count deleted. diff --git a/claimsync/storage/storage_paged.go b/claimsync/storage/storage_paged.go index b7255734d..8b345cbd9 100644 --- a/claimsync/storage/storage_paged.go +++ b/claimsync/storage/storage_paged.go @@ -204,12 +204,19 @@ func (p *claimStorage) GetClaimsPaged( JOIN all_claims_ranked n ON o.global_index = n.global_index AND n.rn_newest_global = 1 WHERE o.rn_oldest_global = 1 -- Globally oldest claim AND o.global_index IN (SELECT global_index FROM newest_on_page) + ), + page_result AS ( + SELECT * FROM claims_with_unset_on_page + UNION ALL + SELECT * FROM compactable_claims ) - SELECT * FROM claims_with_unset_on_page - UNION ALL - SELECT * FROM compactable_claims - ORDER BY block_num DESC, block_pos DESC; - `, whereClause, whereClause, claimColumnsSQL, compactedClaimsSelectSQL) + -- the hash of each claim's block is joined here, in the same statement, so it is the hash of the + -- block the claim belonged to when it was read (see claimWithBlockHash) + SELECT r.*, %s + FROM page_result r + LEFT JOIN block b ON b.num = r.block_num + ORDER BY r.block_num DESC, r.block_pos DESC; + `, whereClause, whereClause, claimColumnsSQL, compactedClaimsSelectSQL, blockHashColumnSQL) rows, err := p.database.QueryContext(dbCtx, query, pageSize, offset) if err != nil { @@ -227,13 +234,13 @@ func (p *claimStorage) GetClaimsPaged( } }() - claims := []*claimsynctypes.Claim{} - if err = meddler.ScanAll(rows, &claims); err != nil { + claimRows := []*claimWithBlockHash{} + if err = meddler.ScanAll(rows, &claimRows); err != nil { p.log.Errorf("GetClaimsPaged: meddler.ScanAll failed for pageNumber=%d, pageSize=%d: %v", pageNumber, pageSize, err) return nil, 0, err } - return claims, claimsCount, nil + return toClaims(claimRows), claimsCount, nil } // buildGlobalIndexFilterClause builds a WHERE clause for filtering by global_index diff --git a/claimsync/storage/storage_test.go b/claimsync/storage/storage_test.go index e5d0cc066..41b9e3126 100644 --- a/claimsync/storage/storage_test.go +++ b/claimsync/storage/storage_test.go @@ -1435,3 +1435,129 @@ func TestGetClaimsPaged_Compact(t *testing.T) { require.Equal(t, claims[4].ProofLocalExitRoot, got[0].ProofLocalExitRoot) }) } + +func TestClaimQueriesFillBlockHash(t *testing.T) { + s, rawDB := newTestStorage(t) + ctx := context.Background() + hash1, hash4 := common.HexToHash("0xb10c"), common.HexToHash("0xb10c4") + ger, otherGER := common.HexToHash("0x6e1"), common.HexToHash("0x6e2") + newestMER := common.HexToHash("0x3e1") + + // block 1 and 4 with hash, block 2 stored with the zero hash and block 3 without hash + tx, err := s.NewTx(ctx) + require.NoError(t, err) + require.NoError(t, s.InsertBlock(ctx, tx, 1, hash1)) + require.NoError(t, s.InsertBlock(ctx, tx, 2, common.Hash{})) + _, err = tx.Exec(`INSERT INTO block (num) VALUES (3)`) + require.NoError(t, err) + require.NoError(t, s.InsertBlock(ctx, tx, 4, hash4)) + for _, claim := range []claimsynctypes.Claim{ + {BlockNum: 1, GlobalIndex: big.NewInt(1), Type: claimsynctypes.DetailedClaimEvent, GlobalExitRoot: ger}, + {BlockNum: 2, GlobalIndex: big.NewInt(2), Type: claimsynctypes.ClaimEvent}, + {BlockNum: 2, BlockPos: 1, GlobalIndex: big.NewInt(8), Type: claimsynctypes.DetailedClaimEvent, + GlobalExitRoot: ger}, + {BlockNum: 3, GlobalIndex: big.NewInt(3), Type: claimsynctypes.DetailedClaimEvent, GlobalExitRoot: ger}, + // claimed twice without unset: compacted into the oldest one (block 1) + {BlockNum: 1, BlockPos: 1, GlobalIndex: big.NewInt(5), Type: claimsynctypes.ClaimEvent}, + {BlockNum: 4, GlobalIndex: big.NewInt(5), Type: claimsynctypes.ClaimEvent, MainnetExitRoot: newestMER}, + // claimed twice with unset: both returned + {BlockNum: 3, BlockPos: 1, GlobalIndex: big.NewInt(6), Type: claimsynctypes.ClaimEvent}, + {BlockNum: 4, BlockPos: 1, GlobalIndex: big.NewInt(6), Type: claimsynctypes.ClaimEvent}, + {BlockNum: 4, BlockPos: 2, GlobalIndex: big.NewInt(7), Type: claimsynctypes.DetailedClaimEvent, + GlobalExitRoot: otherGER}, + } { + claim.Amount = big.NewInt(1) + require.NoError(t, s.InsertClaim(ctx, tx, claim)) + } + require.NoError(t, s.InsertUnsetClaim(ctx, tx, claimsynctypes.UnsetClaim{ + BlockNum: 4, BlockPos: 3, GlobalIndex: big.NewInt(6), + })) + require.NoError(t, tx.Commit()) + + type claimKey struct { + blockNum, blockPos, globalIndex uint64 + blockHash common.Hash + } + keys := func(claims []*claimsynctypes.Claim) []claimKey { + result := make([]claimKey, len(claims)) + for i, claim := range claims { + result[i] = claimKey{claim.BlockNum, claim.BlockPos, claim.GlobalIndex.Uint64(), claim.BlockHash} + } + return result + } + + t.Run("GetBlockHash", func(t *testing.T) { + gotHash, found, err := s.GetBlockHash(ctx, nil, 1) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, hash1, gotHash) + + _, found, err = s.GetBlockHash(ctx, nil, 3) + require.NoError(t, err) + require.False(t, found) + + _, found, err = s.GetBlockHash(ctx, nil, 99) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("GetClaimsPaged", func(t *testing.T) { + claims, count, err := s.GetClaimsPaged(ctx, 1, 20, nil, nil) + require.NoError(t, err) + require.Equal(t, 8, count) + require.Equal(t, []claimKey{ + {4, 2, 7, hash4}, + {4, 1, 6, hash4}, + {3, 1, 6, common.Hash{}}, + {3, 0, 3, common.Hash{}}, + {2, 1, 8, common.Hash{}}, + {2, 0, 2, common.Hash{}}, + {1, 1, 5, hash1}, // compacted: the oldest claim's block, the newest claim's exit roots + {1, 0, 1, hash1}, + }, keys(claims)) + require.Equal(t, newestMER, claims[6].MainnetExitRoot) + require.Equal(t, claimsynctypes.ClaimEvent, claims[6].Type) + + claims, _, err = s.GetClaimsPaged(ctx, 1, 20, nil, big.NewInt(6)) + require.NoError(t, err) + require.Equal(t, []claimKey{{4, 1, 6, hash4}, {3, 1, 6, common.Hash{}}}, keys(claims)) + }) + + t.Run("GetClaimsByGER", func(t *testing.T) { + claims, err := s.GetClaimsByGER(ctx, nil, ger) + require.NoError(t, err) + require.Equal(t, []claimKey{ + {1, 0, 1, hash1}, + {2, 1, 8, common.Hash{}}, + {3, 0, 3, common.Hash{}}, + }, keys(claims)) + require.Equal(t, ger, claims[0].GlobalExitRoot) + require.Equal(t, claimsynctypes.DetailedClaimEvent, claims[0].Type) + + claims, err = s.GetClaimsByGER(ctx, rawDB, otherGER) + require.NoError(t, err) + require.Equal(t, []claimKey{{4, 2, 7, hash4}}, keys(claims)) + + claims, err = s.GetClaimsByGER(ctx, nil, common.HexToHash("0xdead")) + require.NoError(t, err) + require.Empty(t, claims) + }) +} + +// TestClaimWithBlockHashColumns checks that claimWithBlockHash maps the same columns as +// claimsynctypes.Claim, besides block_hash. Field names and types are already checked by the compiler, +// through the conversion in toClaims. +func TestClaimWithBlockHashColumns(t *testing.T) { + publicType, readModelType := reflect.TypeOf(claimsynctypes.Claim{}), reflect.TypeOf(claimWithBlockHash{}) + require.Equal(t, publicType.NumField(), readModelType.NumField()) + for i := range publicType.NumField() { + publicField, readModelField := publicType.Field(i), readModelType.Field(i) + require.Equal(t, publicField.Name, readModelField.Name) + if publicField.Name == "BlockHash" { + require.Equal(t, "-", publicField.Tag.Get("meddler")) + require.Equal(t, "block_hash,hash", readModelField.Tag.Get("meddler")) + continue + } + require.Equal(t, publicField.Tag.Get("meddler"), readModelField.Tag.Get("meddler"), publicField.Name) + } +} diff --git a/claimsync/types/claim_data.go b/claimsync/types/claim_data.go index ead5a264e..5c6698b37 100644 --- a/claimsync/types/claim_data.go +++ b/claimsync/types/claim_data.go @@ -42,6 +42,11 @@ type Claim struct { IsMessage bool `meddler:"is_message"` BlockTimestamp uint64 `meddler:"block_timestamp"` Type ClaimType `meddler:"type"` + // BlockHash is the hash of the block BlockNum, as stored in the block table. It is not a column of + // the claim table: the paged and by-GER read queries join it from the block table in the same + // statement that reads the row, so it is the hash of the block the claim belonged to when it was + // read. Zero if that block has no hash, or when the claim was read by any other query. + BlockHash common.Hash `meddler:"-"` } // String returns a string representation of the Claim. diff --git a/claimsync/types/claim_reader.go b/claimsync/types/claim_reader.go index acbc3f5f7..36eb3124c 100644 --- a/claimsync/types/claim_reader.go +++ b/claimsync/types/claim_reader.go @@ -15,6 +15,9 @@ type ClaimsReader interface { GetClaims(ctx context.Context, tx dbtypes.Querier, fromBlock, toBlock uint64) ([]Claim, error) GetClaimsByGlobalIndex(ctx context.Context, tx dbtypes.Querier, globalIndex *big.Int) ([]Claim, error) GetClaimsByGER(ctx context.Context, tx dbtypes.Querier, globalExitRoot common.Hash) ([]*Claim, error) + // GetBlockHash returns the hash stored for the block. found is false if the block is not stored + // or has no hash + GetBlockHash(ctx context.Context, tx dbtypes.Querier, blockNum uint64) (hash common.Hash, found bool, err error) GetClaimsPaged( ctx context.Context, pageNumber, pageSize uint32, networkIDs []uint32, globalIndex *big.Int, diff --git a/claimsync/types/claim_storager.go b/claimsync/types/claim_storager.go index c0ad59021..46f3735f0 100644 --- a/claimsync/types/claim_storager.go +++ b/claimsync/types/claim_storager.go @@ -38,6 +38,9 @@ type ClaimStorager interface { DeleteBlocksFrom(ctx context.Context, tx dbtypes.Querier, firstBlock uint64) (int64, error) // GetClaimsByGER returns all DetailedClaimEvent claims with the given global exit root GetClaimsByGER(ctx context.Context, tx dbtypes.Querier, globalExitRoot common.Hash) ([]*Claim, error) + // GetBlockHash returns the hash stored for the block. found is false if the block is not stored + // or has no hash + GetBlockHash(ctx context.Context, tx dbtypes.Querier, blockNum uint64) (hash common.Hash, found bool, err error) // GetClaimsPaged returns claims for the given page parameters and filters, // it returns: // - the list of claims for the requested page diff --git a/claimsync/types/mocks/mock_claim_storager.go b/claimsync/types/mocks/mock_claim_storager.go index 604b0d2e7..4fb8b7565 100644 --- a/claimsync/types/mocks/mock_claim_storager.go +++ b/claimsync/types/mocks/mock_claim_storager.go @@ -3,18 +3,14 @@ package mocks import ( + context "context" big "math/big" claimsynctypes "github.com/agglayer/aggkit/claimsync/types" + types "github.com/agglayer/aggkit/db/types" + sync "github.com/agglayer/aggkit/sync" common "github.com/ethereum/go-ethereum/common" - - context "context" - mock "github.com/stretchr/testify/mock" - - sync "github.com/agglayer/aggkit/sync" - - types "github.com/agglayer/aggkit/db/types" ) // ClaimStorager is an autogenerated mock type for the ClaimStorager type @@ -88,6 +84,73 @@ func (_c *ClaimStorager_DeleteBlocksFrom_Call) RunAndReturn(run func(context.Con return _c } +// GetBlockHash provides a mock function with given fields: ctx, tx, blockNum +func (_m *ClaimStorager) GetBlockHash(ctx context.Context, tx types.Querier, blockNum uint64) (common.Hash, bool, error) { + ret := _m.Called(ctx, tx, blockNum) + + if len(ret) == 0 { + panic("no return value specified for GetBlockHash") + } + + var r0 common.Hash + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, types.Querier, uint64) (common.Hash, bool, error)); ok { + return rf(ctx, tx, blockNum) + } + if rf, ok := ret.Get(0).(func(context.Context, types.Querier, uint64) common.Hash); ok { + r0 = rf(ctx, tx, blockNum) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Hash) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, types.Querier, uint64) bool); ok { + r1 = rf(ctx, tx, blockNum) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, types.Querier, uint64) error); ok { + r2 = rf(ctx, tx, blockNum) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ClaimStorager_GetBlockHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlockHash' +type ClaimStorager_GetBlockHash_Call struct { + *mock.Call +} + +// GetBlockHash is a helper method to define mock.On call +// - ctx context.Context +// - tx types.Querier +// - blockNum uint64 +func (_e *ClaimStorager_Expecter) GetBlockHash(ctx interface{}, tx interface{}, blockNum interface{}) *ClaimStorager_GetBlockHash_Call { + return &ClaimStorager_GetBlockHash_Call{Call: _e.mock.On("GetBlockHash", ctx, tx, blockNum)} +} + +func (_c *ClaimStorager_GetBlockHash_Call) Run(run func(ctx context.Context, tx types.Querier, blockNum uint64)) *ClaimStorager_GetBlockHash_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.Querier), args[2].(uint64)) + }) + return _c +} + +func (_c *ClaimStorager_GetBlockHash_Call) Return(hash common.Hash, found bool, err error) *ClaimStorager_GetBlockHash_Call { + _c.Call.Return(hash, found, err) + return _c +} + +func (_c *ClaimStorager_GetBlockHash_Call) RunAndReturn(run func(context.Context, types.Querier, uint64) (common.Hash, bool, error)) *ClaimStorager_GetBlockHash_Call { + _c.Call.Return(run) + return _c +} + // GetClaims provides a mock function with given fields: ctx, tx, fromBlock, toBlock func (_m *ClaimStorager) GetClaims(ctx context.Context, tx types.Querier, fromBlock uint64, toBlock uint64) ([]claimsynctypes.Claim, error) { ret := _m.Called(ctx, tx, fromBlock, toBlock) diff --git a/claimsync/types/mocks/mock_claims_reader.go b/claimsync/types/mocks/mock_claims_reader.go index 91009ac52..cfc591637 100644 --- a/claimsync/types/mocks/mock_claims_reader.go +++ b/claimsync/types/mocks/mock_claims_reader.go @@ -3,16 +3,13 @@ package mocks import ( + context "context" big "math/big" claimsynctypes "github.com/agglayer/aggkit/claimsync/types" + types "github.com/agglayer/aggkit/db/types" common "github.com/ethereum/go-ethereum/common" - - context "context" - mock "github.com/stretchr/testify/mock" - - types "github.com/agglayer/aggkit/db/types" ) // ClaimsReader is an autogenerated mock type for the ClaimsReader type @@ -28,6 +25,73 @@ func (_m *ClaimsReader) EXPECT() *ClaimsReader_Expecter { return &ClaimsReader_Expecter{mock: &_m.Mock} } +// GetBlockHash provides a mock function with given fields: ctx, tx, blockNum +func (_m *ClaimsReader) GetBlockHash(ctx context.Context, tx types.Querier, blockNum uint64) (common.Hash, bool, error) { + ret := _m.Called(ctx, tx, blockNum) + + if len(ret) == 0 { + panic("no return value specified for GetBlockHash") + } + + var r0 common.Hash + var r1 bool + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, types.Querier, uint64) (common.Hash, bool, error)); ok { + return rf(ctx, tx, blockNum) + } + if rf, ok := ret.Get(0).(func(context.Context, types.Querier, uint64) common.Hash); ok { + r0 = rf(ctx, tx, blockNum) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Hash) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, types.Querier, uint64) bool); ok { + r1 = rf(ctx, tx, blockNum) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, types.Querier, uint64) error); ok { + r2 = rf(ctx, tx, blockNum) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ClaimsReader_GetBlockHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBlockHash' +type ClaimsReader_GetBlockHash_Call struct { + *mock.Call +} + +// GetBlockHash is a helper method to define mock.On call +// - ctx context.Context +// - tx types.Querier +// - blockNum uint64 +func (_e *ClaimsReader_Expecter) GetBlockHash(ctx interface{}, tx interface{}, blockNum interface{}) *ClaimsReader_GetBlockHash_Call { + return &ClaimsReader_GetBlockHash_Call{Call: _e.mock.On("GetBlockHash", ctx, tx, blockNum)} +} + +func (_c *ClaimsReader_GetBlockHash_Call) Run(run func(ctx context.Context, tx types.Querier, blockNum uint64)) *ClaimsReader_GetBlockHash_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(types.Querier), args[2].(uint64)) + }) + return _c +} + +func (_c *ClaimsReader_GetBlockHash_Call) Return(hash common.Hash, found bool, err error) *ClaimsReader_GetBlockHash_Call { + _c.Call.Return(hash, found, err) + return _c +} + +func (_c *ClaimsReader_GetBlockHash_Call) RunAndReturn(run func(context.Context, types.Querier, uint64) (common.Hash, bool, error)) *ClaimsReader_GetBlockHash_Call { + _c.Call.Return(run) + return _c +} + // GetClaims provides a mock function with given fields: ctx, tx, fromBlock, toBlock func (_m *ClaimsReader) GetClaims(ctx context.Context, tx types.Querier, fromBlock uint64, toBlock uint64) ([]claimsynctypes.Claim, error) { ret := _m.Called(ctx, tx, fromBlock, toBlock) diff --git a/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 347a0d991..c84ddf22c 100644 --- a/docs/assets/swagger/bridge_service/swagger.json +++ b/docs/assets/swagger/bridge_service/swagger.json @@ -17,7 +17,7 @@ "paths": { "/": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -613,7 +613,7 @@ }, "/health": { "get": { - "description": "Returns the health status, version information and summary bridge sync status of\nthe bridge service. Always responds 200 -- see SyncStatus/Details for sync health.", + "description": "Returns the health status, version information, API revision and summary bridge sync\nstatus of the bridge service. Always responds 200 -- see SyncStatus/Details for sync\nhealth. api_revision is the bridge service wire contract version only, not the bridge\ntracker's.", "produces": [ "application/json" ], @@ -1499,6 +1499,11 @@ "description": "Contains basic health‐check information for the bridge service", "type": "object", "properties": { + "api_revision": { + "description": "APIRevision is the bridge service's wire API contract version (see\nbridgeservice.CurrentAPIRevision), bumped whenever a change could break an existing client, so a\nclient can tell which contract shape the responding instance speaks. It is independent of the\nbridge tracker's own api_revision. Purely informational, and always present, whatever the\nSyncStatus.", + "type": "integer", + "example": 1 + }, "details": { "description": "Details gives a compact per-component breakdown backing SyncStatus. It intentionally\ndoes not duplicate the full SyncStatus (deposit counts, block numbers) payload -- callers\nwanting that detail should use GET /bridge/v1/sync-status.", "allOf": [ @@ -1535,6 +1540,11 @@ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1700,6 +1710,11 @@ "type": "string", "example": "1000000000000000000" }, + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1929,6 +1944,11 @@ "types.L1InfoTreeLeafResponse": { "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block(s) this leaf depends on are finalized (reorg-safe): the L1 block\nof the leaf and, for an L2 lookup, the L2 block where the GER was injected too. Only set by the\ninjected-l1-info-leaf endpoint; omitted when the syncer that served the leaf cannot determine it (e.g. L2\nchains synced by l2gersync in Legacy mode, where the injection block is only approximate).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2266,6 +2286,11 @@ "description": "The deposit-count position of a local exit root in its network's local exit tree", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_num": { "description": "BlockNum is the block in which the leaf that produced this root was added", "type": "integer", @@ -2336,6 +2361,11 @@ "description": "A single L1 settlement event for this network's local exit root", "type": "object", "properties": { + "block_finalized": { + "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when the syncer that served the item cannot determine it.", + "type": "boolean", + "example": true + }, "block_hash": { "description": "L1 block's hash, when available (nil if the block table has no recorded hash for it).", "type": "string", diff --git a/docs/bridge_service.md b/docs/bridge_service.md index 7cdd19544..fff49abaa 100644 --- a/docs/bridge_service.md +++ b/docs/bridge_service.md @@ -213,6 +213,7 @@ It interacts with the L2 or L1 execution layer (via RPC) in order to: "status": "ok", "time": "2025-06-05T07:30:00Z", "version": "v0.11.0", + "api_revision": 1, "sync_status": "pending", "details": { "l1": { "is_active": true, "is_synced": false, "is_halted": false }, @@ -225,6 +226,18 @@ It interacts with the L2 or L1 execution layer (via RPC) in order to: } ``` +`api_revision` is the bridge service's wire API contract version (`bridgeservice.CurrentAPIRevision`, in +`bridgeservice/revision.go`, which also holds the revision history). It starts at `1` and is bumped by one +whenever a change could break an existing client — a field added/removed/renamed, an enum's value set +changed, a query parameter whose absence changes the response shape, or any behaviour change an existing +client could trip over — so a client can tell which contract an instance speaks without diffing response +bodies. It is purely informational (the service never rejects or alters its behaviour based on it) and is +always present, whatever the `sync_status`. It refers to the **bridge service contract only**: the bridge +tracker has its own, independent `api_revision` in `GET /tracker/v1/health`, and the two counters are +unrelated. The revision of an environment's instance can be read with `GET /health | jq .api_revision`. +The changes of each revision are detailed in +[bridgeservice/docs/API_REVISIONS.md](../bridgeservice/docs/API_REVISIONS.md), which also states when a bump is required. + `sync_status` (`types.HealthSyncStatus`) is one of: - `done` — every configured sync component is fully caught up (or has no "caught up" signal at all, diff --git a/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go new file mode 100644 index 000000000..74f7780ac --- /dev/null +++ b/l1infotreesync/is_block_finalized_test.go @@ -0,0 +1,258 @@ +package l1infotreesync + +import ( + "encoding/json" + "errors" + "math/big" + "path" + "reflect" + "testing" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/reorgdetector" + "github.com/agglayer/aggkit/sync" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestIsBlockFinalizedMultidownloader(t *testing.T) { + ctx := t.Context() + hash := common.HexToHash("0x10") + header := &aggkittypes.BlockHeader{Number: 10, Hash: hash} + + newSyncer := func(t *testing.T) (*L1InfoTreeSync, *BlockFinalityQuerierMock) { + t.Helper() + querier := NewBlockFinalityQuerierMock(t) + return &L1InfoTreeSync{blockFinality: querier}, querier + } + + t.Run("read only syncer: unknown", func(t *testing.T) { + syncer := &L1InfoTreeSync{} + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.False(t, finalized) + require.False(t, found) + }) + + t.Run("stored as finalized", func(t *testing.T) { + syncer, querier := newSyncer(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, true, nil) + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.True(t, finalized) + require.True(t, found) + }) + + t.Run("no hash given: unknown, the stored block may have replaced the one the caller read", func(t *testing.T) { + // the querier must not even be asked: no expectation set + syncer, _ := newSyncer(t) + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, common.Hash{}) + require.NoError(t, err) + require.False(t, finalized) + require.False(t, found) + }) + + t.Run("stored but not finalized", func(t *testing.T) { + syncer, querier := newSyncer(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, false, nil) + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.False(t, finalized) + require.True(t, found) + }) + + t.Run("stored with another hash: unknown", func(t *testing.T) { + syncer, querier := newSyncer(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, true, nil) + _, found, err := syncer.IsBlockFinalized(ctx, 10, common.HexToHash("0x99")) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("block not stored: unknown", func(t *testing.T) { + syncer, querier := newSyncer(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(nil, false, nil) + _, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("storage error", func(t *testing.T) { + syncer, querier := newSyncer(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)). + Return(nil, false, errors.New("boom")) + _, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) +} + +// newLegacyTestSyncer returns a legacy syncer with block 10 (hash 0x10) and block 11 (no hash) stored +func newLegacyTestSyncer(t *testing.T) (*L1InfoTreeSync, *sync.ReorgDetectorMock, common.Hash) { + t.Helper() + + processor, err := newProcessor(path.Join(t.TempDir(), "l1infotreesyncIsBlockFinalized.sqlite")) + require.NoError(t, err) + hash := common.HexToHash("0x10") + _, err = processor.db.Exec(`INSERT INTO block (num, hash) VALUES (10, $1)`, hash.String()) + require.NoError(t, err) + _, err = processor.db.Exec(`INSERT INTO block (num) VALUES (11)`) + require.NoError(t, err) + + rd := sync.NewReorgDetectorMock(t) + return &L1InfoTreeSync{processor: processor, reorgDetector: rd}, rd, hash +} + +func TestIsBlockFinalizedLegacy(t *testing.T) { + ctx := t.Context() + + t.Run("stored and not tracked anymore: finalized", func(t *testing.T) { + syncer, rd, hash := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, uint64(10)).Return(nil, db.ErrNotFound) + + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.True(t, found) + require.True(t, finalized) + }) + + t.Run("still tracked by the reorg detector: not finalized", func(t *testing.T) { + syncer, rd, hash := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, uint64(10)). + Return(&reorgdetector.Header{Num: 10, Hash: hash}, nil) + + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.True(t, found) + require.False(t, finalized) + }) + + t.Run("not tracked and hash differs from the stored one: unknown", func(t *testing.T) { + syncer, rd, _ := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, uint64(10)).Return(nil, db.ErrNotFound) + + _, found, err := syncer.IsBlockFinalized(ctx, 10, common.HexToHash("0x99")) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("zero hash: unknown without asking the reorg detector", func(t *testing.T) { + syncer, _, _ := newLegacyTestSyncer(t) + + _, found, err := syncer.IsBlockFinalized(ctx, 10, common.Hash{}) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("not tracked, block without hash or not stored: unknown", func(t *testing.T) { + syncer, rd, hash := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, mock.Anything).Return(nil, db.ErrNotFound).Twice() + + for _, tc := range []struct { + blockNum uint64 + hash common.Hash + }{{11, hash}, {99, hash}} { + _, found, err := syncer.IsBlockFinalized(ctx, tc.blockNum, tc.hash) + require.NoError(t, err) + require.False(t, found) + } + }) + + t.Run("unknown subscriber: error, never finalized", func(t *testing.T) { + syncer, rd, hash := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, uint64(10)). + Return(nil, reorgdetector.ErrSubscriberNotFound) + + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.ErrorIs(t, err, reorgdetector.ErrSubscriberNotFound) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("reorg detector error", func(t *testing.T) { + syncer, rd, hash := newLegacyTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(syncerID, uint64(10)).Return(nil, errors.New("boom")) + + _, found, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) + + t.Run("halted processor", func(t *testing.T) { + syncer, _, hash := newLegacyTestSyncer(t) + syncer.processor.halted = true + + _, _, err := syncer.IsBlockFinalized(ctx, 10, hash) + require.ErrorIs(t, err, sync.ErrInconsistentState) + }) +} + +func TestGetInfoByIndexFillsBlockHash(t *testing.T) { + processor, err := newProcessor(path.Join(t.TempDir(), "l1infotreesyncGetInfoByIndex.sqlite")) + require.NoError(t, err) + hash := common.HexToHash("0xb10c") + for i, blockHash := range []common.Hash{hash, common.HexToHash("0xb10c2")} { + require.NoError(t, processor.ProcessBlock(t.Context(), sync.Block{ + Num: uint64(i) + 1, + Hash: blockHash, + Events: []any{Event{UpdateL1InfoTree: &UpdateL1InfoTree{ + MainnetExitRoot: common.BigToHash(big.NewInt(int64(0xbeef + i))), + RollupExitRoot: common.HexToHash("5ca1e"), + ParentHash: common.HexToHash("1010101"), + Timestamp: 420 + uint64(i), + }}}, + })) + } + // block 2 without stored hash + _, err = processor.db.Exec(`UPDATE block SET hash = NULL WHERE num = 2`) + require.NoError(t, err) + syncer := &L1InfoTreeSync{processor: processor} + + leaf, err := syncer.GetInfoByIndex(t.Context(), 0) + require.NoError(t, err) + require.Equal(t, uint64(1), leaf.BlockNumber) + require.Equal(t, hash, leaf.BlockHash) + // the leaf's own hash column is not mixed up with the block's one + plainLeaf, err := processor.GetInfoByIndex(t.Context(), 0) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, plainLeaf.Hash) + require.Equal(t, common.Hash{}, plainLeaf.BlockHash) + plainLeaf.BlockHash = hash + require.Equal(t, plainLeaf, leaf) + + leaf, err = syncer.GetInfoByIndex(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, uint64(2), leaf.BlockNumber) + require.Equal(t, common.Hash{}, leaf.BlockHash) + + _, err = syncer.GetInfoByIndex(t.Context(), 2) + require.ErrorIs(t, err, db.ErrNotFound) + + // the hash is not part of the leaf's JSON + leaf, err = syncer.GetInfoByIndex(t.Context(), 0) + require.NoError(t, err) + raw, err := json.Marshal(leaf) + require.NoError(t, err) + require.NotContains(t, string(raw), "BlockHash") + require.NotContains(t, string(raw), hash.Hex()) +} + +// TestL1InfoTreeLeafWithBlockHashColumns checks that l1InfoTreeLeafWithBlockHash maps the same columns as +// L1InfoTreeLeaf, besides block_hash. Field names and types are already checked by the compiler, through +// the conversion in getInfoByIndexWithBlockHash. +func TestL1InfoTreeLeafWithBlockHashColumns(t *testing.T) { + publicType, readModelType := reflect.TypeOf(L1InfoTreeLeaf{}), reflect.TypeOf(l1InfoTreeLeafWithBlockHash{}) + require.Equal(t, publicType.NumField(), readModelType.NumField()) + for i := range publicType.NumField() { + publicField, readModelField := publicType.Field(i), readModelType.Field(i) + require.Equal(t, publicField.Name, readModelField.Name) + if publicField.Name == "BlockHash" { + require.Equal(t, "-", publicField.Tag.Get("meddler")) + require.Equal(t, "block_hash,hash", readModelField.Tag.Get("meddler")) + continue + } + require.Equal(t, publicField.Tag.Get("meddler"), readModelField.Tag.Get("meddler"), publicField.Name) + } +} diff --git a/l1infotreesync/l1infotreesync.go b/l1infotreesync/l1infotreesync.go index 1c8690059..f2af9fe7e 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -13,6 +13,8 @@ import ( "github.com/agglayer/aggkit/log" "github.com/agglayer/aggkit/multidownloader" mdrsync "github.com/agglayer/aggkit/multidownloader/sync" + mdrtypes "github.com/agglayer/aggkit/multidownloader/types" + "github.com/agglayer/aggkit/reorgdetector" "github.com/agglayer/aggkit/sync" "github.com/agglayer/aggkit/tree" "github.com/agglayer/aggkit/tree/types" @@ -43,10 +45,24 @@ type DriverInterface interface { GetCompletionPercentage() *float64 } +// BlockFinalityQuerier tells whether the downloader stored a block as finalized +type BlockFinalityQuerier interface { + // StorageHeaderByNumber returns the stored header of the block and whether it is finalized. + // The header is nil if the block is not stored + StorageHeaderByNumber(ctx context.Context, + number *aggkittypes.BlockNumberFinality) (*aggkittypes.BlockHeader, mdrtypes.FinalizedType, error) +} + type L1InfoTreeSync struct { processor *processor driver DriverInterface cfg Config + // blockFinality is only set when synced through the multidownloader, which is the one + // that tracks the finality of the blocks + blockFinality BlockFinalityQuerier + // reorgDetector is only set for the legacy syncer, which has no finality flag: it tracks (under + // syncerID) the blocks that were not final when processed + reorgDetector sync.ReorgDetector } type RuntimeData = mdrsync.RuntimeData @@ -137,9 +153,10 @@ func NewMultidownloadBased( return nil, err } return &L1InfoTreeSync{ - processor: processor, - driver: driver, - cfg: cfg, + processor: processor, + driver: driver, + cfg: cfg, + blockFinality: l1Multidownloader, }, nil } @@ -215,12 +232,58 @@ func NewLegacy( } return &L1InfoTreeSync{ - processor: processor, - driver: driver, - cfg: cfg, + processor: processor, + driver: driver, + cfg: cfg, + reorgDetector: reorgDetector, }, nil } +// IsBlockFinalized reports whether the block with the given number and hash, as read from this syncer, +// is finalized, i.e. it can no longer be reorged. +// With the multidownloader, it is the IsFinal flag the multidownloader stores, which is only set once the +// block was validated; the stored header must have the given hash. +// The legacy syncer has no such flag, but the reorg detector tracks every block that was not final when +// processed and only stops tracking it once its hash was checked against the canonical chain and it is +// below the finalized block. So a processed block with the given hash that is no longer tracked is +// finalized. +// In both modes the hash is required: the multidownloader stores one block per number, so without the hash +// of the block the caller read, the flag could belong to the block that replaced it after a reorg. +// found is false when it can't be determined: no hash was given (e.g. rows written before the block table +// had a hash), the block is not stored (or not with that hash), or the syncer is read-only. +func (d *L1InfoTreeSync) IsBlockFinalized(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + if blockHash == (common.Hash{}) { + return false, false, nil + } + + switch { + case d.blockFinality != nil: + header, isFinal, err := d.blockFinality.StorageHeaderByNumber(ctx, aggkittypes.NewBlockNumber(blockNum)) + if err != nil { + return false, false, fmt.Errorf("l1infotreesync: failed to get block %d finality: %w", blockNum, err) + } + if header == nil || header.Hash != blockHash { + return false, false, nil + } + return isFinal, true, nil + case d.reorgDetector != nil: + return d.isBlockFinalizedLegacy(ctx, blockNum, blockHash) + default: + return false, false, nil + } +} + +func (d *L1InfoTreeSync) isBlockFinalizedLegacy(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + if d.processor.isHalted() { + return false, false, sync.ErrInconsistentState + } + + return reorgdetector.IsBlockFinalized(ctx, d.reorgDetector, syncerID, blockNum, blockHash, + d.processor.getBlockHash) +} + // Finality returns the block finality this syncer syncs up to (its own BlockFinality config). // It is intentionally independent of the reorg-safety finality of the L1 multidownloader it may // run on. A read-only instance (NewReadOnly) has no config and returns the zero value. @@ -314,7 +377,9 @@ func (s *L1InfoTreeSync) GetInfoByIndex(ctx context.Context, index uint32) (*L1I if s.processor.isHalted() { return nil, sync.ErrInconsistentState } - return s.processor.GetInfoByIndex(ctx, index) + // the leaf and the hash of its block are read in the same statement, so the hash can't be the one of + // a block that replaced the leaf's block after a reorg + return s.processor.getInfoByIndexWithBlockHash(ctx, index) } // GetL1InfoTreeRootByIndex returns the root of the L1 info tree at the moment the leaf with the given index was added diff --git a/l1infotreesync/mock_block_finality_querier.go b/l1infotreesync/mock_block_finality_querier.go new file mode 100644 index 000000000..cec9c6381 --- /dev/null +++ b/l1infotreesync/mock_block_finality_querier.go @@ -0,0 +1,104 @@ +// Code generated by mockery. DO NOT EDIT. + +package l1infotreesync + +import ( + context "context" + + multidownloadertypes "github.com/agglayer/aggkit/multidownloader/types" + types "github.com/agglayer/aggkit/types" + mock "github.com/stretchr/testify/mock" +) + +// BlockFinalityQuerierMock is an autogenerated mock type for the BlockFinalityQuerier type +type BlockFinalityQuerierMock struct { + mock.Mock +} + +type BlockFinalityQuerierMock_Expecter struct { + mock *mock.Mock +} + +func (_m *BlockFinalityQuerierMock) EXPECT() *BlockFinalityQuerierMock_Expecter { + return &BlockFinalityQuerierMock_Expecter{mock: &_m.Mock} +} + +// StorageHeaderByNumber provides a mock function with given fields: ctx, number +func (_m *BlockFinalityQuerierMock) StorageHeaderByNumber(ctx context.Context, number *types.BlockNumberFinality) (*types.BlockHeader, multidownloadertypes.FinalizedType, error) { + ret := _m.Called(ctx, number) + + if len(ret) == 0 { + panic("no return value specified for StorageHeaderByNumber") + } + + var r0 *types.BlockHeader + var r1 multidownloadertypes.FinalizedType + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, *types.BlockNumberFinality) (*types.BlockHeader, multidownloadertypes.FinalizedType, error)); ok { + return rf(ctx, number) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.BlockNumberFinality) *types.BlockHeader); ok { + r0 = rf(ctx, number) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.BlockHeader) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.BlockNumberFinality) multidownloadertypes.FinalizedType); ok { + r1 = rf(ctx, number) + } else { + r1 = ret.Get(1).(multidownloadertypes.FinalizedType) + } + + if rf, ok := ret.Get(2).(func(context.Context, *types.BlockNumberFinality) error); ok { + r2 = rf(ctx, number) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// BlockFinalityQuerierMock_StorageHeaderByNumber_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StorageHeaderByNumber' +type BlockFinalityQuerierMock_StorageHeaderByNumber_Call struct { + *mock.Call +} + +// StorageHeaderByNumber is a helper method to define mock.On call +// - ctx context.Context +// - number *types.BlockNumberFinality +func (_e *BlockFinalityQuerierMock_Expecter) StorageHeaderByNumber(ctx interface{}, number interface{}) *BlockFinalityQuerierMock_StorageHeaderByNumber_Call { + return &BlockFinalityQuerierMock_StorageHeaderByNumber_Call{Call: _e.mock.On("StorageHeaderByNumber", ctx, number)} +} + +func (_c *BlockFinalityQuerierMock_StorageHeaderByNumber_Call) Run(run func(ctx context.Context, number *types.BlockNumberFinality)) *BlockFinalityQuerierMock_StorageHeaderByNumber_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.BlockNumberFinality)) + }) + return _c +} + +func (_c *BlockFinalityQuerierMock_StorageHeaderByNumber_Call) Return(_a0 *types.BlockHeader, _a1 multidownloadertypes.FinalizedType, _a2 error) *BlockFinalityQuerierMock_StorageHeaderByNumber_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *BlockFinalityQuerierMock_StorageHeaderByNumber_Call) RunAndReturn(run func(context.Context, *types.BlockNumberFinality) (*types.BlockHeader, multidownloadertypes.FinalizedType, error)) *BlockFinalityQuerierMock_StorageHeaderByNumber_Call { + _c.Call.Return(run) + return _c +} + +// NewBlockFinalityQuerierMock creates a new instance of BlockFinalityQuerierMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewBlockFinalityQuerierMock(t interface { + mock.TestingT + Cleanup(func()) +}) *BlockFinalityQuerierMock { + mock := &BlockFinalityQuerierMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/l1infotreesync/processor.go b/l1infotreesync/processor.go index 77ec548e2..742e0534f 100644 --- a/l1infotreesync/processor.go +++ b/l1infotreesync/processor.go @@ -153,6 +153,29 @@ type L1InfoTreeLeaf struct { RollupExitRoot common.Hash `meddler:"rollup_exit_root,hash" json:"rollup_exit_root"` GlobalExitRoot common.Hash `meddler:"global_exit_root,hash" json:"global_exit_root"` Hash common.Hash `meddler:"hash,hash" json:"hash"` + // BlockHash is the hash of the block BlockNumber, as stored in the block table. It is not a column of + // l1info_leaf: L1InfoTreeSync.GetInfoByIndex joins it from the block table in the same statement that + // reads the leaf, so it is the hash of the block the leaf belonged to when it was read. Zero if that + // block has no hash, or when the leaf was read by any other query. + BlockHash common.Hash `meddler:"-" json:"-"` +} + +// l1InfoTreeLeafWithBlockHash is the read model of an l1info_leaf row joined with the hash of its block +// (see getInfoByIndexWithBlockHash). It can't be L1InfoTreeLeaf itself: meddler.Insert writes every +// meddler-tagged field and block_hash is not a column of l1info_leaf, and meddler doesn't flatten +// embedded structs. It has exactly the fields of L1InfoTreeLeaf, in the same order, so that +// L1InfoTreeLeaf(row) compiles only while both match. +type l1InfoTreeLeafWithBlockHash struct { + BlockNumber uint64 `meddler:"block_num"` + BlockPosition uint64 `meddler:"block_pos"` + L1InfoTreeIndex uint32 `meddler:"position"` + PreviousBlockHash common.Hash `meddler:"previous_block_hash,hash"` + Timestamp uint64 `meddler:"timestamp"` + MainnetExitRoot common.Hash `meddler:"mainnet_exit_root,hash"` + RollupExitRoot common.Hash `meddler:"rollup_exit_root,hash"` + GlobalExitRoot common.Hash `meddler:"global_exit_root,hash"` + Hash common.Hash `meddler:"hash,hash"` + BlockHash common.Hash `meddler:"block_hash,hash"` } func (l *L1InfoTreeLeaf) String() string { @@ -287,11 +310,50 @@ func (p *processor) GetLatestL1InfoGER(ctx context.Context) (common.Hash, error) return common.HexToHash(gerHex), nil } +// getBlockHash returns the hash stored for the given block. found is false if the block is not stored +// or it has no hash (blocks synced before the hash column existed). +func (p *processor) getBlockHash(ctx context.Context, blockNum uint64) (hash common.Hash, found bool, err error) { + var hashStr sql.NullString + err = p.db.QueryRowContext(ctx, `SELECT hash FROM block WHERE num = $1`, blockNum).Scan(&hashStr) + if errors.Is(err, sql.ErrNoRows) { + return common.Hash{}, false, nil + } + if err != nil { + return common.Hash{}, false, fmt.Errorf("failed to get the hash of block %d: %w", blockNum, err) + } + if !hashStr.Valid || hashStr.String == "" { + return common.Hash{}, false, nil + } + return common.HexToHash(hashStr.String), true, nil +} + // GetInfoByIndex returns the value of a leaf (not the hash) of the L1 info tree func (p *processor) GetInfoByIndex(ctx context.Context, index uint32) (*L1InfoTreeLeaf, error) { return p.getInfoByIndexWithTx(p.db, index) } +// getInfoByIndexWithBlockHash returns the leaf with the given index, like GetInfoByIndex, with BlockHash +// set to the hash of its block read in the same statement (zero if that block has no hash). Both tables +// have a hash column: the leaf's one is l.hash, the block's one is returned as block_hash. +func (p *processor) getInfoByIndexWithBlockHash(ctx context.Context, index uint32) (*L1InfoTreeLeaf, error) { + rows, err := p.db.QueryContext(ctx, ` + SELECT l.*, COALESCE(b.hash, '') AS block_hash + FROM l1info_leaf l + LEFT JOIN block b ON b.num = l.block_num + WHERE l.position = $1; + `, index) + if err != nil { + return nil, err + } + var row l1InfoTreeLeafWithBlockHash + // meddler.ScanRow closes rows + if err = meddler.ScanRow(rows, &row); err != nil { + return nil, db.ReturnErrNotFound(err) + } + leaf := L1InfoTreeLeaf(row) + return &leaf, nil +} + func (p *processor) getInfoByIndexWithTx(tx dbtypes.DBer, index uint32) (*L1InfoTreeLeaf, error) { info := &L1InfoTreeLeaf{} return info, db.ReturnErrNotFound(meddler.QueryRow( diff --git a/l2gersync/is_block_finalized_test.go b/l2gersync/is_block_finalized_test.go new file mode 100644 index 000000000..48afc31d9 --- /dev/null +++ b/l2gersync/is_block_finalized_test.go @@ -0,0 +1,167 @@ +package l2gersync + +import ( + "context" + "errors" + "path" + "reflect" + "testing" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/reorgdetector" + "github.com/agglayer/aggkit/sync" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newBlockFinalityTestSyncer returns a SovereignChain syncer with a GER injected in block 10 +// (hash 0x10) and another in block 11 stored without hash +func newBlockFinalityTestSyncer(t *testing.T) (*L2GERSync, *sync.ReorgDetectorMock, common.Hash) { + t.Helper() + + processor, err := newProcessor(path.Join(t.TempDir(), "l2gersync_IsBlockFinalized.sqlite")) + require.NoError(t, err) + + hash := common.HexToHash("0x10") + require.NoError(t, processor.ProcessBlock(context.Background(), sync.Block{ + Num: 10, + Hash: hash, + Events: []any{newEvent(newGlobalExitRootInfo(common.HexToHash("0x1"), 2, 10, 0), GEREventTypeInsert)}, + })) + _, err = processor.database.Exec(`INSERT INTO block (num) VALUES (11)`) + require.NoError(t, err) + + rd := sync.NewReorgDetectorMock(t) + return &L2GERSync{processor: processor, reorgDetector: rd, syncMode: SovereignChain}, rd, hash +} + +func TestL2GERSyncIsBlockFinalized(t *testing.T) { + ctx := context.Background() + + t.Run("stored and not tracked anymore: finalized", func(t *testing.T) { + s, rd, hash := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, uint64(10)).Return(nil, db.ErrNotFound) + + finalized, found, err := s.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.True(t, found) + require.True(t, finalized) + }) + + t.Run("still tracked by the reorg detector: not finalized", func(t *testing.T) { + s, rd, hash := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, uint64(10)). + Return(&reorgdetector.Header{Num: 10, Hash: hash}, nil) + + finalized, found, err := s.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.True(t, found) + require.False(t, finalized) + }) + + t.Run("not tracked and hash differs from the stored one: unknown", func(t *testing.T) { + s, rd, _ := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, uint64(10)).Return(nil, db.ErrNotFound) + + _, found, err := s.IsBlockFinalized(ctx, 10, common.HexToHash("0x99")) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("zero hash: unknown without asking the reorg detector", func(t *testing.T) { + s, _, _ := newBlockFinalityTestSyncer(t) + + _, found, err := s.IsBlockFinalized(ctx, 10, common.Hash{}) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("not tracked, block without hash or not stored: unknown", func(t *testing.T) { + s, rd, hash := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, mock.Anything).Return(nil, db.ErrNotFound).Twice() + + for _, tc := range []struct { + blockNum uint64 + hash common.Hash + }{{11, hash}, {99, hash}} { + _, found, err := s.IsBlockFinalized(ctx, tc.blockNum, tc.hash) + require.NoError(t, err) + require.False(t, found) + } + }) + + t.Run("Legacy mode: unknown, the stored block is the polling head", func(t *testing.T) { + s, _, hash := newBlockFinalityTestSyncer(t) + s.syncMode = Legacy + + _, found, err := s.IsBlockFinalized(ctx, 10, hash) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("unknown subscriber: error, never finalized", func(t *testing.T) { + s, rd, hash := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, uint64(10)). + Return(nil, reorgdetector.ErrSubscriberNotFound) + + finalized, found, err := s.IsBlockFinalized(ctx, 10, hash) + require.ErrorIs(t, err, reorgdetector.ErrSubscriberNotFound) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("reorg detector error", func(t *testing.T) { + s, rd, hash := newBlockFinalityTestSyncer(t) + rd.EXPECT().GetTrackedBlockByBlockNumber(reorgDetectorID, uint64(10)).Return(nil, errors.New("boom")) + + _, found, err := s.IsBlockFinalized(ctx, 10, hash) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) +} + +func TestGetFirstGERAfterL1InfoTreeIndexFillsBlockHash(t *testing.T) { + s, _, hash := newBlockFinalityTestSyncer(t) + ctx := context.Background() + + info, err := s.processor.GetFirstGERAfterL1InfoTreeIndex(ctx, 1) + require.NoError(t, err) + require.Equal(t, uint64(10), info.BlockNum) + require.Equal(t, uint32(2), info.L1InfoTreeIndex) + require.Equal(t, common.HexToHash("0x1"), info.GlobalExitRoot) + require.Equal(t, hash, info.BlockHash) + + // a GER in a block stored without hash + _, err = s.processor.database.Exec(`INSERT INTO imported_global_exit_root_v2 + (block_num, block_pos, global_exit_root, l1_info_tree_index) VALUES (11, 0, $1, 3)`, + common.HexToHash("0x2").Hex()) + require.NoError(t, err) + info, err = s.processor.GetFirstGERAfterL1InfoTreeIndex(ctx, 3) + require.NoError(t, err) + require.Equal(t, uint64(11), info.BlockNum) + require.Equal(t, common.HexToHash("0x2"), info.GlobalExitRoot) + require.Equal(t, common.Hash{}, info.BlockHash) + + _, err = s.processor.GetFirstGERAfterL1InfoTreeIndex(ctx, 4) + require.ErrorIs(t, err, db.ErrNotFound) +} + +// TestGlobalExitRootInfoWithBlockHashColumns checks that globalExitRootInfoWithBlockHash maps the same +// columns as GlobalExitRootInfo, besides block_hash. Field names and types are already checked by the +// compiler, through the conversion in GetFirstGERAfterL1InfoTreeIndex. +func TestGlobalExitRootInfoWithBlockHashColumns(t *testing.T) { + publicType := reflect.TypeOf(GlobalExitRootInfo{}) + readModelType := reflect.TypeOf(globalExitRootInfoWithBlockHash{}) + require.Equal(t, publicType.NumField(), readModelType.NumField()) + for i := range publicType.NumField() { + publicField, readModelField := publicType.Field(i), readModelType.Field(i) + require.Equal(t, publicField.Name, readModelField.Name) + if publicField.Name == "BlockHash" { + require.Equal(t, "-", publicField.Tag.Get("meddler")) + require.Equal(t, "block_hash,hash", readModelField.Tag.Get("meddler")) + continue + } + require.Equal(t, publicField.Tag.Get("meddler"), readModelField.Tag.Get("meddler"), publicField.Name) + } +} diff --git a/l2gersync/l2_ger_syncer.go b/l2gersync/l2_ger_syncer.go index f0a450f69..6ff4d1eae 100644 --- a/l2gersync/l2_ger_syncer.go +++ b/l2gersync/l2_ger_syncer.go @@ -10,6 +10,7 @@ import ( "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/l1infotreesync" "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/reorgdetector" "github.com/agglayer/aggkit/sync" treetypes "github.com/agglayer/aggkit/tree/types" aggkittypes "github.com/agglayer/aggkit/types" @@ -49,6 +50,8 @@ type L2GERSync struct { // the real injection block (see evm_downloader_legacy.go), so resolving a timestamp from it // would be no more accurate than not persisting one at all syncMode SyncMode + // reorgDetector tracks (under reorgDetectorID) the blocks that were not final when processed + reorgDetector sync.ReorgDetector } // New initializes and returns a new instance of L2GERSync @@ -120,6 +123,8 @@ func New( cfg: cfg, l2Client: l2Client, syncMode: syncMode, + + reorgDetector: rdL2, }, nil } @@ -238,3 +243,21 @@ func (s *L2GERSync) GetRemoveGEREvents( ) ([]*RemoveGEREvent, error) { return s.processor.GetRemoveGEREvents(ctx, globalExitRoot, limit) } + +// IsBlockFinalized reports whether the block with the given number and hash, as read from this syncer, +// is finalized, i.e. it can no longer be reorged. The syncer has no finality flag, but the reorg detector +// tracks every block that was not final when processed and only stops tracking it once its hash was +// checked against the canonical chain and it is below the finalized block. So a processed block with the +// given hash that is no longer tracked is finalized. +// found is false when it can't be determined: no hash was given, the block is not stored with that same +// hash (unknown, no hash stored for it, or it was replaced after the caller read it), or the chain is +// synced in Legacy mode, where the stored block number is the polling head and not the injection block. +func (s *L2GERSync) IsBlockFinalized(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + if s.syncMode == Legacy || blockHash == (common.Hash{}) { + return false, false, nil + } + + return reorgdetector.IsBlockFinalized(ctx, s.reorgDetector, reorgDetectorID, blockNum, blockHash, + s.processor.getBlockHash) +} diff --git a/l2gersync/processor.go b/l2gersync/processor.go index cbf78475f..3cf3049ec 100644 --- a/l2gersync/processor.go +++ b/l2gersync/processor.go @@ -34,6 +34,27 @@ type GlobalExitRootInfo struct { // (see evm_downloader_legacy.go). Timestamp *uint64 `meddler:"block_timestamp"` Removed bool `meddler:"-"` + // BlockHash is the hash of the block BlockNum, as stored in the block table. It is not a column of + // imported_global_exit_root_v2: GetFirstGERAfterL1InfoTreeIndex joins it from the block table in the + // same statement that reads the row, so it is the hash of the block the GER belonged to when it was + // read. Zero if that block has no hash, or when the GER was read by any other query. + BlockHash ethcommon.Hash `meddler:"-"` +} + +// globalExitRootInfoWithBlockHash is the read model of an imported_global_exit_root_v2 row joined with +// the hash of its block (see GetFirstGERAfterL1InfoTreeIndex). It can't be GlobalExitRootInfo itself: +// meddler.Insert writes every meddler-tagged field and block_hash is not a column of +// imported_global_exit_root_v2, and meddler doesn't flatten embedded structs. It has exactly the fields +// of GlobalExitRootInfo, in the same order, so that GlobalExitRootInfo(row) compiles only while both +// match. +type globalExitRootInfoWithBlockHash struct { + GlobalExitRoot ethcommon.Hash `meddler:"global_exit_root,hash"` + L1InfoTreeIndex uint32 `meddler:"l1_info_tree_index"` + BlockNum uint64 `meddler:"block_num"` + BlockPosition uint64 `meddler:"block_pos"` + Timestamp *uint64 `meddler:"block_timestamp"` + Removed bool `meddler:"-"` + BlockHash ethcommon.Hash `meddler:"block_hash,hash"` } // RemoveGEREvent represents a remove GER event stored in the database @@ -228,21 +249,46 @@ func (p *processor) Reorg(ctx context.Context, firstReorgedBlock uint64) error { // or greater than the specified l1InfoTreeIndex. func (p *processor) GetFirstGERAfterL1InfoTreeIndex( ctx context.Context, l1InfoTreeIndex uint32) (GlobalExitRootInfo, error) { - e := GlobalExitRootInfo{} - err := meddler.QueryRow(p.database, &e, ` - SELECT l1_info_tree_index, block_num, global_exit_root, block_pos, block_timestamp - FROM imported_global_exit_root_v2 - WHERE l1_info_tree_index >= $1 - ORDER BY l1_info_tree_index ASC LIMIT 1; + // the hash of the GER's block is joined in the same statement, so it is the hash of the block the GER + // belonged to when it was read (see GlobalExitRootInfo.BlockHash) + rows, err := p.database.QueryContext(ctx, ` + SELECT g.l1_info_tree_index, g.block_num, g.global_exit_root, g.block_pos, g.block_timestamp, + COALESCE(b.hash, '') AS block_hash + FROM imported_global_exit_root_v2 g + LEFT JOIN block b ON b.num = g.block_num + WHERE g.l1_info_tree_index >= $1 + ORDER BY g.l1_info_tree_index ASC LIMIT 1; `, l1InfoTreeIndex) if err != nil { + return GlobalExitRootInfo{}, fmt.Errorf("failed to get first GER after index %d: %w", l1InfoTreeIndex, err) + } + row := globalExitRootInfoWithBlockHash{} + // meddler.ScanRow closes rows + if err = meddler.ScanRow(rows, &row); err != nil { if errors.Is(err, sql.ErrNoRows) { - return e, db.ErrNotFound + return GlobalExitRootInfo{}, db.ErrNotFound } - return e, fmt.Errorf("failed to get first GER after index %d: %w", l1InfoTreeIndex, err) + return GlobalExitRootInfo{}, fmt.Errorf("failed to get first GER after index %d: %w", l1InfoTreeIndex, err) } - return e, nil + return GlobalExitRootInfo(row), nil +} + +// getBlockHash returns the hash stored for the given block. found is false if the block is not stored +// or it has no hash (blocks synced before the hash column existed). +func (p *processor) getBlockHash(ctx context.Context, blockNum uint64) (hash ethcommon.Hash, found bool, err error) { + var hashStr sql.NullString + err = p.database.QueryRowContext(ctx, `SELECT hash FROM block WHERE num = $1`, blockNum).Scan(&hashStr) + if errors.Is(err, sql.ErrNoRows) { + return ethcommon.Hash{}, false, nil + } + if err != nil { + return ethcommon.Hash{}, false, fmt.Errorf("failed to get the hash of block %d: %w", blockNum, err) + } + if !hashStr.Valid || hashStr.String == "" { + return ethcommon.Hash{}, false, nil + } + return ethcommon.HexToHash(hashStr.String), true, nil } // GetLastGER retrieves the most recently injected global exit root, i.e. the row with the diff --git a/reorgdetector/is_block_finalized.go b/reorgdetector/is_block_finalized.go new file mode 100644 index 000000000..2ae7a303f --- /dev/null +++ b/reorgdetector/is_block_finalized.go @@ -0,0 +1,75 @@ +package reorgdetector + +import ( + "context" + "errors" + "fmt" + + "github.com/agglayer/aggkit/db" + "github.com/ethereum/go-ethereum/common" +) + +// BlockTracker is the part of the ReorgDetector needed to know if a block is still tracked +type BlockTracker interface { + GetTrackedBlockByBlockNumber(id string, blockNumber uint64) (*Header, error) +} + +// StoredBlockHashFunc returns the hash the syncer stored for a block; found=false if not stored / no hash +type StoredBlockHashFunc func(ctx context.Context, blockNum uint64) (hash common.Hash, found bool, err error) + +// IsBlockFinalized reports whether the block with the given number and hash, as read earlier from a syncer +// that is subscribed to the reorg detector with the given id, is finalized. +// +// The reorg detector tracks every block that was not final when the syncer processed it, and stops tracking +// it only once its hash was verified against the canonical chain and it is below the finalized block. So a +// processed block that is not tracked anymore is either finalized or was removed by a reorg. +// +// The order of the checks matters and must not be changed: the tracker is asked FIRST and the syncer's +// stored hash is read AFTERWARDS. On a reorg the reorg detector first notifies the syncer, which deletes the +// orphaned blocks from its DB, and only then stops tracking them. Reading the DB first would allow: +// (1) the DB still has the orphaned block, (2) the whole reorg completes, (3) the block is not tracked +// anymore => the orphaned block would be reported as finalized. With the tracker first, a block that is not +// tracked either was verified as final (the DB still has it, with the same hash) or its reorg completed +// (the DB doesn't have it, or has another block), so the DB read tells them apart. The hash given by the +// caller was read from the syncer earlier, so a block that was not final was already tracked by then (the +// driver tracks a block before processing it). +// +// Result: +// - found=false when it can't be determined: no hash was given, the block tracked or stored is not the one +// with the given hash, or it is not stored. +// - found=true, finalized=false when the block is still tracked. +// - found=true, finalized=true when the block is not tracked and the syncer stores it with the given hash. +// +// An unknown subscriber id is an error (ErrSubscriberNotFound), never "finalized". +func IsBlockFinalized(ctx context.Context, tracker BlockTracker, id string, blockNum uint64, + blockHash common.Hash, storedHash StoredBlockHashFunc) (finalized, found bool, err error) { + if blockHash == (common.Hash{}) { + return false, false, nil + } + + header, err := tracker.GetTrackedBlockByBlockNumber(id, blockNum) + switch { + case err == nil: + if header == nil || header.Hash != blockHash { + // the block the caller read is not the tracked one + return false, false, nil + } + // still tracked: its hash was not confirmed against a finalized block yet + return false, true, nil + case errors.Is(err, ErrSubscriberNotFound): + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + case errors.Is(err, db.ErrNotFound): + // not tracked anymore: finalized or reorged, the stored hash tells which + default: + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + } + + stored, ok, err := storedHash(ctx, blockNum) + if err != nil { + return false, false, err + } + if !ok || stored != blockHash { + return false, false, nil + } + return true, true, nil +} diff --git a/reorgdetector/is_block_finalized_test.go b/reorgdetector/is_block_finalized_test.go new file mode 100644 index 000000000..b8c169d8c --- /dev/null +++ b/reorgdetector/is_block_finalized_test.go @@ -0,0 +1,178 @@ +package reorgdetector + +import ( + "context" + "errors" + "math/big" + "path" + "testing" + "time" + + cfgtypes "github.com/agglayer/aggkit/config/types" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/etherman" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient/simulated" + "github.com/stretchr/testify/require" +) + +func TestIsBlockFinalized(t *testing.T) { + ctx := context.Background() + const syncerID = "test-syncer" + hash := common.BigToHash(big.NewInt(5)) + otherHash := common.BigToHash(big.NewInt(6)) + + newDetector := func(t *testing.T) *ReorgDetector { + t.Helper() + clientL1 := simulated.NewBackend(nil, simulated.WithBlockGasLimit(10000000)) + rd, err := New(etherman.NewDefaultEthClient(clientL1.Client(), nil, nil), Config{ + DBPath: path.Join(t.TempDir(), "reorgdetectorIsBlockFinalized.sqlite"), + CheckReorgsInterval: cfgtypes.NewDuration(time.Hour), + }, L1) + require.NoError(t, err) + _, err = rd.Subscribe(syncerID) + require.NoError(t, err) + require.NoError(t, rd.AddBlockToTrack(ctx, syncerID, 5, hash)) + return rd + } + // stored returns a StoredBlockHashFunc that counts its calls + stored := func(calls *int, h common.Hash, ok bool, err error) StoredBlockHashFunc { + return func(context.Context, uint64) (common.Hash, bool, error) { + *calls++ + return h, ok, err + } + } + + t.Run("tracked with the same hash: not finalized", func(t *testing.T) { + calls := 0 + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), syncerID, 5, hash, + stored(&calls, hash, true, nil)) + require.NoError(t, err) + require.True(t, found) + require.False(t, finalized) + require.Zero(t, calls) + }) + + t.Run("tracked with another hash: unknown", func(t *testing.T) { + calls := 0 + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), syncerID, 5, otherHash, + stored(&calls, otherHash, true, nil)) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + require.Zero(t, calls) + }) + + t.Run("not tracked and stored with the same hash: finalized", func(t *testing.T) { + calls := 0 + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), syncerID, 10, hash, + stored(&calls, hash, true, nil)) + require.NoError(t, err) + require.True(t, found) + require.True(t, finalized) + require.Equal(t, 1, calls) + }) + + t.Run("not tracked and stored with another hash or not stored: unknown", func(t *testing.T) { + for name, cb := range map[string]func(*int) StoredBlockHashFunc{ + "another hash": func(c *int) StoredBlockHashFunc { return stored(c, otherHash, true, nil) }, + "not found": func(c *int) StoredBlockHashFunc { return stored(c, common.Hash{}, false, nil) }, + } { + t.Run(name, func(t *testing.T) { + calls := 0 + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), syncerID, 10, hash, cb(&calls)) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + require.Equal(t, 1, calls) + }) + } + }) + + t.Run("unknown subscriber: error, never finalized, DB not read", func(t *testing.T) { + calls := 0 + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), "non-existent-syncer", 10, hash, + stored(&calls, hash, true, nil)) + require.ErrorIs(t, err, ErrSubscriberNotFound) + require.False(t, found) + require.False(t, finalized) + require.Zero(t, calls) + }) + + t.Run("zero hash: unknown, nothing is called", func(t *testing.T) { + calls := 0 + tracker := &fakeTracker{err: errors.New("must not be called")} + finalized, found, err := IsBlockFinalized(ctx, tracker, syncerID, 5, common.Hash{}, + stored(&calls, hash, true, nil)) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + require.Zero(t, calls) + require.Zero(t, tracker.calls) + }) + + t.Run("stored hash callback error propagates", func(t *testing.T) { + calls := 0 + boom := errors.New("boom") + finalized, found, err := IsBlockFinalized(ctx, newDetector(t), syncerID, 10, hash, + stored(&calls, common.Hash{}, false, boom)) + require.ErrorIs(t, err, boom) + require.False(t, found) + require.False(t, finalized) + }) + + t.Run("tracker generic error propagates", func(t *testing.T) { + calls := 0 + boom := errors.New("boom") + finalized, found, err := IsBlockFinalized(ctx, &fakeTracker{err: boom}, syncerID, 10, hash, + stored(&calls, hash, true, nil)) + require.ErrorIs(t, err, boom) + require.False(t, found) + require.False(t, finalized) + require.Zero(t, calls) + }) +} + +// TestIsBlockFinalizedAsksTrackerBeforeStoredHash checks the order of the checks with a reorg that completes +// between them. The reorg detector deletes the orphaned block from the syncer (through the notification) and +// only afterwards stops tracking it, so by the time the tracker says "not tracked" the DB doesn't have the +// block anymore. A DB-first implementation would have read the block (matching hash) before the reorg and +// then, seeing it untracked, would return finalized=true for an orphaned block. Tracker-first returns unknown. +func TestIsBlockFinalizedAsksTrackerBeforeStoredHash(t *testing.T) { + hash := common.BigToHash(big.NewInt(5)) + var order []string + dbHasBlock := true + + tracker := &fakeTracker{ + err: db.ErrNotFound, + hook: func() { + order = append(order, "tracker") + // the reorg just completed: the syncer deleted the block and the detector stopped tracking it + dbHasBlock = false + }, + } + storedHash := func(context.Context, uint64) (common.Hash, bool, error) { + order = append(order, "stored") + return hash, dbHasBlock, nil + } + + finalized, found, err := IsBlockFinalized(context.Background(), tracker, "id", 5, hash, storedHash) + require.NoError(t, err) + require.False(t, found) + require.False(t, finalized) + require.Equal(t, []string{"tracker", "stored"}, order) +} + +type fakeTracker struct { + err error + hook func() + calls int +} + +func (f *fakeTracker) GetTrackedBlockByBlockNumber(string, uint64) (*Header, error) { + f.calls++ + if f.hook != nil { + f.hook() + } + return nil, f.err +} diff --git a/reorgdetector/reorgdetector.go b/reorgdetector/reorgdetector.go index 020bbb7cc..407161ce4 100644 --- a/reorgdetector/reorgdetector.go +++ b/reorgdetector/reorgdetector.go @@ -3,6 +3,7 @@ package reorgdetector import ( "context" "database/sql" + "errors" "fmt" "sync" "time" @@ -142,13 +143,19 @@ func (rd *ReorgDetector) AddBlockToTrack(ctx context.Context, id string, num uin return nil } -// func to get the tracked blocks for a subscriber by block number and hash, it should be 1 or none, from headercache +// ErrSubscriberNotFound is returned by GetTrackedBlockByBlockNumber when the id has no tracker, so it can't be +// confused with db.ErrNotFound, which means that the block is not tracked by a known subscriber +var ErrSubscriberNotFound = errors.New("reorgdetector: subscriber not found") + +// GetTrackedBlockByBlockNumber returns the tracked block of a subscriber by block number (1 or none, from +// headercache). It returns ErrSubscriberNotFound if the id is not subscribed and db.ErrNotFound if the block +// is not tracked func (rd *ReorgDetector) GetTrackedBlockByBlockNumber(id string, blockNumber uint64) (*Header, error) { rd.trackedBlocksLock.RLock() defer rd.trackedBlocksLock.RUnlock() header, ok := rd.trackedBlocks[id] if !ok { - return nil, db.ErrNotFound + return nil, fmt.Errorf("%w: %s", ErrSubscriberNotFound, id) } return header.get(blockNumber) } diff --git a/reorgdetector/reorgdetector_test.go b/reorgdetector/reorgdetector_test.go index 2808a3cce..fdf00de6f 100644 --- a/reorgdetector/reorgdetector_test.go +++ b/reorgdetector/reorgdetector_test.go @@ -381,7 +381,8 @@ func TestGetTrackedBlockByBlockNumber(t *testing.T) { header, err := reorgDetector.GetTrackedBlockByBlockNumber("non-existent-syncer", 1) require.Error(t, err) require.Nil(t, header) - require.Equal(t, db.ErrNotFound, err) + require.ErrorIs(t, err, ErrSubscriberNotFound) + require.NotErrorIs(t, err, db.ErrNotFound) // Test block number not found for existing subscriber testHeader := Header{Num: 5, Hash: common.BigToHash(big.NewInt(5))} diff --git a/tree/types/types.go b/tree/types/types.go index dd1826709..7d7732b62 100644 --- a/tree/types/types.go +++ b/tree/types/types.go @@ -21,6 +21,11 @@ type Root struct { Index uint32 `meddler:"position"` BlockNum uint64 `meddler:"block_num"` BlockPosition uint64 `meddler:"block_position"` + // BlockHash is the hash of the block BlockNum, as stored by the syncer. It is not a column of + // the tree, so the tree never fills it: only the syncer queries that serve a root with its block + // hash (e.g. BridgeSync.GetRootByLER) set it, reading it in the same statement as the root. + // Zero if that block has no hash, or when the root was read by any other query. + BlockHash common.Hash `meddler:"-"` } type TreeNode struct {