From 3019acd1178ae581c7c322852c7bce3eec42dbcd Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:05:01 +0200 Subject: [PATCH 01/12] feat(bridgeservice): add block_finalized to block-related responses Report whether the block of each item is finalized on its network in /bridges, /claims, /injected-l1-info-leaf, /root-by-ler and /settlements (plus the bridge/claim lookups sharing the same response types). The field is a nullable bool omitted when the finalized block cannot be determined, keeping compatibility with previous versions. Fixes #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 186 ++++++++++++++++++ bridgeservice/bridge.go | 39 +++- bridgeservice/bridge_interfaces.go | 1 + bridgeservice/bridge_test.go | 17 ++ bridgeservice/docs/docs.go | 25 +++ bridgeservice/docs/swagger.json | 25 +++ bridgeservice/docs/swagger.yaml | 31 +++ bridgeservice/mocks/mock_bridger.go | 65 +++++- bridgeservice/types/types.go | 21 ++ bridgeservice/utils.go | 48 +++++ bridgesync/bridgesync.go | 15 ++ .../swagger/bridge_service/swagger.json | 25 +++ 12 files changed, 484 insertions(+), 14 deletions(-) create mode 100644 bridgeservice/block_finalized_test.go diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go new file mode 100644 index 000000000..3b56ea9a0 --- /dev/null +++ b/bridgeservice/block_finalized_test.go @@ -0,0 +1,186 @@ +package bridgeservice + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + + bridgetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + claimsynctypes "github.com/agglayer/aggkit/claimsync/types" + "github.com/agglayer/aggkit/l1infotreesync" + 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 TestBlockFinalityIsFinalized(t *testing.T) { + finalizedBlock := uint64(100) + finality := blockFinality{finalizedBlock: &finalizedBlock} + + require.Equal(t, boolPtr(true), finality.isFinalized(99)) + require.Equal(t, boolPtr(true), finality.isFinalized(100)) + require.Equal(t, boolPtr(false), finality.isFinalized(101)) + require.Nil(t, blockFinality{}.isFinalized(1)) +} + +func TestAndFinalized(t *testing.T) { + require.Equal(t, boolPtr(true), andFinalized(boolPtr(true), boolPtr(true))) + require.Equal(t, boolPtr(false), andFinalized(boolPtr(true), boolPtr(false))) + require.Equal(t, boolPtr(false), andFinalized(boolPtr(false), boolPtr(true))) + require.Nil(t, andFinalized(nil, boolPtr(true))) + require.Nil(t, andFinalized(boolPtr(true), nil)) +} + +func TestBlockFinalizedInResponses(t *testing.T) { + const finalizedBlock = uint64(100) + + t.Run("bridges", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) + + newBridge := func(blockNum uint64) *bridgesync.Bridge { + return &bridgesync.Bridge{BlockNum: blockNum, Amount: common.Big0} + } + bridgeMocks.bridgeL1.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{newBridge(finalizedBlock), newBridge(finalizedBlock + 1)}, 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 use the L2 finalized block", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + // L1 finalized block would make the bridge finalized, the L2 one must be the one used + expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock*10, nil) + expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) + + bridgeMocks.bridgeL2.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{{BlockNum: finalizedBlock + 1, 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 finalized block is unavailable", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectFinalizedBlock(bridgeMocks.bridgeL1, 0, errors.New("rpc down")) + + bridgeMocks.bridgeL1.EXPECT(). + GetBridgesPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*bridgesync.Bridge{{BlockNum: 1, 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", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) + + newClaim := func(blockNum uint64) *claimsynctypes.Claim { + return &claimsynctypes.Claim{BlockNum: blockNum, Amount: common.Big0, GlobalIndex: common.Big0} + } + bridgeMocks.claimL2.EXPECT(). + GetClaimsPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]*claimsynctypes.Claim{newClaim(finalizedBlock - 1), newClaim(finalizedBlock + 1)}, 2, 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, 2) + require.Equal(t, boolPtr(true), response.Claims[0].BlockFinalized) + require.Equal(t, boolPtr(false), response.Claims[1].BlockFinalized) + }) + + t.Run("root-by-ler", func(t *testing.T) { + ler := common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") + for blockNum, expected := range map[uint64]bool{finalizedBlock: true, finalizedBlock + 1: false} { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) + bridgeMocks.bridgeL2.EXPECT(). + GetRootByLER(mock.Anything, ler). + Return(&tree.Root{Index: 1, BlockNum: blockNum}, 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, "block %d", blockNum) + } + }) + + t.Run("settlements use the L1 finalized block", func(t *testing.T) { + bridgeMocks := newBridgeWithMocks(t, l2NetworkID) + expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) + bridgeMocks.l1InfoTree.EXPECT(). + GetVerifiedBatchesPaged(l2NetworkID, DefaultPage, DefaultPageSize). + Return([]*l1infotreesync.VerifiedBatchWithBlockHash{ + {BlockNumber: finalizedBlock + 1}, + {BlockNumber: finalizedBlock}, + }, 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) + expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) + bridgeMocks.l1InfoTree.EXPECT(). + GetInfoByIndex(mock.Anything, uint32(3)). + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: finalizedBlock + 1}, 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) + }) +} diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 7d38fd833..807b2fa38 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -432,8 +432,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)) + finality := b.finalityForNetwork(ctx, networkID) for _, bridge := range bridges { - bridgeResponses = append(bridgeResponses, NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block)) + bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) + bridgeResponse.BlockFinalized = finality.isFinalized(bridge.BlockNum) + bridgeResponses = append(bridgeResponses, bridgeResponse) } c.JSON(statusCode, @@ -557,8 +560,10 @@ func (b *BridgeService) GetClaimsHandler(c *gin.Context) { // Use conditional function to create claim responses claimResponses := make([]*types.ClaimResponse, len(claims)) + finality := b.finalityForNetwork(ctx, networkID) for i, claim := range claims { claimResponses[i] = NewClaimResponse(claim, includeAllFieldsFlag) + claimResponses[i].BlockFinalized = finality.isFinalized(claim.BlockNum) } c.JSON(statusCode, @@ -1053,6 +1058,12 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { resp := NewL1InfoTreeLeafResponse(l1InfoLeaf) resp.InjectedL2BlockNumber = injectedL2BlockNumber resp.InjectedL2BlockTimestamp = injectedL2Timestamp + // the leaf's L1 block must be finalized and, if injected on an L2, that L2 block too + resp.BlockFinalized = b.finalityForNetwork(ctx, mainnetNetworkID).isFinalized(l1InfoLeaf.BlockNumber) + if injectedL2BlockNumber != nil { + resp.BlockFinalized = andFinalized(resp.BlockFinalized, + b.finalityForNetwork(ctx, networkID).isFinalized(*injectedL2BlockNumber)) + } statusCode = http.StatusOK c.JSON(statusCode, resp) @@ -2425,8 +2436,11 @@ func (b *BridgeService) GetClaimsByGERHandler(c *gin.Context) { } claimResponses := make([]*types.ClaimResponse, 0, len(claims)) + finality := b.finalityForNetwork(ctx, networkID) for _, claim := range claims { - claimResponses = append(claimResponses, NewClaimResponse(claim, false)) + claimResponse := NewClaimResponse(claim, false) + claimResponse.BlockFinalized = finality.isFinalized(claim.BlockNum) + claimResponses = append(claimResponses, claimResponse) } c.JSON(statusCode, types.ClaimsByGERResult{ Claims: claimResponses, @@ -2521,7 +2535,9 @@ 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.finalityForNetwork(ctx, networkID).isFinalized(bridge.BlockNum) + c.JSON(statusCode, bridgeResponse) } // GetBridgesByContentHandler retrieves bridges matching the given content fields for the specified network. @@ -2671,8 +2687,11 @@ func (b *BridgeService) GetBridgesByContentHandler(c *gin.Context) { etrogUpgradeL1Block := b.agglayerManagerUpgradeQuery.GetUpgradeBlock(ctx, etrogVersionID) bridgeResponses := make([]*types.BridgeResponse, 0, len(bridges)) + finality := b.finalityForNetwork(ctx, networkID) for _, bridge := range bridges { - bridgeResponses = append(bridgeResponses, NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block)) + bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) + bridgeResponse.BlockFinalized = finality.isFinalized(bridge.BlockNum) + bridgeResponses = append(bridgeResponses, bridgeResponse) } c.JSON(statusCode, types.BridgesByContentResult{ Bridges: bridgeResponses, @@ -2764,9 +2783,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.finalityForNetwork(ctx, networkID).isFinalized(root.BlockNum), }) } @@ -2800,7 +2820,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 @@ -2824,11 +2844,14 @@ func (b *BridgeService) GetSettlementsHandler(c *gin.Context) { } settlements := make([]*types.SettlementResponse, 0, len(verified)) + // settlements are L1 events + finality := b.finalityForNetwork(ctx, mainnetNetworkID) for _, v := range verified { settlement := &types.SettlementResponse{ NewLocalExitRoot: types.Hash(v.ExitRoot.Hex()), BlockNumber: v.BlockNumber, BlockTimestamp: v.BlockTimestamp, + BlockFinalized: finality.isFinalized(v.BlockNumber), } if v.TxHash != nil { txHash := types.Hash(v.TxHash.Hex()) diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index 8cfe4e93e..c078a548e 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -26,6 +26,7 @@ type Bridger interface { GetContractDepositCount(ctx context.Context) (uint32, error) GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) GetLatestNetworkBlock(ctx context.Context) (uint64, error) + GetFinalizedBlock(ctx context.Context) (uint64, error) IsActive(ctx context.Context) bool GetBridgeByDepositCount(ctx context.Context, depositCount uint32) (*bridgesync.Bridge, error) GetBridgesByContent(ctx context.Context, leafType uint8, originAddress common.Address, diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index 451f9a307..2431e4023 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -73,6 +73,10 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { WriteTimeout: 0, NetworkID: networkID, } + // by default the finalized block is unavailable, so block_finalized is omitted from responses; + // tests covering it override this with expectFinalizedBlock + expectFinalizedBlock(b.bridgeL1, 0, errors.New("finalized block not available")) + expectFinalizedBlock(b.bridgeL2, 0, errors.New("finalized block not available")) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) b.router = gin.New() @@ -80,6 +84,19 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { return b } +// expectFinalizedBlock replaces any GetFinalizedBlock expectation of the bridger with one returning +// the given result (any number of times). +func expectFinalizedBlock(bridger *mocks.Bridger, finalizedBlock uint64, err error) { + expected := bridger.ExpectedCalls[:0] + for _, call := range bridger.ExpectedCalls { + if call.Method != "GetFinalizedBlock" { + expected = append(expected, call) + } + } + bridger.ExpectedCalls = expected + bridger.EXPECT().GetFinalizedBlock(mock.Anything).Return(finalizedBlock, err).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 diff --git a/bridgeservice/docs/docs.go b/bridgeservice/docs/docs.go index 4b173cd5c..fc611cf17 100644 --- a/bridgeservice/docs/docs.go +++ b/bridgeservice/docs/docs.go @@ -1542,6 +1542,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1707,6 +1712,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1936,6 +1946,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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2273,6 +2288,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 +2363,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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..1b65ec07a 100644 --- a/bridgeservice/docs/swagger.json +++ b/bridgeservice/docs/swagger.json @@ -1535,6 +1535,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1700,6 +1705,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1929,6 +1939,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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2266,6 +2281,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 +2356,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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..68f9ee7f9 100644 --- a/bridgeservice/docs/swagger.yaml +++ b/bridgeservice/docs/swagger.yaml @@ -225,6 +225,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 finality could not be determined (e.g. older versions or finalized block unavailable). + example: true + type: boolean block_num: description: Block number where the bridge event was recorded example: 1234 @@ -353,6 +359,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 finality could not be determined (e.g. older versions or finalized block unavailable). + example: true + type: boolean block_num: description: Block number where the claim was processed example: 1234 @@ -538,6 +550,13 @@ 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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf + endpoint; omitted when finality could not be determined. + example: true + type: boolean block_num: description: Block number where the leaf was recorded example: 123456 @@ -821,6 +840,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 finality could not be determined (e.g. older versions or finalized block unavailable). + example: true + type: boolean block_num: description: BlockNum is the block in which the leaf that produced this root was added @@ -877,6 +902,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 finality could not be determined (e.g. older versions or finalized block unavailable). + example: true + type: boolean block_hash: description: L1 block's hash, when available (nil if the block table has no recorded hash for it). diff --git a/bridgeservice/mocks/mock_bridger.go b/bridgeservice/mocks/mock_bridger.go index bc837ea17..68df69f34 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 @@ -278,6 +275,62 @@ func (_c *Bridger_GetContractDepositCount_Call) RunAndReturn(run func(context.Co return _c } +// GetFinalizedBlock provides a mock function with given fields: ctx +func (_m *Bridger) GetFinalizedBlock(ctx context.Context) (uint64, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetFinalizedBlock") + } + + var r0 uint64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) uint64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(uint64) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Bridger_GetFinalizedBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFinalizedBlock' +type Bridger_GetFinalizedBlock_Call struct { + *mock.Call +} + +// GetFinalizedBlock is a helper method to define mock.On call +// - ctx context.Context +func (_e *Bridger_Expecter) GetFinalizedBlock(ctx interface{}) *Bridger_GetFinalizedBlock_Call { + return &Bridger_GetFinalizedBlock_Call{Call: _e.mock.On("GetFinalizedBlock", ctx)} +} + +func (_c *Bridger_GetFinalizedBlock_Call) Run(run func(ctx context.Context)) *Bridger_GetFinalizedBlock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Bridger_GetFinalizedBlock_Call) Return(_a0 uint64, _a1 error) *Bridger_GetFinalizedBlock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Bridger_GetFinalizedBlock_Call) RunAndReturn(run func(context.Context) (uint64, error)) *Bridger_GetFinalizedBlock_Call { + _c.Call.Return(run) + return _c +} + // GetLastProcessedBlock provides a mock function with given fields: ctx func (_m *Bridger) GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) { ret := _m.Called(ctx) diff --git a/bridgeservice/types/types.go b/bridgeservice/types/types.go index 0d7341aa0..1e31f70c6 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 finality could not be determined (e.g. older versions or finalized block unavailable). + 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 finality could not be determined (e.g. older versions or finalized block unavailable). + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // ClaimsByGERResult contains the claims matching a given global exit root and their count @@ -387,6 +395,11 @@ 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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf + // endpoint; omitted when finality could not be determined. + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // SyncStatus represents the bridge synchronization status for both L1 and L2 networks @@ -559,6 +572,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 finality could not be determined (e.g. older versions or finalized block unavailable). + BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } // SettlementsResult contains a page of this network's L1 settlement history, most recent first, @@ -647,6 +664,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 finality could not be determined (e.g. older versions or finalized block unavailable). + 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..eb501ccf3 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -1,6 +1,7 @@ package bridgeservice import ( + "context" "encoding/hex" "fmt" "math/big" @@ -225,3 +226,50 @@ func NewL1InfoTreeLeafResponse(leaf *l1infotreesync.L1InfoTreeLeaf) *bridgetypes Hash: bridgetypes.Hash(leaf.Hash.Hex()), } } + +// blockFinality tells whether a block of a given network is finalized. The zero value reports +// every block as unknown (nil), which is what clients get when the finalized block is unavailable. +type blockFinality struct { + finalizedBlock *uint64 +} + +// isFinalized returns whether blockNum is finalized, or nil when the finalized block is unknown +func (f blockFinality) isFinalized(blockNum uint64) *bool { + if f.finalizedBlock == nil { + return nil + } + finalized := blockNum <= *f.finalizedBlock + return &finalized +} + +// andFinalized combines two finality results: nil if any is unknown, true only if both are true +func andFinalized(a, b *bool) *bool { + if a == nil || b == nil { + return nil + } + res := *a && *b + return &res +} + +// finalityForNetwork resolves the finalized block of the given network (0 = L1, or this instance's L2). +// Failures are not fatal: the finality is just reported as unknown. +func (b *BridgeService) finalityForNetwork(ctx context.Context, networkID uint32) blockFinality { + var bridger Bridger + switch networkID { + case mainnetNetworkID: + bridger = b.bridgeL1 + case b.networkID: + bridger = b.bridgeL2 + } + if bridger == nil { + return blockFinality{} + } + + finalizedBlock, err := bridger.GetFinalizedBlock(ctx) + if err != nil { + b.logger.Warnf("unable to get the finalized block of network %d, block_finalized will be omitted: %v", + networkID, err) + return blockFinality{} + } + return blockFinality{finalizedBlock: &finalizedBlock} +} diff --git a/bridgesync/bridgesync.go b/bridgesync/bridgesync.go index 7372fdfd5..0f4161865 100644 --- a/bridgesync/bridgesync.go +++ b/bridgesync/bridgesync.go @@ -510,6 +510,21 @@ func (s *BridgeSync) GetLatestNetworkBlock(ctx context.Context) (uint64, error) return blockNumber, nil } +// GetFinalizedBlock returns the latest finalized block number of the network +func (s *BridgeSync) GetFinalizedBlock(ctx context.Context) (uint64, error) { + if s.processor.isHalted() { + return 0, sync.ErrInconsistentState + } + + finalized := aggkittypes.FinalizedBlock + blockNumber, err := finalized.BlockNumber(ctx, s.ethClient) + if err != nil { + return 0, fmt.Errorf("failed to get finalized block number: %w", err) + } + + return blockNumber, nil +} + // IsActive returns true if the syncer is active (not halted) func (s *BridgeSync) IsActive(ctx context.Context) bool { return !s.processor.isHalted() diff --git a/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 347a0d991..1b65ec07a 100644 --- a/docs/assets/swagger/bridge_service/swagger.json +++ b/docs/assets/swagger/bridge_service/swagger.json @@ -1535,6 +1535,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the bridge event was recorded", "type": "integer", @@ -1700,6 +1705,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the claim was processed", "type": "integer", @@ -1929,6 +1939,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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "type": "boolean", + "example": true + }, "block_num": { "description": "Block number where the leaf was recorded", "type": "integer", @@ -2266,6 +2281,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 +2356,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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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", From a85e443a6f417a05994e5829a3dc7edf0ad2f3fc Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:16 +0200 Subject: [PATCH 02/12] feat(l1infotreesync): report block finality from the syncer Add L1InfoTreeSync.IsBlockFinalized, backed by the multidownloader's stored IsFinal flag (which is only set once the block hash was validated), and use it in the bridge service for the L1 blocks of /settlements and /injected-l1-info-leaf instead of comparing block numbers against the finalized block. Legacy and read-only syncers report it as unknown, so block_finalized is omitted. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 21 +++- bridgeservice/bridge.go | 6 +- bridgeservice/bridge_interfaces.go | 3 + bridgeservice/bridge_test.go | 17 +++ .../mocks/mock_l1_info_tree_syncer.go | 71 +++++++++++- bridgeservice/utils.go | 17 +++ l1infotreesync/is_block_finalized_test.go | 62 +++++++++++ l1infotreesync/l1infotreesync.go | 36 +++++- l1infotreesync/mock_block_finality_querier.go | 104 ++++++++++++++++++ 9 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 l1infotreesync/is_block_finalized_test.go create mode 100644 l1infotreesync/mock_block_finality_querier.go diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go index 3b56ea9a0..b0aecdf04 100644 --- a/bridgeservice/block_finalized_test.go +++ b/bridgeservice/block_finalized_test.go @@ -145,9 +145,9 @@ func TestBlockFinalizedInResponses(t *testing.T) { } }) - t.Run("settlements use the L1 finalized block", func(t *testing.T) { + t.Run("settlements use the l1infotreesync finality", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{finalizedBlock + 1: false, finalizedBlock: true}) bridgeMocks.l1InfoTree.EXPECT(). GetVerifiedBatchesPaged(l2NetworkID, DefaultPage, DefaultPageSize). Return([]*l1infotreesync.VerifiedBatchWithBlockHash{ @@ -167,7 +167,7 @@ func TestBlockFinalizedInResponses(t *testing.T) { t.Run("injected-l1-info-leaf on L1", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{finalizedBlock + 1: false}) bridgeMocks.l1InfoTree.EXPECT(). GetInfoByIndex(mock.Anything, uint32(3)). Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: finalizedBlock + 1}, nil) @@ -183,4 +183,19 @@ func TestBlockFinalizedInResponses(t *testing.T) { 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: finalizedBlock}, 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") + }) } diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 807b2fa38..f0b5e8de8 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -1059,7 +1059,7 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { resp.InjectedL2BlockNumber = injectedL2BlockNumber resp.InjectedL2BlockTimestamp = injectedL2Timestamp // the leaf's L1 block must be finalized and, if injected on an L2, that L2 block too - resp.BlockFinalized = b.finalityForNetwork(ctx, mainnetNetworkID).isFinalized(l1InfoLeaf.BlockNumber) + resp.BlockFinalized = b.l1BlockFinalized(ctx, l1InfoLeaf.BlockNumber) if injectedL2BlockNumber != nil { resp.BlockFinalized = andFinalized(resp.BlockFinalized, b.finalityForNetwork(ctx, networkID).isFinalized(*injectedL2BlockNumber)) @@ -2844,14 +2844,12 @@ func (b *BridgeService) GetSettlementsHandler(c *gin.Context) { } settlements := make([]*types.SettlementResponse, 0, len(verified)) - // settlements are L1 events - finality := b.finalityForNetwork(ctx, mainnetNetworkID) for _, v := range verified { settlement := &types.SettlementResponse{ NewLocalExitRoot: types.Hash(v.ExitRoot.Hex()), BlockNumber: v.BlockNumber, BlockTimestamp: v.BlockTimestamp, - BlockFinalized: finality.isFinalized(v.BlockNumber), + BlockFinalized: b.l1BlockFinalized(ctx, v.BlockNumber), } if v.TxHash != nil { txHash := types.Hash(v.TxHash.Hex()) diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index c078a548e..a5c6ac925 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -79,4 +79,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 was stored as finalized (so it can't be reorged). + // found is false when it can't be determined + IsBlockFinalized(ctx context.Context, blockNum uint64) (finalized, found bool, err error) } diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index 2431e4023..f4bb2e1d2 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -77,6 +77,7 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { // tests covering it override this with expectFinalizedBlock expectFinalizedBlock(b.bridgeL1, 0, errors.New("finalized block not available")) expectFinalizedBlock(b.bridgeL2, 0, errors.New("finalized block not available")) + expectL1BlocksFinalized(b.l1InfoTree, nil) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) b.router = gin.New() @@ -97,6 +98,22 @@ func expectFinalizedBlock(bridger *mocks.Bridger, finalizedBlock uint64, err err bridger.EXPECT().GetFinalizedBlock(mock.Anything).Return(finalizedBlock, err).Maybe() } +// expectL1BlocksFinalized replaces any IsBlockFinalized expectation of the l1infotreesync mock: the +// blocks in finality get that finality, any other block is unknown (found=false). +func expectL1BlocksFinalized(l1InfoTree *mocks.L1InfoTreeSyncer, 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).Return(finalized, true, nil).Maybe() + } + l1InfoTree.EXPECT().IsBlockFinalized(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 diff --git a/bridgeservice/mocks/mock_l1_info_tree_syncer.go b/bridgeservice/mocks/mock_l1_info_tree_syncer.go index 82441a45b..73dd9763c 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,70 @@ func (_c *L1InfoTreeSyncer_IsActive_Call) RunAndReturn(run func(context.Context) return _c } +// IsBlockFinalized provides a mock function with given fields: ctx, blockNum +func (_m *L1InfoTreeSyncer) IsBlockFinalized(ctx context.Context, blockNum uint64) (bool, bool, error) { + ret := _m.Called(ctx, blockNum) + + 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) (bool, bool, error)); ok { + return rf(ctx, blockNum) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64) bool); ok { + r0 = rf(ctx, blockNum) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64) bool); ok { + r1 = rf(ctx, blockNum) + } else { + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(context.Context, uint64) error); ok { + r2 = rf(ctx, blockNum) + } 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 +func (_e *L1InfoTreeSyncer_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}) *L1InfoTreeSyncer_IsBlockFinalized_Call { + return &L1InfoTreeSyncer_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum)} +} + +func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) Run(run func(ctx context.Context, blockNum uint64)) *L1InfoTreeSyncer_IsBlockFinalized_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64)) + }) + 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) (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/utils.go b/bridgeservice/utils.go index eb501ccf3..22c955e73 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -273,3 +273,20 @@ func (b *BridgeService) finalityForNetwork(ctx context.Context, networkID uint32 } return blockFinality{finalizedBlock: &finalizedBlock} } + +// l1BlockFinalized asks the l1infotreesync syncer whether an L1 block it stored 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) *bool { + if b.l1InfoTree == nil { + return nil + } + finalized, found, err := b.l1InfoTree.IsBlockFinalized(ctx, blockNum) + if err != nil { + b.logger.Warnf("unable to get the finality of L1 block %d, block_finalized will be omitted: %v", blockNum, err) + return nil + } + if !found { + return nil + } + return &finalized +} diff --git a/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go new file mode 100644 index 000000000..8960517ee --- /dev/null +++ b/l1infotreesync/is_block_finalized_test.go @@ -0,0 +1,62 @@ +package l1infotreesync + +import ( + "errors" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestIsBlockFinalized(t *testing.T) { + ctx := t.Context() + header := &aggkittypes.BlockHeader{Number: 10} + + t.Run("not multidownloader based: unknown", func(t *testing.T) { + syncer := &L1InfoTreeSync{} + finalized, found, err := syncer.IsBlockFinalized(ctx, 10) + require.NoError(t, err) + require.False(t, finalized) + require.False(t, found) + }) + + t.Run("stored as finalized", func(t *testing.T) { + querier := NewBlockFinalityQuerierMock(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, true, nil) + syncer := &L1InfoTreeSync{blockFinality: querier} + finalized, found, err := syncer.IsBlockFinalized(ctx, 10) + require.NoError(t, err) + require.True(t, finalized) + require.True(t, found) + }) + + t.Run("stored but not finalized", func(t *testing.T) { + querier := NewBlockFinalityQuerierMock(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, false, nil) + syncer := &L1InfoTreeSync{blockFinality: querier} + finalized, found, err := syncer.IsBlockFinalized(ctx, 10) + require.NoError(t, err) + require.False(t, finalized) + require.True(t, found) + }) + + t.Run("block not stored: unknown", func(t *testing.T) { + querier := NewBlockFinalityQuerierMock(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(nil, false, nil) + syncer := &L1InfoTreeSync{blockFinality: querier} + _, found, err := syncer.IsBlockFinalized(ctx, 10) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("storage error", func(t *testing.T) { + querier := NewBlockFinalityQuerierMock(t) + querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)). + Return(nil, false, errors.New("boom")) + syncer := &L1InfoTreeSync{blockFinality: querier} + _, found, err := syncer.IsBlockFinalized(ctx, 10) + require.ErrorContains(t, err, "boom") + require.False(t, found) + }) +} diff --git a/l1infotreesync/l1infotreesync.go b/l1infotreesync/l1infotreesync.go index 1c8690059..855aadb93 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -13,6 +13,7 @@ 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/sync" "github.com/agglayer/aggkit/tree" "github.com/agglayer/aggkit/tree/types" @@ -43,10 +44,21 @@ 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. It is nil for the legacy syncer + blockFinality BlockFinalityQuerier } type RuntimeData = mdrsync.RuntimeData @@ -137,9 +149,10 @@ func NewMultidownloadBased( return nil, err } return &L1InfoTreeSync{ - processor: processor, - driver: driver, - cfg: cfg, + processor: processor, + driver: driver, + cfg: cfg, + blockFinality: l1Multidownloader, }, nil } @@ -221,6 +234,23 @@ func NewLegacy( }, nil } +// IsBlockFinalized reports whether the block was stored as finalized by the multidownloader (so +// it can no longer be reorged). found is false when it can't be determined: the block is not +// stored by the multidownloader, or the syncer is not multidownloader based (legacy or read-only). +func (d *L1InfoTreeSync) IsBlockFinalized(ctx context.Context, blockNum uint64) (finalized, found bool, err error) { + if d.blockFinality == nil { + return false, false, 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 { + return false, false, nil + } + return isFinal, true, nil +} + // 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. 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 +} From 15dc98ea6d87908a7dc8c97cfbd6bd62d465073c Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:15:09 +0200 Subject: [PATCH 03/12] feat(bridgesync): report block finality of bridges and roots from the syncer Add BridgeSync.IsBlockFinalized(num, hash). The legacy syncer has no finality flag, but the reorg detector only stops tracking a block once its hash matched the canonical chain below the finalized block, so a processed block with the given hash that is no longer tracked is finalized. The hash read together with the item is passed so a block replaced after the read is reported as unknown. Bridge and tree.Root now carry the stored BlockHash (not persisted, filled by the read queries). The bridge service uses the syncer finality for /bridges, /bridge-by-deposit-count, /bridges-by-content and /root-by-ler, and drops the block number comparison against the finalized block. Claims and the L2 side of /injected-l1-info-leaf omit block_finalized until their syncers report it. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 101 +++++------- bridgeservice/bridge.go | 24 ++- bridgeservice/bridge_interfaces.go | 4 +- bridgeservice/bridge_test.go | 21 ++- bridgeservice/docs/docs.go | 2 +- bridgeservice/docs/swagger.json | 2 +- bridgeservice/docs/swagger.yaml | 6 +- bridgeservice/mocks/mock_bridger.go | 121 +++++++------- bridgeservice/types/types.go | 6 +- bridgeservice/utils.go | 57 +++---- bridgesync/bridgesync.go | 86 ++++++---- bridgesync/is_block_finalized_test.go | 147 ++++++++++++++++++ bridgesync/processor.go | 52 +++++++ .../swagger/bridge_service/swagger.json | 2 +- tree/types/types.go | 3 + 15 files changed, 420 insertions(+), 214 deletions(-) create mode 100644 bridgesync/is_block_finalized_test.go diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go index b0aecdf04..47abcd4e4 100644 --- a/bridgeservice/block_finalized_test.go +++ b/bridgeservice/block_finalized_test.go @@ -11,7 +11,6 @@ import ( bridgetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgesync" - claimsynctypes "github.com/agglayer/aggkit/claimsync/types" "github.com/agglayer/aggkit/l1infotreesync" tree "github.com/agglayer/aggkit/tree/types" "github.com/ethereum/go-ethereum/common" @@ -21,37 +20,23 @@ import ( func boolPtr(v bool) *bool { return &v } -func TestBlockFinalityIsFinalized(t *testing.T) { - finalizedBlock := uint64(100) - finality := blockFinality{finalizedBlock: &finalizedBlock} - - require.Equal(t, boolPtr(true), finality.isFinalized(99)) - require.Equal(t, boolPtr(true), finality.isFinalized(100)) - require.Equal(t, boolPtr(false), finality.isFinalized(101)) - require.Nil(t, blockFinality{}.isFinalized(1)) -} - -func TestAndFinalized(t *testing.T) { - require.Equal(t, boolPtr(true), andFinalized(boolPtr(true), boolPtr(true))) - require.Equal(t, boolPtr(false), andFinalized(boolPtr(true), boolPtr(false))) - require.Equal(t, boolPtr(false), andFinalized(boolPtr(false), boolPtr(true))) - require.Nil(t, andFinalized(nil, boolPtr(true))) - require.Nil(t, andFinalized(boolPtr(true), nil)) -} - func TestBlockFinalizedInResponses(t *testing.T) { - const finalizedBlock = uint64(100) + var ( + blockHash = common.HexToHash("0xb10c") + otherBlockHash = common.HexToHash("0xdead") + ) t.Run("bridges", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock, nil) - - newBridge := func(blockNum uint64) *bridgesync.Bridge { - return &bridgesync.Bridge{BlockNum: blockNum, Amount: common.Big0} - } + 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{newBridge(finalizedBlock), newBridge(finalizedBlock + 1)}, 2, nil) + 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, @@ -65,15 +50,15 @@ func TestBlockFinalizedInResponses(t *testing.T) { require.Equal(t, boolPtr(false), response.Bridges[1].BlockFinalized) }) - t.Run("bridges on L2 use the L2 finalized block", func(t *testing.T) { + t.Run("bridges on L2 ask the L2 syncer, with the hash it served", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - // L1 finalized block would make the bridge finalized, the L2 one must be the one used - expectFinalizedBlock(bridgeMocks.bridgeL1, finalizedBlock*10, nil) - expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) + // 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: finalizedBlock + 1, Amount: common.Big0}}, 1, nil) + 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, @@ -86,13 +71,13 @@ func TestBlockFinalizedInResponses(t *testing.T) { require.Equal(t, boolPtr(false), response.Bridges[0].BlockFinalized) }) - t.Run("bridges omit block_finalized when the finalized block is unavailable", func(t *testing.T) { + t.Run("bridges omit block_finalized when the syncer can't tell", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL1, 0, errors.New("rpc down")) - + // 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: 1, Amount: common.Big0}}, 1, nil) + 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, @@ -101,36 +86,30 @@ func TestBlockFinalizedInResponses(t *testing.T) { require.NotContains(t, w.Body.String(), "block_finalized") }) - t.Run("claims", func(t *testing.T) { + t.Run("bridges omit block_finalized when the syncer fails", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) - - newClaim := func(blockNum uint64) *claimsynctypes.Claim { - return &claimsynctypes.Claim{BlockNum: blockNum, Amount: common.Big0, GlobalIndex: common.Big0} - } - bridgeMocks.claimL2.EXPECT(). - GetClaimsPaged(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]*claimsynctypes.Claim{newClaim(finalizedBlock - 1), newClaim(finalizedBlock + 1)}, 2, nil) + 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/claims?%s=%d", BridgeV1Prefix, networkIDParam, l2NetworkID)) + fmt.Sprintf("%s/bridges?%s=%d", BridgeV1Prefix, networkIDParam, mainnetNetworkID)) 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, 2) - require.Equal(t, boolPtr(true), response.Claims[0].BlockFinalized) - require.Equal(t, boolPtr(false), response.Claims[1].BlockFinalized) + require.NotContains(t, w.Body.String(), "block_finalized") }) t.Run("root-by-ler", func(t *testing.T) { ler := common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") - for blockNum, expected := range map[uint64]bool{finalizedBlock: true, finalizedBlock + 1: false} { + for _, expected := range []bool{true, false} { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectFinalizedBlock(bridgeMocks.bridgeL2, finalizedBlock, nil) + expectBridgeBlockFinalized(bridgeMocks.bridgeL2, 10, blockHash, expected) bridgeMocks.bridgeL2.EXPECT(). GetRootByLER(mock.Anything, ler). - Return(&tree.Root{Index: 1, BlockNum: blockNum}, nil) + Return(&tree.Root{Index: 1, BlockNum: 10, BlockHash: blockHash}, nil) queryParams := url.Values{} queryParams.Set(networkIDParam, strconv.Itoa(int(l2NetworkID))) @@ -141,18 +120,18 @@ func TestBlockFinalizedInResponses(t *testing.T) { var response bridgetypes.RootByLERResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - require.Equal(t, boolPtr(expected), response.BlockFinalized, "block %d", blockNum) + 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, map[uint64]bool{finalizedBlock + 1: false, finalizedBlock: true}) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{101: false, 100: true}) bridgeMocks.l1InfoTree.EXPECT(). GetVerifiedBatchesPaged(l2NetworkID, DefaultPage, DefaultPageSize). Return([]*l1infotreesync.VerifiedBatchWithBlockHash{ - {BlockNumber: finalizedBlock + 1}, - {BlockNumber: finalizedBlock}, + {BlockNumber: 101}, + {BlockNumber: 100}, }, 2, nil) w := performRequest(t, bridgeMocks.router, fmt.Sprintf("%s/settlements", BridgeV1Prefix)) @@ -167,10 +146,10 @@ func TestBlockFinalizedInResponses(t *testing.T) { t.Run("injected-l1-info-leaf on L1", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{finalizedBlock + 1: false}) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{101: false}) bridgeMocks.l1InfoTree.EXPECT(). GetInfoByIndex(mock.Anything, uint32(3)). - Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: finalizedBlock + 1}, nil) + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 101}, nil) queryParams := url.Values{} queryParams.Set(networkIDParam, "0") @@ -188,7 +167,7 @@ func TestBlockFinalizedInResponses(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) bridgeMocks.l1InfoTree.EXPECT(). GetInfoByIndex(mock.Anything, uint32(3)). - Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: finalizedBlock}, nil) + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 100}, nil) queryParams := url.Values{} queryParams.Set(networkIDParam, "0") diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index f0b5e8de8..4ef8ccdb3 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -432,10 +432,10 @@ 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)) - finality := b.finalityForNetwork(ctx, networkID) + bridger := b.bridgerForNetwork(networkID) for _, bridge := range bridges { bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) - bridgeResponse.BlockFinalized = finality.isFinalized(bridge.BlockNum) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, bridger, bridge.BlockNum, bridge.BlockHash) bridgeResponses = append(bridgeResponses, bridgeResponse) } @@ -560,10 +560,8 @@ func (b *BridgeService) GetClaimsHandler(c *gin.Context) { // Use conditional function to create claim responses claimResponses := make([]*types.ClaimResponse, len(claims)) - finality := b.finalityForNetwork(ctx, networkID) for i, claim := range claims { claimResponses[i] = NewClaimResponse(claim, includeAllFieldsFlag) - claimResponses[i].BlockFinalized = finality.isFinalized(claim.BlockNum) } c.JSON(statusCode, @@ -1061,8 +1059,8 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { // the leaf's L1 block must be finalized and, if injected on an L2, that L2 block too resp.BlockFinalized = b.l1BlockFinalized(ctx, l1InfoLeaf.BlockNumber) if injectedL2BlockNumber != nil { - resp.BlockFinalized = andFinalized(resp.BlockFinalized, - b.finalityForNetwork(ctx, networkID).isFinalized(*injectedL2BlockNumber)) + // the injection block on L2 is not covered by a syncer finality yet + resp.BlockFinalized = nil } statusCode = http.StatusOK @@ -2436,11 +2434,8 @@ func (b *BridgeService) GetClaimsByGERHandler(c *gin.Context) { } claimResponses := make([]*types.ClaimResponse, 0, len(claims)) - finality := b.finalityForNetwork(ctx, networkID) for _, claim := range claims { - claimResponse := NewClaimResponse(claim, false) - claimResponse.BlockFinalized = finality.isFinalized(claim.BlockNum) - claimResponses = append(claimResponses, claimResponse) + claimResponses = append(claimResponses, NewClaimResponse(claim, false)) } c.JSON(statusCode, types.ClaimsByGERResult{ Claims: claimResponses, @@ -2536,7 +2531,8 @@ func (b *BridgeService) GetBridgeByDepositCountHandler(c *gin.Context) { etrogUpgradeL1Block := b.agglayerManagerUpgradeQuery.GetUpgradeBlock(ctx, etrogVersionID) bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) - bridgeResponse.BlockFinalized = b.finalityForNetwork(ctx, networkID).isFinalized(bridge.BlockNum) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, b.bridgerForNetwork(networkID), + bridge.BlockNum, bridge.BlockHash) c.JSON(statusCode, bridgeResponse) } @@ -2687,10 +2683,10 @@ func (b *BridgeService) GetBridgesByContentHandler(c *gin.Context) { etrogUpgradeL1Block := b.agglayerManagerUpgradeQuery.GetUpgradeBlock(ctx, etrogVersionID) bridgeResponses := make([]*types.BridgeResponse, 0, len(bridges)) - finality := b.finalityForNetwork(ctx, networkID) + bridger := b.bridgerForNetwork(networkID) for _, bridge := range bridges { bridgeResponse := NewBridgeResponse(bridge, networkID, etrogUpgradeL1Block) - bridgeResponse.BlockFinalized = finality.isFinalized(bridge.BlockNum) + bridgeResponse.BlockFinalized = b.blockFinalized(ctx, bridger, bridge.BlockNum, bridge.BlockHash) bridgeResponses = append(bridgeResponses, bridgeResponse) } c.JSON(statusCode, types.BridgesByContentResult{ @@ -2786,7 +2782,7 @@ func (b *BridgeService) RootByLERHandler(c *gin.Context) { Index: root.Index, BlockNum: root.BlockNum, BlockPosition: root.BlockPosition, - BlockFinalized: b.finalityForNetwork(ctx, networkID).isFinalized(root.BlockNum), + BlockFinalized: b.blockFinalized(ctx, bridger, root.BlockNum, root.BlockHash), }) } diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index a5c6ac925..3620e41f9 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -26,7 +26,9 @@ type Bridger interface { GetContractDepositCount(ctx context.Context) (uint32, error) GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) GetLatestNetworkBlock(ctx context.Context) (uint64, error) - GetFinalizedBlock(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, diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index f4bb2e1d2..c2642861e 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -73,10 +73,10 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { WriteTimeout: 0, NetworkID: networkID, } - // by default the finalized block is unavailable, so block_finalized is omitted from responses; + // by default the finality is unknown, so block_finalized is omitted from responses; // tests covering it override this with expectFinalizedBlock - expectFinalizedBlock(b.bridgeL1, 0, errors.New("finalized block not available")) - expectFinalizedBlock(b.bridgeL2, 0, errors.New("finalized block not available")) + 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() expectL1BlocksFinalized(b.l1InfoTree, nil) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) @@ -85,17 +85,22 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { return b } -// expectFinalizedBlock replaces any GetFinalizedBlock expectation of the bridger with one returning -// the given result (any number of times). -func expectFinalizedBlock(bridger *mocks.Bridger, finalizedBlock uint64, err error) { +// 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 != "GetFinalizedBlock" { + if call.Method != "IsBlockFinalized" { expected = append(expected, call) } } bridger.ExpectedCalls = expected - bridger.EXPECT().GetFinalizedBlock(mock.Anything).Return(finalizedBlock, err).Maybe() } // expectL1BlocksFinalized replaces any IsBlockFinalized expectation of the l1infotreesync mock: the diff --git a/bridgeservice/docs/docs.go b/bridgeservice/docs/docs.go index fc611cf17..853b4819a 100644 --- a/bridgeservice/docs/docs.go +++ b/bridgeservice/docs/docs.go @@ -1947,7 +1947,7 @@ const docTemplate = `{ "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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", "type": "boolean", "example": true }, diff --git a/bridgeservice/docs/swagger.json b/bridgeservice/docs/swagger.json index 1b65ec07a..6b380792a 100644 --- a/bridgeservice/docs/swagger.json +++ b/bridgeservice/docs/swagger.json @@ -1940,7 +1940,7 @@ "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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", "type": "boolean", "example": true }, diff --git a/bridgeservice/docs/swagger.yaml b/bridgeservice/docs/swagger.yaml index 68f9ee7f9..4efb3c976 100644 --- a/bridgeservice/docs/swagger.yaml +++ b/bridgeservice/docs/swagger.yaml @@ -552,9 +552,9 @@ definitions: 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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf - endpoint; omitted when finality could not be determined. + BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the + injected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be + determined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet). example: true type: boolean block_num: diff --git a/bridgeservice/mocks/mock_bridger.go b/bridgeservice/mocks/mock_bridger.go index 68df69f34..5a095c675 100644 --- a/bridgeservice/mocks/mock_bridger.go +++ b/bridgeservice/mocks/mock_bridger.go @@ -275,62 +275,6 @@ func (_c *Bridger_GetContractDepositCount_Call) RunAndReturn(run func(context.Co return _c } -// GetFinalizedBlock provides a mock function with given fields: ctx -func (_m *Bridger) GetFinalizedBlock(ctx context.Context) (uint64, error) { - ret := _m.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GetFinalizedBlock") - } - - var r0 uint64 - var r1 error - if rf, ok := ret.Get(0).(func(context.Context) (uint64, error)); ok { - return rf(ctx) - } - if rf, ok := ret.Get(0).(func(context.Context) uint64); ok { - r0 = rf(ctx) - } else { - r0 = ret.Get(0).(uint64) - } - - if rf, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = rf(ctx) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Bridger_GetFinalizedBlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFinalizedBlock' -type Bridger_GetFinalizedBlock_Call struct { - *mock.Call -} - -// GetFinalizedBlock is a helper method to define mock.On call -// - ctx context.Context -func (_e *Bridger_Expecter) GetFinalizedBlock(ctx interface{}) *Bridger_GetFinalizedBlock_Call { - return &Bridger_GetFinalizedBlock_Call{Call: _e.mock.On("GetFinalizedBlock", ctx)} -} - -func (_c *Bridger_GetFinalizedBlock_Call) Run(run func(ctx context.Context)) *Bridger_GetFinalizedBlock_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context)) - }) - return _c -} - -func (_c *Bridger_GetFinalizedBlock_Call) Return(_a0 uint64, _a1 error) *Bridger_GetFinalizedBlock_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *Bridger_GetFinalizedBlock_Call) RunAndReturn(run func(context.Context) (uint64, error)) *Bridger_GetFinalizedBlock_Call { - _c.Call.Return(run) - return _c -} - // GetLastProcessedBlock provides a mock function with given fields: ctx func (_m *Bridger) GetLastProcessedBlock(ctx context.Context) (uint64, bool, error) { ret := _m.Called(ctx) @@ -866,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/types/types.go b/bridgeservice/types/types.go index 1e31f70c6..7a36886ae 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -396,9 +396,9 @@ type L1InfoTreeLeafResponse struct { // 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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf - // endpoint; omitted when finality could not be determined. + // BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the + // injected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be + // determined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet). BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } diff --git a/bridgeservice/utils.go b/bridgeservice/utils.go index 22c955e73..061ccf8a5 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -11,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" ) @@ -227,51 +228,33 @@ func NewL1InfoTreeLeafResponse(leaf *l1infotreesync.L1InfoTreeLeaf) *bridgetypes } } -// blockFinality tells whether a block of a given network is finalized. The zero value reports -// every block as unknown (nil), which is what clients get when the finalized block is unavailable. -type blockFinality struct { - finalizedBlock *uint64 -} - -// isFinalized returns whether blockNum is finalized, or nil when the finalized block is unknown -func (f blockFinality) isFinalized(blockNum uint64) *bool { - if f.finalizedBlock == nil { - return nil - } - finalized := blockNum <= *f.finalizedBlock - return &finalized -} - -// andFinalized combines two finality results: nil if any is unknown, true only if both are true -func andFinalized(a, b *bool) *bool { - if a == nil || b == nil { - return nil - } - res := *a && *b - return &res -} - -// finalityForNetwork resolves the finalized block of the given network (0 = L1, or this instance's L2). -// Failures are not fatal: the finality is just reported as unknown. -func (b *BridgeService) finalityForNetwork(ctx context.Context, networkID uint32) blockFinality { - var bridger Bridger +// 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: - bridger = b.bridgeL1 + return b.bridgeL1 case b.networkID: - bridger = b.bridgeL2 + return b.bridgeL2 } + return nil +} + +// blockFinalized asks the bridge 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, bridger Bridger, blockNum uint64, + blockHash common.Hash) *bool { if bridger == nil { - return blockFinality{} + return nil } - - finalizedBlock, err := bridger.GetFinalizedBlock(ctx) + finalized, found, err := bridger.IsBlockFinalized(ctx, blockNum, blockHash) if err != nil { - b.logger.Warnf("unable to get the finalized block of network %d, block_finalized will be omitted: %v", - networkID, err) - return blockFinality{} + b.logger.Warnf("unable to get the finality of block %d, block_finalized will be omitted: %v", blockNum, err) + return nil } - return blockFinality{finalizedBlock: &finalizedBlock} + if !found { + return nil + } + return &finalized } // l1BlockFinalized asks the l1infotreesync syncer whether an L1 block it stored is finalized. diff --git a/bridgesync/bridgesync.go b/bridgesync/bridgesync.go index 0f4161865..f842684ff 100644 --- a/bridgesync/bridgesync.go +++ b/bridgesync/bridgesync.go @@ -11,6 +11,7 @@ import ( "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/agglayer/aggkit/db" "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/log" "github.com/agglayer/aggkit/reorgdetector" @@ -71,11 +72,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 @@ -246,14 +249,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: syncerID.String(), + ethClient: ethClient, + agglayerBridge: agglayerBridge, + cfg: cfg, }, nil } @@ -414,9 +418,50 @@ func (s *BridgeSync) GetRootByLER(ctx context.Context, ler common.Hash) (*tree.R if err != nil { return root, err } + blockHash, _, err := s.processor.getBlockHash(ctx, root.BlockNum) + if err != nil { + return nil, err + } + root.BlockHash = blockHash return root, 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. 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 + } + if blockHash == (common.Hash{}) { + return false, false, nil + } + + storedHash, stored, err := s.processor.getBlockHash(ctx, blockNum) + if err != nil { + return false, false, err + } + if !stored || storedHash != blockHash { + return false, false, nil + } + + _, err = s.reorgDetector.GetTrackedBlockByBlockNumber(s.reorgDetectorID, blockNum) + switch { + case err == nil: + // still tracked: its hash was not confirmed against a finalized block yet + return false, true, nil + case errors.Is(err, db.ErrNotFound): + return true, true, nil + default: + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + } +} + // GetExitRootByIndex returns the root of the exit tree at the moment the leaf with the given index was added func (s *BridgeSync) GetExitRootByIndex(ctx context.Context, index uint32) (tree.Root, error) { if s.processor.isHalted() { @@ -510,21 +555,6 @@ func (s *BridgeSync) GetLatestNetworkBlock(ctx context.Context) (uint64, error) return blockNumber, nil } -// GetFinalizedBlock returns the latest finalized block number of the network -func (s *BridgeSync) GetFinalizedBlock(ctx context.Context) (uint64, error) { - if s.processor.isHalted() { - return 0, sync.ErrInconsistentState - } - - finalized := aggkittypes.FinalizedBlock - blockNumber, err := finalized.BlockNumber(ctx, s.ethClient) - if err != nil { - return 0, fmt.Errorf("failed to get finalized block number: %w", err) - } - - return blockNumber, nil -} - // IsActive returns true if the syncer is active (not halted) func (s *BridgeSync) IsActive(ctx context.Context) bool { return !s.processor.isHalted() diff --git a/bridgesync/is_block_finalized_test.go b/bridgesync/is_block_finalized_test.go new file mode 100644 index 000000000..947b9bbc3 --- /dev/null +++ b/bridgesync/is_block_finalized_test.go @@ -0,0 +1,147 @@ +package bridgesync + +import ( + "context" + "errors" + "math/big" + "path" + "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" + "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("hash differs from the stored one: unknown", func(t *testing.T) { + s, _ := newBlockFinalityTestSyncer(t) + + // 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", 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("block without stored hash: unknown", func(t *testing.T) { + s, _ := newBlockFinalityTestSyncer(t) + + _, found, err := s.IsBlockFinalized(ctx, 3, hash1) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("block not stored: unknown", func(t *testing.T) { + s, _ := newBlockFinalityTestSyncer(t) + + _, found, err := s.IsBlockFinalized(ctx, 99, hash1) + require.NoError(t, err) + require.False(t, found) + }) + + 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() + s, _ := newBlockFinalityTestSyncer(t) + + bridges, _, err := s.GetBridgesPaged(ctx, 1, 10, nil, nil, "") + require.NoError(t, err) + require.Len(t, bridges, 3) + hashByBlock := map[uint64]common.Hash{} + for _, b := range bridges { + hashByBlock[b.BlockNum] = b.BlockHash + } + // block 3 has no stored hash + require.Equal(t, map[uint64]common.Hash{ + 1: common.HexToHash("0x1"), + 2: common.HexToHash("0x2"), + 3: {}, + }, hashByBlock) + + bridge, err := s.GetBridgeByDepositCount(ctx, 1) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0x2"), bridge.BlockHash) +} diff --git a/bridgesync/processor.go b/bridgesync/processor.go index 378bfd5b9..603224bf7 100644 --- a/bridgesync/processor.go +++ b/bridgesync/processor.go @@ -124,6 +124,9 @@ 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: it is filled in by the read queries (zero if unknown), see fillBlockHashes + BlockHash common.Hash `meddler:"-"` } func (b *Bridge) String() string { @@ -550,6 +553,10 @@ func (p *processor) GetBridgesPaged( return nil, 0, err } + if err = p.fillBlockHashes(ctx, bridges); err != nil { + return nil, 0, err + } + return bridges, bridgesCount, nil } @@ -1476,6 +1483,9 @@ func (p *processor) GetBridgeByDepositCount(ctx context.Context, depositCount ui return nil, fmt.Errorf("GetBridgeByDepositCount (%s): scan: %w", tq.name, scanErr) } if len(bridges) > 0 { + if err = p.fillBlockHashes(ctx, bridges[:1]); err != nil { + return nil, err + } return bridges[0], nil } } @@ -1549,9 +1559,51 @@ func (p *processor) GetBridgesByContent( } result = append(result, bridges...) } + if err := p.fillBlockHashes(ctx, result); err != nil { + return nil, err + } return result, 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 +} + +// fillBlockHashes sets Bridge.BlockHash from the block table. Bridges whose block has no stored +// hash keep the zero hash. +func (p *processor) fillBlockHashes(ctx context.Context, bridges []*Bridge) error { + hashes := make(map[uint64]common.Hash) + for _, bridge := range bridges { + hash, ok := hashes[bridge.BlockNum] + if !ok { + var err error + hash, _, err = p.getBlockHash(ctx, bridge.BlockNum) + if err != nil { + return err + } + hashes[bridge.BlockNum] = hash + } + bridge.BlockHash = hash + } + return 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/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 1b65ec07a..6b380792a 100644 --- a/docs/assets/swagger/bridge_service/swagger.json +++ b/docs/assets/swagger/bridge_service/swagger.json @@ -1940,7 +1940,7 @@ "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, when injected on L2, the L2 injection block too. Only set by the injected-l1-info-leaf\nendpoint; omitted when finality could not be determined.", + "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", "type": "boolean", "example": true }, diff --git a/tree/types/types.go b/tree/types/types.go index dd1826709..acbe36ed1 100644 --- a/tree/types/types.go +++ b/tree/types/types.go @@ -21,6 +21,9 @@ 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: it is only filled in by the syncers that serve the root (zero if unknown) + BlockHash common.Hash `meddler:"-"` } type TreeNode struct { From e82f9030772a2d5d88da45bffc628eb005e21de3 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:22:23 +0200 Subject: [PATCH 04/12] feat(claimsync): report block finality of claims from the syncer Add ClaimSync.IsBlockFinalized(num, hash), with the same rule as the bridge syncer: a processed block with the given hash that the reorg detector no longer tracks is finalized, since it only stops tracking it after matching its hash against the canonical chain below the finalized block. Claim now carries the stored BlockHash (not persisted, filled by the paged and by-GER queries). The bridge service uses the claim syncer of the network for /claims and /claims-by-ger. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 64 ++++++++++++ bridgeservice/bridge.go | 7 +- bridgeservice/bridge_interfaces.go | 3 + bridgeservice/bridge_test.go | 2 + bridgeservice/mocks/mock_claimer.go | 71 ++++++++++++- bridgeservice/utils.go | 24 ++++- claimsync/claimsync.go | 36 +++++++ claimsync/is_block_finalized_test.go | 101 +++++++++++++++++++ claimsync/storage/storage.go | 44 ++++++++ claimsync/storage/storage_paged.go | 4 + claimsync/storage/storage_test.go | 36 +++++++ claimsync/types/claim_data.go | 3 + claimsync/types/claim_reader.go | 3 + claimsync/types/claim_storager.go | 3 + claimsync/types/mocks/mock_claim_storager.go | 77 ++++++++++++-- claimsync/types/mocks/mock_claims_reader.go | 74 +++++++++++++- 16 files changed, 531 insertions(+), 21 deletions(-) create mode 100644 claimsync/is_block_finalized_test.go diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go index 47abcd4e4..a7dee8d5f 100644 --- a/bridgeservice/block_finalized_test.go +++ b/bridgeservice/block_finalized_test.go @@ -9,8 +9,10 @@ import ( "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" tree "github.com/agglayer/aggkit/tree/types" "github.com/ethereum/go-ethereum/common" @@ -20,6 +22,16 @@ import ( 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") @@ -102,6 +114,58 @@ func TestBlockFinalizedInResponses(t *testing.T) { 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} { diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 4ef8ccdb3..048d75805 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -560,8 +560,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, @@ -2434,8 +2436,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, diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index 3620e41f9..6f80c972c 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -47,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 { diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index c2642861e..c340b0fc9 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -77,6 +77,8 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { // 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() expectL1BlocksFinalized(b.l1InfoTree, nil) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) 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/utils.go b/bridgeservice/utils.go index 061ccf8a5..cb4c8a1aa 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -239,14 +239,30 @@ func (b *BridgeService) bridgerForNetwork(networkID uint32) Bridger { return nil } -// blockFinalized asks the bridge syncer whether a block it served (identified by number and hash) is finalized. +// 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, bridger Bridger, blockNum uint64, +func (b *BridgeService) blockFinalized(ctx context.Context, syncer blockFinalitySource, blockNum uint64, blockHash common.Hash) *bool { - if bridger == nil { + if syncer == nil { return nil } - finalized, found, err := bridger.IsBlockFinalized(ctx, blockNum, blockHash) + 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 diff --git a/claimsync/claimsync.go b/claimsync/claimsync.go index b34982561..e9a664da2 100644 --- a/claimsync/claimsync.go +++ b/claimsync/claimsync.go @@ -13,6 +13,7 @@ import ( claimsyncStorage "github.com/agglayer/aggkit/claimsync/storage" claimsynctypes "github.com/agglayer/aggkit/claimsync/types" aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/agglayer/aggkit/db" "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/log" "github.com/agglayer/aggkit/sync" @@ -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,36 @@ 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) { + if blockHash == (common.Hash{}) { + return false, false, nil + } + + storedHash, stored, err := c.reader.GetBlockHash(ctx, nil, blockNum) + if err != nil { + return false, false, err + } + if !stored || storedHash != blockHash { + return false, false, nil + } + + _, err = c.reorgDetector.GetTrackedBlockByBlockNumber(c.syncerID.String(), blockNum) + switch { + case err == nil: + // still tracked: its hash was not confirmed against a finalized block yet + return false, true, nil + case errors.Is(err, db.ErrNotFound): + return true, true, nil + default: + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + } +} diff --git a/claimsync/is_block_finalized_test.go b/claimsync/is_block_finalized_test.go new file mode 100644 index 000000000..a6771385e --- /dev/null +++ b/claimsync/is_block_finalized_test.go @@ -0,0 +1,101 @@ +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) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)).Return(nil, db.ErrNotFound) + + 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, reader, rd := newSyncer(t) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + 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("hash differs from the stored one: unknown", func(t *testing.T) { + s, reader, _ := newSyncer(t) + 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("block without stored hash: unknown", func(t *testing.T) { + s, reader, _ := newSyncer(t) + 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, _ := newSyncer(t) + 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, reader, rd := newSyncer(t) + reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + 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) + }) +} diff --git a/claimsync/storage/storage.go b/claimsync/storage/storage.go index 3e47b4888..8cd566d70 100644 --- a/claimsync/storage/storage.go +++ b/claimsync/storage/storage.go @@ -359,9 +359,53 @@ func (p *claimStorage) GetClaimsByGER( if err = meddler.ScanAll(rows, &claims); err != nil { return nil, fmt.Errorf("GetClaimsByGER: scan: %w", err) } + if err = p.FillBlockHashes(ctx, tx, claims); err != nil { + return nil, fmt.Errorf("GetClaimsByGER: %w", err) + } return claims, 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 +} + +// FillBlockHashes sets Claim.BlockHash from the block table. Claims whose block has no stored hash +// keep the zero hash. +func (s *claimStorage) FillBlockHashes(ctx context.Context, tx dbtypes.Querier, + claims []*claimsynctypes.Claim) error { + hashes := make(map[uint64]common.Hash) + for _, claim := range claims { + hash, ok := hashes[claim.BlockNum] + if !ok { + var err error + hash, _, err = s.GetBlockHash(ctx, tx, claim.BlockNum) + if err != nil { + return err + } + hashes[claim.BlockNum] = hash + } + claim.BlockHash = hash + } + return nil +} + // DeleteBlocksFrom deletes all blocks with num >= firstBlock and returns the count deleted. // Cascade constraints automatically remove associated claims, unset_claims and set_claims. func (s *claimStorage) DeleteBlocksFrom(ctx context.Context, tx dbtypes.Querier, firstBlock uint64) (int64, error) { diff --git a/claimsync/storage/storage_paged.go b/claimsync/storage/storage_paged.go index b7255734d..16d1b58b1 100644 --- a/claimsync/storage/storage_paged.go +++ b/claimsync/storage/storage_paged.go @@ -233,6 +233,10 @@ func (p *claimStorage) GetClaimsPaged( return nil, 0, err } + if err = p.FillBlockHashes(ctx, nil, claims); err != nil { + return nil, 0, fmt.Errorf("GetClaimsPaged: %w", err) + } + return claims, claimsCount, nil } diff --git a/claimsync/storage/storage_test.go b/claimsync/storage/storage_test.go index e5d0cc066..4734d63ba 100644 --- a/claimsync/storage/storage_test.go +++ b/claimsync/storage/storage_test.go @@ -1435,3 +1435,39 @@ func TestGetClaimsPaged_Compact(t *testing.T) { require.Equal(t, claims[4].ProofLocalExitRoot, got[0].ProofLocalExitRoot) }) } + +func TestClaimQueriesFillBlockHash(t *testing.T) { + s, _ := newTestStorage(t) + ctx := context.Background() + hash := common.HexToHash("0xb10c") + + tx, err := s.NewTx(ctx) + require.NoError(t, err) + require.NoError(t, s.InsertBlock(ctx, tx, 1, hash)) + require.NoError(t, s.InsertBlock(ctx, tx, 2, common.Hash{})) + require.NoError(t, s.InsertClaim(ctx, tx, claimsynctypes.Claim{ + BlockNum: 1, GlobalIndex: big.NewInt(1), Amount: big.NewInt(1), Type: claimsynctypes.ClaimEvent, + })) + require.NoError(t, s.InsertClaim(ctx, tx, claimsynctypes.Claim{ + BlockNum: 2, GlobalIndex: big.NewInt(2), Amount: big.NewInt(1), Type: claimsynctypes.ClaimEvent, + })) + require.NoError(t, tx.Commit()) + + gotHash, found, err := s.GetBlockHash(ctx, nil, 1) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, hash, gotHash) + + _, found, err = s.GetBlockHash(ctx, nil, 99) + require.NoError(t, err) + require.False(t, found) + + claims, count, err := s.GetClaimsPaged(ctx, 1, 10, nil, nil) + require.NoError(t, err) + require.Equal(t, 2, count) + hashByBlock := map[uint64]common.Hash{} + for _, claim := range claims { + hashByBlock[claim.BlockNum] = claim.BlockHash + } + require.Equal(t, map[uint64]common.Hash{1: hash, 2: {}}, hashByBlock) +} diff --git a/claimsync/types/claim_data.go b/claimsync/types/claim_data.go index ead5a264e..392aa5aa7 100644 --- a/claimsync/types/claim_data.go +++ b/claimsync/types/claim_data.go @@ -42,6 +42,9 @@ 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: it is filled in by the paged/by-GER read queries (zero if unknown) + 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) From 69ac0b994f66d46cd4cb37ecb48ec419ea9e4751 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:08:30 +0100 Subject: [PATCH 05/12] feat(l2gersync): report block finality of the injection block from the syncer Add L2GERSync.IsBlockFinalized(num, hash), with the same rule as the bridge and claim syncers (a processed block with the given hash that the reorg detector no longer tracks is finalized). It reports unknown for chains synced in Legacy mode, where the stored block number is the polling head and not the real injection block. GlobalExitRootInfo now carries the stored BlockHash (not persisted, filled by GetFirstGERAfterL1InfoTreeIndex). For an L2 lookup, /injected-l1-info-leaf reports block_finalized only if both the L1 leaf block and the L2 injection block are finalized, false as soon as one is known not to be, and omits it otherwise. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 56 +++++++++ bridgeservice/bridge.go | 8 +- bridgeservice/bridge_interfaces.go | 3 + bridgeservice/bridge_test.go | 1 + bridgeservice/docs/docs.go | 2 +- bridgeservice/docs/swagger.json | 2 +- bridgeservice/docs/swagger.yaml | 7 +- bridgeservice/mocks/mock_l2_ger_syncer.go | 69 ++++++++++- bridgeservice/types/types.go | 7 +- bridgeservice/utils.go | 14 +++ .../swagger/bridge_service/swagger.json | 2 +- l2gersync/is_block_finalized_test.go | 108 ++++++++++++++++++ l2gersync/l2_ger_syncer.go | 40 +++++++ l2gersync/processor.go | 23 ++++ 14 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 l2gersync/is_block_finalized_test.go diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go index a7dee8d5f..e633fc214 100644 --- a/bridgeservice/block_finalized_test.go +++ b/bridgeservice/block_finalized_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -241,4 +242,59 @@ func TestBlockFinalizedInResponses(t *testing.T) { 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, 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}, 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 048d75805..2f746e09e 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -1008,6 +1008,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: @@ -1039,6 +1041,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) @@ -1058,11 +1061,10 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { resp := NewL1InfoTreeLeafResponse(l1InfoLeaf) resp.InjectedL2BlockNumber = injectedL2BlockNumber resp.InjectedL2BlockTimestamp = injectedL2Timestamp - // the leaf's L1 block must be finalized and, if injected on an L2, that L2 block too + // 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) if injectedL2BlockNumber != nil { - // the injection block on L2 is not covered by a syncer finality yet - resp.BlockFinalized = nil + resp.BlockFinalized = andFinalized(resp.BlockFinalized, injectedL2Finalized) } statusCode = http.StatusOK diff --git a/bridgeservice/bridge_interfaces.go b/bridgeservice/bridge_interfaces.go index 6f80c972c..c24d44256 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -61,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 { diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index c340b0fc9..ccb993589 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -79,6 +79,7 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { 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, nil) b.bridge = New(cfg, b.upgradeQuerier, b.l1InfoTree, b.injectedGERs, b.bridgeL1, b.claimL1, b.bridgeL2, b.claimL2) diff --git a/bridgeservice/docs/docs.go b/bridgeservice/docs/docs.go index 853b4819a..9a78ef678 100644 --- a/bridgeservice/docs/docs.go +++ b/bridgeservice/docs/docs.go @@ -1947,7 +1947,7 @@ const docTemplate = `{ "type": "object", "properties": { "block_finalized": { - "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", + "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", "type": "boolean", "example": true }, diff --git a/bridgeservice/docs/swagger.json b/bridgeservice/docs/swagger.json index 6b380792a..7a288314a 100644 --- a/bridgeservice/docs/swagger.json +++ b/bridgeservice/docs/swagger.json @@ -1940,7 +1940,7 @@ "type": "object", "properties": { "block_finalized": { - "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", + "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", "type": "boolean", "example": true }, diff --git a/bridgeservice/docs/swagger.yaml b/bridgeservice/docs/swagger.yaml index 4efb3c976..c2b5e7f36 100644 --- a/bridgeservice/docs/swagger.yaml +++ b/bridgeservice/docs/swagger.yaml @@ -552,9 +552,10 @@ definitions: properties: block_finalized: description: |- - BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the - injected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be - determined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet). + 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 finality could not be determined (e.g. L2 chains synced by + l2gersync in Legacy mode, where the injection block is only approximate). example: true type: boolean block_num: 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/types/types.go b/bridgeservice/types/types.go index 7a36886ae..8902488eb 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -396,9 +396,10 @@ type L1InfoTreeLeafResponse struct { // 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 L1 block of the leaf is finalized (reorg-safe). Only set by the - // injected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be - // determined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet). + // 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 finality could not be determined (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"` } diff --git a/bridgeservice/utils.go b/bridgeservice/utils.go index cb4c8a1aa..28ed258d7 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -289,3 +289,17 @@ func (b *BridgeService) l1BlockFinalized(ctx context.Context, blockNum uint64) * } return &finalized } + +// 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/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 6b380792a..7a288314a 100644 --- a/docs/assets/swagger/bridge_service/swagger.json +++ b/docs/assets/swagger/bridge_service/swagger.json @@ -1940,7 +1940,7 @@ "type": "object", "properties": { "block_finalized": { - "description": "BlockFinalized reports whether the L1 block of the leaf is finalized (reorg-safe). Only set by the\ninjected-l1-info-leaf endpoint for an L1 (network_id=0) lookup; omitted when finality could not be\ndetermined and, for now, for an L2 lookup (the L2 injection block finality is not reported yet).", + "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", "type": "boolean", "example": true }, diff --git a/l2gersync/is_block_finalized_test.go b/l2gersync/is_block_finalized_test.go new file mode 100644 index 000000000..4d9a1ee9d --- /dev/null +++ b/l2gersync/is_block_finalized_test.go @@ -0,0 +1,108 @@ +package l2gersync + +import ( + "context" + "errors" + "path" + "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/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("hash differs from the stored one: unknown", func(t *testing.T) { + s, _, _ := newBlockFinalityTestSyncer(t) + + _, found, err := s.IsBlockFinalized(ctx, 10, common.HexToHash("0x99")) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("zero hash, block without hash or not stored: unknown", func(t *testing.T) { + s, _, hash := newBlockFinalityTestSyncer(t) + + for _, tc := range []struct { + blockNum uint64 + hash common.Hash + }{{10, 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("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) + + info, err := s.processor.GetFirstGERAfterL1InfoTreeIndex(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, uint64(10), info.BlockNum) + require.Equal(t, hash, info.BlockHash) +} diff --git a/l2gersync/l2_ger_syncer.go b/l2gersync/l2_ger_syncer.go index f0a450f69..ffe8dbf08 100644 --- a/l2gersync/l2_ger_syncer.go +++ b/l2gersync/l2_ger_syncer.go @@ -2,11 +2,13 @@ package l2gersync import ( "context" + "errors" "fmt" "strings" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerger" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayergerl2" + "github.com/agglayer/aggkit/db" "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/l1infotreesync" "github.com/agglayer/aggkit/log" @@ -49,6 +51,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 +124,8 @@ func New( cfg: cfg, l2Client: l2Client, syncMode: syncMode, + + reorgDetector: rdL2, }, nil } @@ -238,3 +244,37 @@ 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 + } + + storedHash, stored, err := s.processor.getBlockHash(ctx, blockNum) + if err != nil { + return false, false, err + } + if !stored || storedHash != blockHash { + return false, false, nil + } + + _, err = s.reorgDetector.GetTrackedBlockByBlockNumber(reorgDetectorID, blockNum) + switch { + case err == nil: + // still tracked: its hash was not confirmed against a finalized block yet + return false, true, nil + case errors.Is(err, db.ErrNotFound): + return true, true, nil + default: + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + } +} diff --git a/l2gersync/processor.go b/l2gersync/processor.go index cbf78475f..8b2b80da6 100644 --- a/l2gersync/processor.go +++ b/l2gersync/processor.go @@ -34,6 +34,9 @@ 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 fills it in (zero if unknown) + BlockHash ethcommon.Hash `meddler:"-"` } // RemoveGEREvent represents a remove GER event stored in the database @@ -241,10 +244,30 @@ func (p *processor) GetFirstGERAfterL1InfoTreeIndex( } return e, fmt.Errorf("failed to get first GER after index %d: %w", l1InfoTreeIndex, err) } + if e.BlockHash, _, err = p.getBlockHash(ctx, e.BlockNum); err != nil { + return e, err + } return e, 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 // highest L1 info tree index. Returns db.ErrNotFound if no GER has been injected yet. func (p *processor) GetLastGER(ctx context.Context) (GlobalExitRootInfo, error) { From 16f5d4423826022b9773865e25eff7fd8bb3889c Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:25:25 +0100 Subject: [PATCH 06/12] feat(l1infotreesync): report block finality for the legacy syncer Apply the same pattern as the other legacy syncers to L1InfoTreeSync: a processed block with the given hash that the reorg detector no longer tracks is finalized. IsBlockFinalized now takes the block hash read together with the item: the legacy path requires it, the multidownloader path (IsFinal flag) checks it when given. L1InfoTreeLeaf carries the stored BlockHash (not persisted nor serialized, filled by L1InfoTreeSync.GetInfoByIndex) and the settlements use their joined block hash, so /injected-l1-info-leaf and /settlements report block_finalized in both modes. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/block_finalized_test.go | 14 +- bridgeservice/bridge.go | 8 +- bridgeservice/bridge_interfaces.go | 6 +- bridgeservice/bridge_test.go | 10 +- .../mocks/mock_l1_info_tree_syncer.go | 33 ++-- bridgeservice/utils.go | 17 +- l1infotreesync/is_block_finalized_test.go | 165 ++++++++++++++++-- l1infotreesync/l1infotreesync.go | 80 +++++++-- l1infotreesync/processor.go | 20 +++ 9 files changed, 278 insertions(+), 75 deletions(-) diff --git a/bridgeservice/block_finalized_test.go b/bridgeservice/block_finalized_test.go index e633fc214..f3db28812 100644 --- a/bridgeservice/block_finalized_test.go +++ b/bridgeservice/block_finalized_test.go @@ -191,12 +191,12 @@ func TestBlockFinalizedInResponses(t *testing.T) { t.Run("settlements use the l1infotreesync finality", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{101: false, 100: true}) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{101: false, 100: true}) bridgeMocks.l1InfoTree.EXPECT(). GetVerifiedBatchesPaged(l2NetworkID, DefaultPage, DefaultPageSize). Return([]*l1infotreesync.VerifiedBatchWithBlockHash{ - {BlockNumber: 101}, - {BlockNumber: 100}, + {BlockNumber: 101, BlockHash: &blockHash}, + {BlockNumber: 100, BlockHash: &blockHash}, }, 2, nil) w := performRequest(t, bridgeMocks.router, fmt.Sprintf("%s/settlements", BridgeV1Prefix)) @@ -211,10 +211,10 @@ func TestBlockFinalizedInResponses(t *testing.T) { t.Run("injected-l1-info-leaf on L1", func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) - expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{101: false}) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{101: false}) bridgeMocks.l1InfoTree.EXPECT(). GetInfoByIndex(mock.Anything, uint32(3)). - Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 101}, nil) + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: 101, BlockHash: blockHash}, nil) queryParams := url.Values{} queryParams.Set(networkIDParam, "0") @@ -266,7 +266,7 @@ func TestBlockFinalizedInResponses(t *testing.T) { t.Run(tc.name, func(t *testing.T) { bridgeMocks := newBridgeWithMocks(t, l2NetworkID) if tc.l1Finalized != nil { - expectL1BlocksFinalized(bridgeMocks.l1InfoTree, map[uint64]bool{l1Block: *tc.l1Finalized}) + expectL1BlocksFinalized(bridgeMocks.l1InfoTree, blockHash, map[uint64]bool{l1Block: *tc.l1Finalized}) } bridgeMocks.injectedGERs.ExpectedCalls = nil if tc.l2Finalized != nil { @@ -282,7 +282,7 @@ func TestBlockFinalizedInResponses(t *testing.T) { }, nil) bridgeMocks.l1InfoTree.EXPECT(). GetInfoByIndex(mock.Anything, uint32(3)). - Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: l1Block}, nil) + Return(&l1infotreesync.L1InfoTreeLeaf{BlockNumber: l1Block, BlockHash: blockHash}, nil) queryParams := url.Values{} queryParams.Set(networkIDParam, strconv.Itoa(int(l2NetworkID))) diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 2f746e09e..2ce752d70 100644 --- a/bridgeservice/bridge.go +++ b/bridgeservice/bridge.go @@ -1062,7 +1062,7 @@ func (b *BridgeService) InjectedL1InfoLeafHandler(c *gin.Context) { 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) + resp.BlockFinalized = b.l1BlockFinalized(ctx, l1InfoLeaf.BlockNumber, l1InfoLeaf.BlockHash) if injectedL2BlockNumber != nil { resp.BlockFinalized = andFinalized(resp.BlockFinalized, injectedL2Finalized) } @@ -2848,11 +2848,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), + 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 c24d44256..46258e752 100644 --- a/bridgeservice/bridge_interfaces.go +++ b/bridgeservice/bridge_interfaces.go @@ -87,7 +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 was stored as finalized (so it can't be reorged). - // found is false when it can't be determined - IsBlockFinalized(ctx context.Context, blockNum uint64) (finalized, found bool, err 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 ccb993589..36064b0d7 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -80,7 +80,7 @@ func newBridgeWithMocks(t *testing.T, networkID uint32) bridgeWithMocks { 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, nil) + 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() @@ -107,8 +107,8 @@ func clearIsBlockFinalized(bridger *mocks.Bridger) { } // expectL1BlocksFinalized replaces any IsBlockFinalized expectation of the l1infotreesync mock: the -// blocks in finality get that finality, any other block is unknown (found=false). -func expectL1BlocksFinalized(l1InfoTree *mocks.L1InfoTreeSyncer, finality map[uint64]bool) { +// 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" { @@ -117,9 +117,9 @@ func expectL1BlocksFinalized(l1InfoTree *mocks.L1InfoTreeSyncer, finality map[ui } l1InfoTree.ExpectedCalls = expected for blockNum, finalized := range finality { - l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, blockNum).Return(finalized, true, nil).Maybe() + l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, blockNum, blockHash).Return(finalized, true, nil).Maybe() } - l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything).Return(false, false, nil).Maybe() + l1InfoTree.EXPECT().IsBlockFinalized(mock.Anything, mock.Anything, mock.Anything).Return(false, false, nil).Maybe() } // newBridgeWithMocksAndHealthCacheTTL is newBridgeWithMocks with an explicit diff --git a/bridgeservice/mocks/mock_l1_info_tree_syncer.go b/bridgeservice/mocks/mock_l1_info_tree_syncer.go index 73dd9763c..3eaf8a506 100644 --- a/bridgeservice/mocks/mock_l1_info_tree_syncer.go +++ b/bridgeservice/mocks/mock_l1_info_tree_syncer.go @@ -894,9 +894,9 @@ func (_c *L1InfoTreeSyncer_IsActive_Call) RunAndReturn(run func(context.Context) return _c } -// IsBlockFinalized provides a mock function with given fields: ctx, blockNum -func (_m *L1InfoTreeSyncer) IsBlockFinalized(ctx context.Context, blockNum uint64) (bool, bool, error) { - ret := _m.Called(ctx, blockNum) +// 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") @@ -905,23 +905,23 @@ func (_m *L1InfoTreeSyncer) IsBlockFinalized(ctx context.Context, blockNum uint6 var r0 bool var r1 bool var r2 error - if rf, ok := ret.Get(0).(func(context.Context, uint64) (bool, bool, error)); ok { - return rf(ctx, blockNum) + 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) bool); ok { - r0 = rf(ctx, blockNum) + 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) bool); ok { - r1 = rf(ctx, blockNum) + 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) error); ok { - r2 = rf(ctx, blockNum) + if rf, ok := ret.Get(2).(func(context.Context, uint64, common.Hash) error); ok { + r2 = rf(ctx, blockNum, blockHash) } else { r2 = ret.Error(2) } @@ -937,13 +937,14 @@ type L1InfoTreeSyncer_IsBlockFinalized_Call struct { // IsBlockFinalized is a helper method to define mock.On call // - ctx context.Context // - blockNum uint64 -func (_e *L1InfoTreeSyncer_Expecter) IsBlockFinalized(ctx interface{}, blockNum interface{}) *L1InfoTreeSyncer_IsBlockFinalized_Call { - return &L1InfoTreeSyncer_IsBlockFinalized_Call{Call: _e.mock.On("IsBlockFinalized", ctx, blockNum)} +// - 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)) *L1InfoTreeSyncer_IsBlockFinalized_Call { +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)) + run(args[0].(context.Context), args[1].(uint64), args[2].(common.Hash)) }) return _c } @@ -953,7 +954,7 @@ func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) Return(finalized bool, found b return _c } -func (_c *L1InfoTreeSyncer_IsBlockFinalized_Call) RunAndReturn(run func(context.Context, uint64) (bool, bool, error)) *L1InfoTreeSyncer_IsBlockFinalized_Call { +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 } diff --git a/bridgeservice/utils.go b/bridgeservice/utils.go index 28ed258d7..e437a7c9e 100644 --- a/bridgeservice/utils.go +++ b/bridgeservice/utils.go @@ -273,21 +273,14 @@ func (b *BridgeService) blockFinalized(ctx context.Context, syncer blockFinality return &finalized } -// l1BlockFinalized asks the l1infotreesync syncer whether an L1 block it stored 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) *bool { +// 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 } - finalized, found, err := b.l1InfoTree.IsBlockFinalized(ctx, blockNum) - if err != nil { - b.logger.Warnf("unable to get the finality of L1 block %d, block_finalized will be omitted: %v", blockNum, err) - return nil - } - if !found { - return nil - } - return &finalized + 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 diff --git a/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go index 8960517ee..c226e1f7e 100644 --- a/l1infotreesync/is_block_finalized_test.go +++ b/l1infotreesync/is_block_finalized_test.go @@ -1,62 +1,195 @@ package l1infotreesync import ( + "encoding/json" "errors" + "path" "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 TestIsBlockFinalized(t *testing.T) { +func TestIsBlockFinalizedMultidownloader(t *testing.T) { ctx := t.Context() - header := &aggkittypes.BlockHeader{Number: 10} + hash := common.HexToHash("0x10") + header := &aggkittypes.BlockHeader{Number: 10, Hash: hash} - t.Run("not multidownloader based: unknown", func(t *testing.T) { + 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) + 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) { - querier := NewBlockFinalityQuerierMock(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("stored as finalized, no hash given", func(t *testing.T) { + syncer, querier := newSyncer(t) querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, true, nil) - syncer := &L1InfoTreeSync{blockFinality: querier} - finalized, found, err := syncer.IsBlockFinalized(ctx, 10) + finalized, found, err := syncer.IsBlockFinalized(ctx, 10, common.Hash{}) require.NoError(t, err) require.True(t, finalized) require.True(t, found) }) t.Run("stored but not finalized", func(t *testing.T) { - querier := NewBlockFinalityQuerierMock(t) + syncer, querier := newSyncer(t) querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, false, nil) - syncer := &L1InfoTreeSync{blockFinality: querier} - finalized, found, err := syncer.IsBlockFinalized(ctx, 10) + 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) { - querier := NewBlockFinalityQuerierMock(t) + syncer, querier := newSyncer(t) querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(nil, false, nil) - syncer := &L1InfoTreeSync{blockFinality: querier} - _, found, err := syncer.IsBlockFinalized(ctx, 10) + _, found, err := syncer.IsBlockFinalized(ctx, 10, hash) require.NoError(t, err) require.False(t, found) }) t.Run("storage error", func(t *testing.T) { - querier := NewBlockFinalityQuerierMock(t) + syncer, querier := newSyncer(t) querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)). Return(nil, false, errors.New("boom")) - syncer := &L1InfoTreeSync{blockFinality: querier} - _, found, err := syncer.IsBlockFinalized(ctx, 10) + _, 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("hash differs from the stored one: unknown", func(t *testing.T) { + syncer, _, _ := newLegacyTestSyncer(t) + + _, found, err := syncer.IsBlockFinalized(ctx, 10, common.HexToHash("0x99")) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("zero hash, block without hash or not stored: unknown", func(t *testing.T) { + syncer, _, hash := newLegacyTestSyncer(t) + + for _, tc := range []struct { + blockNum uint64 + hash common.Hash + }{{10, 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("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") + require.NoError(t, processor.ProcessBlock(t.Context(), sync.Block{ + Num: 1, + Hash: hash, + Events: []any{Event{UpdateL1InfoTree: &UpdateL1InfoTree{ + MainnetExitRoot: common.HexToHash("beef"), + RollupExitRoot: common.HexToHash("5ca1e"), + ParentHash: common.HexToHash("1010101"), + Timestamp: 420, + }}}, + })) + 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 hash is not part of the leaf's JSON + raw, err := json.Marshal(leaf) + require.NoError(t, err) + require.NotContains(t, string(raw), "BlockHash") + require.NotContains(t, string(raw), hash.Hex()) } diff --git a/l1infotreesync/l1infotreesync.go b/l1infotreesync/l1infotreesync.go index 855aadb93..be0547f61 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -57,8 +57,11 @@ type L1InfoTreeSync struct { driver DriverInterface cfg Config // blockFinality is only set when synced through the multidownloader, which is the one - // that tracks the finality of the blocks. It is nil for the legacy syncer + // 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 @@ -228,27 +231,69 @@ func NewLegacy( } return &L1InfoTreeSync{ - processor: processor, - driver: driver, - cfg: cfg, + processor: processor, + driver: driver, + cfg: cfg, + reorgDetector: reorgDetector, }, nil } -// IsBlockFinalized reports whether the block was stored as finalized by the multidownloader (so -// it can no longer be reorged). found is false when it can't be determined: the block is not -// stored by the multidownloader, or the syncer is not multidownloader based (legacy or read-only). -func (d *L1InfoTreeSync) IsBlockFinalized(ctx context.Context, blockNum uint64) (finalized, found bool, err error) { - if d.blockFinality == 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 hash, when given, must be the stored one. +// 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; here the hash is required. +// found is false when it can't be determined: the block is not stored (or not with that hash), no hash was +// given to the legacy syncer, or the syncer is read-only. +func (d *L1InfoTreeSync) IsBlockFinalized(ctx context.Context, blockNum uint64, + blockHash common.Hash) (finalized, found bool, err error) { + 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 || (blockHash != (common.Hash{}) && 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 + } + if blockHash == (common.Hash{}) { return false, false, nil } - header, isFinal, err := d.blockFinality.StorageHeaderByNumber(ctx, aggkittypes.NewBlockNumber(blockNum)) + + storedHash, stored, err := d.processor.getBlockHash(ctx, blockNum) if err != nil { - return false, false, fmt.Errorf("l1infotreesync: failed to get block %d finality: %w", blockNum, err) + return false, false, err } - if header == nil { + if !stored || storedHash != blockHash { return false, false, nil } - return isFinal, true, nil + + _, err = d.reorgDetector.GetTrackedBlockByBlockNumber(syncerID, blockNum) + switch { + case err == nil: + // still tracked: its hash was not confirmed against a finalized block yet + return false, true, nil + case errors.Is(err, db.ErrNotFound): + return true, true, nil + default: + return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) + } } // Finality returns the block finality this syncer syncs up to (its own BlockFinality config). @@ -344,7 +389,14 @@ func (s *L1InfoTreeSync) GetInfoByIndex(ctx context.Context, index uint32) (*L1I if s.processor.isHalted() { return nil, sync.ErrInconsistentState } - return s.processor.GetInfoByIndex(ctx, index) + leaf, err := s.processor.GetInfoByIndex(ctx, index) + if err != nil { + return leaf, err + } + if leaf.BlockHash, _, err = s.processor.getBlockHash(ctx, leaf.BlockNumber); err != nil { + return nil, err + } + return leaf, nil } // GetL1InfoTreeRootByIndex returns the root of the L1 info tree at the moment the leaf with the given index was added diff --git a/l1infotreesync/processor.go b/l1infotreesync/processor.go index 77ec548e2..b1f212b54 100644 --- a/l1infotreesync/processor.go +++ b/l1infotreesync/processor.go @@ -153,6 +153,9 @@ 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 fills it in when serving a leaf (zero if unknown) + BlockHash common.Hash `meddler:"-" json:"-"` } func (l *L1InfoTreeLeaf) String() string { @@ -287,6 +290,23 @@ 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) From 68d409bfeddc44cd3282952d16f394ba07e5fff4 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:39:42 +0100 Subject: [PATCH 07/12] feat(bridgeservice): expose api_revision in the health endpoint Add bridgeservice.CurrentAPIRevision (revision.go, starting at 1, with the bump rule and revision history, like the bridge tracker's) and return it as api_revision in GET / and GET /health, in every sync_status case. It is purely informational and independent of the bridge tracker's revision. Update the swagger annotations, the health check section of docs/bridge_service.md and add tests: handler response (every sync_status), client round trip, the revision history staying in sync with the constant, and the finder's health checker ignoring the response body (with and without the new field). Closes #1920 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/bridge.go | 17 ++++--- bridgeservice/bridge_test.go | 6 +++ bridgeservice/client/client_test.go | 8 +-- bridgeservice/docs/docs.go | 9 +++- bridgeservice/docs/swagger.json | 9 +++- bridgeservice/docs/swagger.yaml | 21 ++++++-- bridgeservice/revision.go | 17 +++++++ bridgeservice/revision_test.go | 29 +++++++++++ bridgeservice/types/types.go | 11 ++++- bridgeservicefinder/health_body_test.go | 49 +++++++++++++++++++ .../swagger/bridge_service/swagger.json | 9 +++- docs/bridge_service.md | 11 +++++ 12 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 bridgeservice/revision.go create mode 100644 bridgeservice/revision_test.go create mode 100644 bridgeservicefinder/health_body_test.go diff --git a/bridgeservice/bridge.go b/bridgeservice/bridge.go index 2ce752d70..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) diff --git a/bridgeservice/bridge_test.go b/bridgeservice/bridge_test.go index 36064b0d7..e4a712d06 100644 --- a/bridgeservice/bridge_test.go +++ b/bridgeservice/bridge_test.go @@ -3991,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) @@ -4045,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) @@ -4072,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) @@ -4108,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) @@ -4143,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/docs.go b/bridgeservice/docs/docs.go index 9a78ef678..54744c283 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": [ diff --git a/bridgeservice/docs/swagger.json b/bridgeservice/docs/swagger.json index 7a288314a..1666bc346 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": [ diff --git a/bridgeservice/docs/swagger.yaml b/bridgeservice/docs/swagger.yaml index c2b5e7f36..876b9cb0a 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' @@ -1114,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: @@ -1553,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/revision.go b/bridgeservice/revision.go new file mode 100644 index 000000000..5df4d8744 --- /dev/null +++ b/bridgeservice/revision.go @@ -0,0 +1,17 @@ +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. +// +// Revision history: +// - 1: first revision, the contract as of the release that introduced api_revision. 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..a2df2d5a6 --- /dev/null +++ b/bridgeservice/revision_test.go @@ -0,0 +1,29 @@ +package bridgeservice + +import ( + "os" + "regexp" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCurrentAPIRevisionHasHistoryEntry keeps CurrentAPIRevision in sync with the revision history +// documented in revision.go: the latest history entry must be the current revision. +func TestCurrentAPIRevisionHasHistoryEntry(t *testing.T) { + content, err := os.ReadFile("revision.go") + require.NoError(t, err) + + entries := regexp.MustCompile(`(?m)^// - (\d+):`).FindAllStringSubmatch(string(content), -1) + require.NotEmpty(t, entries, "revision.go has no revision history entry") + + latest := 0 + for _, entry := range entries { + revision, err := strconv.Atoi(entry[1]) + require.NoError(t, err) + latest = max(latest, revision) + } + require.Equal(t, CurrentAPIRevision, latest, + "CurrentAPIRevision changed without a matching entry in the revision history of revision.go") +} diff --git a/bridgeservice/types/types.go b/bridgeservice/types/types.go index 8902488eb..a9a3b1a65 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -456,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}}} @@ -466,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 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/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 7a288314a..1666bc346 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": [ diff --git a/docs/bridge_service.md b/docs/bridge_service.md index 7cdd19544..d14d29384 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,16 @@ 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`. + `sync_status` (`types.HealthSyncStatus`) is one of: - `done` — every configured sync component is fully caught up (or has no "caught up" signal at all, From c92807afc758c667aef0567265dc49c21da7684e Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:53:49 +0100 Subject: [PATCH 08/12] docs(bridgeservice): detail the changes of each api_revision Add bridgeservice/docs/API_REVISIONS.md with how to read api_revision, when to bump it and the changes of every revision. TestCurrentAPIRevisionHasHistoryEntry now also checks that the latest entry of that file (and of revision.go) is the current revision, so the bump can't be forgotten. Refs #1920 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/docs/API_REVISIONS.md | 55 +++++++++++++++++++++++++++++ bridgeservice/revision.go | 3 ++ bridgeservice/revision_test.go | 30 +++++++++++----- docs/bridge_service.md | 2 ++ 4 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 bridgeservice/docs/API_REVISIONS.md diff --git a/bridgeservice/docs/API_REVISIONS.md b/bridgeservice/docs/API_REVISIONS.md new file mode 100644 index 000000000..3aea3eca1 --- /dev/null +++ b/bridgeservice/docs/API_REVISIONS.md @@ -0,0 +1,55 @@ +# 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 (a new field that clients can safely ignore is **not** a bump, + unless it is the only way to tell a behaviour change); +- 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. + +Worth knowing for clients that have to deal with older instances (those that don't return `api_revision` +at all): + +- 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 can't tell, so an absent value + must be read as "unknown", not as `false`. +- `GET /` and `GET /health` return `api_revision`. diff --git a/bridgeservice/revision.go b/bridgeservice/revision.go index 5df4d8744..de3c4dd55 100644 --- a/bridgeservice/revision.go +++ b/bridgeservice/revision.go @@ -11,6 +11,9 @@ package bridgeservice // 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. Earlier changes // are not back-filled diff --git a/bridgeservice/revision_test.go b/bridgeservice/revision_test.go index a2df2d5a6..d08017e39 100644 --- a/bridgeservice/revision_test.go +++ b/bridgeservice/revision_test.go @@ -9,14 +9,15 @@ import ( "github.com/stretchr/testify/require" ) -// TestCurrentAPIRevisionHasHistoryEntry keeps CurrentAPIRevision in sync with the revision history -// documented in revision.go: the latest history entry must be the current revision. -func TestCurrentAPIRevisionHasHistoryEntry(t *testing.T) { - content, err := os.ReadFile("revision.go") +// 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 := regexp.MustCompile(`(?m)^// - (\d+):`).FindAllStringSubmatch(string(content), -1) - require.NotEmpty(t, entries, "revision.go has no revision history entry") + entries := re.FindAllStringSubmatch(string(content), -1) + require.NotEmpty(t, entries, "%s has no revision history entry", file) latest := 0 for _, entry := range entries { @@ -24,6 +25,19 @@ func TestCurrentAPIRevisionHasHistoryEntry(t *testing.T) { require.NoError(t, err) latest = max(latest, revision) } - require.Equal(t, CurrentAPIRevision, latest, - "CurrentAPIRevision changed without a matching entry in the revision history of revision.go") + 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/docs/bridge_service.md b/docs/bridge_service.md index d14d29384..fff49abaa 100644 --- a/docs/bridge_service.md +++ b/docs/bridge_service.md @@ -235,6 +235,8 @@ bodies. It is purely informational (the service never rejects or alters its beha 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: From 6ebe76eba5d79f6838c9e2a637a81f121a5f01e3 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:33:45 +0100 Subject: [PATCH 09/12] fix(l1infotreesync): require the block hash to report multidownloader finality The multidownloader stores one block per number, so without the hash of the block the caller read, IsFinal could belong to the block that replaced it after a reorg. A zero hash (rows written before the block table had a hash, or a missing hash) now reports unknown in both modes, so block_finalized is omitted. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- l1infotreesync/is_block_finalized_test.go | 10 +++++----- l1infotreesync/l1infotreesync.go | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go index c226e1f7e..58c821938 100644 --- a/l1infotreesync/is_block_finalized_test.go +++ b/l1infotreesync/is_block_finalized_test.go @@ -43,13 +43,13 @@ func TestIsBlockFinalizedMultidownloader(t *testing.T) { require.True(t, found) }) - t.Run("stored as finalized, no hash given", func(t *testing.T) { - syncer, querier := newSyncer(t) - querier.EXPECT().StorageHeaderByNumber(mock.Anything, aggkittypes.NewBlockNumber(10)).Return(header, true, nil) + 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.True(t, finalized) - require.True(t, found) + require.False(t, finalized) + require.False(t, found) }) t.Run("stored but not finalized", func(t *testing.T) { diff --git a/l1infotreesync/l1infotreesync.go b/l1infotreesync/l1infotreesync.go index be0547f61..24d429779 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -241,22 +241,28 @@ func NewLegacy( // 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 hash, when given, must be the stored one. +// 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; here the hash is required. -// found is false when it can't be determined: the block is not stored (or not with that hash), no hash was -// given to the legacy syncer, or the syncer is read-only. +// 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 || (blockHash != (common.Hash{}) && header.Hash != blockHash) { + if header == nil || header.Hash != blockHash { return false, false, nil } return isFinal, true, nil @@ -272,9 +278,6 @@ func (d *L1InfoTreeSync) isBlockFinalizedLegacy(ctx context.Context, blockNum ui if d.processor.isHalted() { return false, false, sync.ErrInconsistentState } - if blockHash == (common.Hash{}) { - return false, false, nil - } storedHash, stored, err := d.processor.getBlockHash(ctx, blockNum) if err != nil { From 6c006e1f209fbae747b4584f671d0df3c85b5284 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:52:02 +0100 Subject: [PATCH 10/12] fix: read the block hash in the same statement as the rows The block_finalized flag is computed from the block hash read together with each item (bridge, claim, L1 info tree leaf, injected GER, exit tree root). Until now that hash was filled by a second query on the block table after reading the rows. A reorg processed between both reads, with the replacement block already inserted, made an orphaned row get the hash of the block that replaced its own, so IsBlockFinalized validated the wrong block. Read the hash with a LEFT JOIN on the block table in the same SELECT (one statement, one snapshot) in GetBridgesPaged, GetBridgeByDepositCount and GetBridgesByContent (bridge and bridge_archive), BridgeSync.GetRootByLER, GetClaimsPaged and GetClaimsByGER, L1InfoTreeSync.GetInfoByIndex and GetFirstGERAfterL1InfoTreeIndex. Rows are scanned into unexported read models with a block_hash column and converted to the public types, which keep BlockHash out of meddler so inserts are unchanged. The read models have the same fields as the public types, so the conversion only compiles while both match, and a test checks their meddler columns. Remove the fillBlockHashes/FillBlockHashes helpers, now unused. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgesync/bridgesync.go | 13 +- bridgesync/is_block_finalized_test.go | 152 +++++++++++++++-- bridgesync/processor.go | 197 +++++++++++++++------- claimsync/storage/storage.go | 82 +++++---- claimsync/storage/storage_paged.go | 27 +-- claimsync/storage/storage_test.go | 134 ++++++++++++--- claimsync/types/claim_data.go | 4 +- l1infotreesync/is_block_finalized_test.go | 62 +++++-- l1infotreesync/l1infotreesync.go | 11 +- l1infotreesync/processor.go | 44 ++++- l2gersync/is_block_finalized_test.go | 39 ++++- l2gersync/processor.go | 49 ++++-- tree/types/types.go | 4 +- 13 files changed, 631 insertions(+), 187 deletions(-) diff --git a/bridgesync/bridgesync.go b/bridgesync/bridgesync.go index f842684ff..6317de867 100644 --- a/bridgesync/bridgesync.go +++ b/bridgesync/bridgesync.go @@ -414,16 +414,9 @@ 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 - } - blockHash, _, err := s.processor.getBlockHash(ctx, root.BlockNum) - if err != nil { - return nil, err - } - root.BlockHash = blockHash - return root, nil + // 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, diff --git a/bridgesync/is_block_finalized_test.go b/bridgesync/is_block_finalized_test.go index 947b9bbc3..a366b69f7 100644 --- a/bridgesync/is_block_finalized_test.go +++ b/bridgesync/is_block_finalized_test.go @@ -5,6 +5,7 @@ import ( "errors" "math/big" "path" + "reflect" "testing" "github.com/agglayer/aggkit/bridgesync/migrations" @@ -13,6 +14,7 @@ import ( "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" @@ -125,23 +127,143 @@ func TestBridgeSyncIsBlockFinalized(t *testing.T) { func TestBridgeQueriesFillBlockHash(t *testing.T) { ctx := context.Background() + hash1, hash2 := common.HexToHash("0x1"), common.HexToHash("0x2") s, _ := newBlockFinalityTestSyncer(t) - bridges, _, err := s.GetBridgesPaged(ctx, 1, 10, nil, nil, "") + // 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.Len(t, bridges, 3) - hashByBlock := map[uint64]common.Hash{} - for _, b := range bridges { - hashByBlock[b.BlockNum] = b.BlockHash + 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) } - // block 3 has no stored hash - require.Equal(t, map[uint64]common.Hash{ - 1: common.HexToHash("0x1"), - 2: common.HexToHash("0x2"), - 3: {}, - }, hashByBlock) - - bridge, err := s.GetBridgeByDepositCount(ctx, 1) - require.NoError(t, err) - require.Equal(t, common.HexToHash("0x2"), bridge.BlockHash) } diff --git a/bridgesync/processor.go b/bridgesync/processor.go index 603224bf7..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 ( @@ -125,10 +151,58 @@ type Bridge struct { 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: it is filled in by the read queries (zero if unknown), see fillBlockHashes + // 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 { amountStr := nilStr if b.Amount != nil { @@ -457,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, @@ -527,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) { @@ -535,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 } @@ -546,18 +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 } - if err = p.fillBlockHashes(ctx, bridges); err != nil { - return nil, 0, err - } - - return bridges, bridgesCount, nil + return toBridges(bridgeRows), bridgesCount, nil } // buildBridgesFilterClause builds the WHERE clause for the bridges table @@ -695,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{}, @@ -710,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 @@ -1465,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 { @@ -1474,19 +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 { - if err = p.fillBlockHashes(ctx, bridges[:1]); err != nil { - return nil, err - } - return bridges[0], nil + if len(bridgeRows) > 0 { + return toBridges(bridgeRows[:1])[0], nil } } return nil, db.ErrNotFound @@ -1534,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) @@ -1544,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", @@ -1559,12 +1629,32 @@ func (p *processor) GetBridgesByContent( } result = append(result, bridges...) } - if err := p.fillBlockHashes(ctx, result); err != nil { - return nil, err - } 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) { @@ -1585,25 +1675,6 @@ func (p *processor) getBlockHash(ctx context.Context, blockNum uint64) (hash com return common.HexToHash(hashStr.String), true, nil } -// fillBlockHashes sets Bridge.BlockHash from the block table. Bridges whose block has no stored -// hash keep the zero hash. -func (p *processor) fillBlockHashes(ctx context.Context, bridges []*Bridge) error { - hashes := make(map[uint64]common.Hash) - for _, bridge := range bridges { - hash, ok := hashes[bridge.BlockNum] - if !ok { - var err error - hash, _, err = p.getBlockHash(ctx, bridge.BlockNum) - if err != nil { - return err - } - hashes[bridge.BlockNum] = hash - } - bridge.BlockHash = hash - } - return 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/storage/storage.go b/claimsync/storage/storage.go index 8cd566d70..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,14 +400,11 @@ 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) } - if err = p.FillBlockHashes(ctx, tx, claims); err != nil { - return nil, fmt.Errorf("GetClaimsByGER: %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 @@ -386,26 +428,6 @@ func (s *claimStorage) GetBlockHash(ctx context.Context, tx dbtypes.Querier, return common.HexToHash(hashStr.String), true, nil } -// FillBlockHashes sets Claim.BlockHash from the block table. Claims whose block has no stored hash -// keep the zero hash. -func (s *claimStorage) FillBlockHashes(ctx context.Context, tx dbtypes.Querier, - claims []*claimsynctypes.Claim) error { - hashes := make(map[uint64]common.Hash) - for _, claim := range claims { - hash, ok := hashes[claim.BlockNum] - if !ok { - var err error - hash, _, err = s.GetBlockHash(ctx, tx, claim.BlockNum) - if err != nil { - return err - } - hashes[claim.BlockNum] = hash - } - claim.BlockHash = hash - } - return nil -} - // DeleteBlocksFrom deletes all blocks with num >= firstBlock and returns the count deleted. // Cascade constraints automatically remove associated claims, unset_claims and set_claims. func (s *claimStorage) DeleteBlocksFrom(ctx context.Context, tx dbtypes.Querier, firstBlock uint64) (int64, error) { diff --git a/claimsync/storage/storage_paged.go b/claimsync/storage/storage_paged.go index 16d1b58b1..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,17 +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 } - if err = p.FillBlockHashes(ctx, nil, claims); err != nil { - return nil, 0, fmt.Errorf("GetClaimsPaged: %w", 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 4734d63ba..41b9e3126 100644 --- a/claimsync/storage/storage_test.go +++ b/claimsync/storage/storage_test.go @@ -1437,37 +1437,127 @@ func TestGetClaimsPaged_Compact(t *testing.T) { } func TestClaimQueriesFillBlockHash(t *testing.T) { - s, _ := newTestStorage(t) + s, rawDB := newTestStorage(t) ctx := context.Background() - hash := common.HexToHash("0xb10c") + 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, hash)) + require.NoError(t, s.InsertBlock(ctx, tx, 1, hash1)) require.NoError(t, s.InsertBlock(ctx, tx, 2, common.Hash{})) - require.NoError(t, s.InsertClaim(ctx, tx, claimsynctypes.Claim{ - BlockNum: 1, GlobalIndex: big.NewInt(1), Amount: big.NewInt(1), Type: claimsynctypes.ClaimEvent, - })) - require.NoError(t, s.InsertClaim(ctx, tx, claimsynctypes.Claim{ - BlockNum: 2, GlobalIndex: big.NewInt(2), Amount: big.NewInt(1), Type: claimsynctypes.ClaimEvent, + _, 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()) - gotHash, found, err := s.GetBlockHash(ctx, nil, 1) - require.NoError(t, err) - require.True(t, found) - require.Equal(t, hash, gotHash) + 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 + } - _, found, err = s.GetBlockHash(ctx, nil, 99) - require.NoError(t, err) - require.False(t, found) + 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) - claims, count, err := s.GetClaimsPaged(ctx, 1, 10, nil, nil) - require.NoError(t, err) - require.Equal(t, 2, count) - hashByBlock := map[uint64]common.Hash{} - for _, claim := range claims { - hashByBlock[claim.BlockNum] = claim.BlockHash + _, 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) } - require.Equal(t, map[uint64]common.Hash{1: hash, 2: {}}, hashByBlock) } diff --git a/claimsync/types/claim_data.go b/claimsync/types/claim_data.go index 392aa5aa7..5c6698b37 100644 --- a/claimsync/types/claim_data.go +++ b/claimsync/types/claim_data.go @@ -43,7 +43,9 @@ type Claim struct { 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: it is filled in by the paged/by-GER read queries (zero if unknown) + // 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:"-"` } diff --git a/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go index 58c821938..495c7954a 100644 --- a/l1infotreesync/is_block_finalized_test.go +++ b/l1infotreesync/is_block_finalized_test.go @@ -3,7 +3,9 @@ package l1infotreesync import ( "encoding/json" "errors" + "math/big" "path" + "reflect" "testing" "github.com/agglayer/aggkit/db" @@ -170,26 +172,66 @@ func TestGetInfoByIndexFillsBlockHash(t *testing.T) { processor, err := newProcessor(path.Join(t.TempDir(), "l1infotreesyncGetInfoByIndex.sqlite")) require.NoError(t, err) hash := common.HexToHash("0xb10c") - require.NoError(t, processor.ProcessBlock(t.Context(), sync.Block{ - Num: 1, - Hash: hash, - Events: []any{Event{UpdateL1InfoTree: &UpdateL1InfoTree{ - MainnetExitRoot: common.HexToHash("beef"), - RollupExitRoot: common.HexToHash("5ca1e"), - ParentHash: common.HexToHash("1010101"), - Timestamp: 420, - }}}, - })) + 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 24d429779..85b346082 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -392,14 +392,9 @@ func (s *L1InfoTreeSync) GetInfoByIndex(ctx context.Context, index uint32) (*L1I if s.processor.isHalted() { return nil, sync.ErrInconsistentState } - leaf, err := s.processor.GetInfoByIndex(ctx, index) - if err != nil { - return leaf, err - } - if leaf.BlockHash, _, err = s.processor.getBlockHash(ctx, leaf.BlockNumber); err != nil { - return nil, err - } - return leaf, nil + // 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/processor.go b/l1infotreesync/processor.go index b1f212b54..742e0534f 100644 --- a/l1infotreesync/processor.go +++ b/l1infotreesync/processor.go @@ -154,10 +154,30 @@ type L1InfoTreeLeaf struct { 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 fills it in when serving a leaf (zero if unknown) + // 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 { return fmt.Sprintf("BlockNumber: %d, BlockPosition: %d, L1InfoTreeIndex: %d, PreviousBlockHash: %s, "+ "Timestamp: %d, MainnetExitRoot: %s, RollupExitRoot: %s, GlobalExitRoot: %s, Hash: %s", @@ -312,6 +332,28 @@ func (p *processor) GetInfoByIndex(ctx context.Context, index uint32) (*L1InfoTr 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 index 4d9a1ee9d..779be8480 100644 --- a/l2gersync/is_block_finalized_test.go +++ b/l2gersync/is_block_finalized_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path" + "reflect" "testing" "github.com/agglayer/aggkit/db" @@ -100,9 +101,45 @@ func TestL2GERSyncIsBlockFinalized(t *testing.T) { func TestGetFirstGERAfterL1InfoTreeIndexFillsBlockHash(t *testing.T) { s, _, hash := newBlockFinalityTestSyncer(t) + ctx := context.Background() - info, err := s.processor.GetFirstGERAfterL1InfoTreeIndex(context.Background(), 1) + 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/processor.go b/l2gersync/processor.go index 8b2b80da6..3cf3049ec 100644 --- a/l2gersync/processor.go +++ b/l2gersync/processor.go @@ -35,10 +35,28 @@ type GlobalExitRootInfo struct { 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 fills it in (zero if unknown) + // 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 type RemoveGEREvent struct { GlobalExitRoot ethcommon.Hash `meddler:"global_exit_root,hash"` @@ -231,24 +249,29 @@ 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) - } - if e.BlockHash, _, err = p.getBlockHash(ctx, e.BlockNum); err != nil { - return e, 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 diff --git a/tree/types/types.go b/tree/types/types.go index acbe36ed1..7d7732b62 100644 --- a/tree/types/types.go +++ b/tree/types/types.go @@ -22,7 +22,9 @@ type Root struct { 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: it is only filled in by the syncers that serve the root (zero if unknown) + // 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:"-"` } From d6164a17b17cb4dce93b66a6f11d8014aa3a5436 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:02 +0100 Subject: [PATCH 11/12] fix(reorgdetector): ask the tracker before the stored hash and fail closed on unknown subscribers IsBlockFinalized read the syncer's stored hash first and asked the reorg detector afterwards. On a reorg the detector notifies the syncer (which deletes the orphaned blocks) and only then stops tracking them, so a full reorg completing between the two reads made an orphaned block look finalized. Add reorgdetector.IsBlockFinalized, shared by BridgeSync, ClaimSync, L2GERSync and L1InfoTreeSync (legacy), which asks the tracker first and compares the stored hash afterwards. GetTrackedBlockByBlockNumber returned db.ErrNotFound both for an untracked block and for an unknown subscriber id, so a wrong id reported every block as finalized. It now returns the new ErrSubscriberNotFound for the latter, and IsBlockFinalized turns it into an error. Refs #1915 Co-Authored-By: Claude Sonnet 5.5 --- bridgesync/bridgesync.go | 35 +---- bridgesync/is_block_finalized_test.go | 28 +++- claimsync/claimsync.go | 28 +--- claimsync/is_block_finalized_test.go | 32 ++-- l1infotreesync/is_block_finalized_test.go | 31 +++- l1infotreesync/l1infotreesync.go | 21 +-- l2gersync/is_block_finalized_test.go | 32 +++- l2gersync/l2_ger_syncer.go | 23 +-- reorgdetector/is_block_finalized.go | 75 +++++++++ reorgdetector/is_block_finalized_test.go | 178 ++++++++++++++++++++++ reorgdetector/reorgdetector.go | 11 +- reorgdetector/reorgdetector_test.go | 3 +- 12 files changed, 379 insertions(+), 118 deletions(-) create mode 100644 reorgdetector/is_block_finalized.go create mode 100644 reorgdetector/is_block_finalized_test.go diff --git a/bridgesync/bridgesync.go b/bridgesync/bridgesync.go index 6317de867..c95b094e0 100644 --- a/bridgesync/bridgesync.go +++ b/bridgesync/bridgesync.go @@ -11,7 +11,6 @@ import ( "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" - "github.com/agglayer/aggkit/db" "github.com/agglayer/aggkit/db/compatibility" "github.com/agglayer/aggkit/log" "github.com/agglayer/aggkit/reorgdetector" @@ -142,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 { @@ -192,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 @@ -215,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 @@ -254,7 +255,7 @@ func newBridgeSync( downloader: downloader, originNetwork: networkID, reorgDetector: rd, - reorgDetectorID: syncerID.String(), + reorgDetectorID: reorgDetectorID, ethClient: ethClient, agglayerBridge: agglayerBridge, cfg: cfg, @@ -431,28 +432,8 @@ func (s *BridgeSync) IsBlockFinalized(ctx context.Context, blockNum uint64, if s.processor.isHalted() { return false, false, sync.ErrInconsistentState } - if blockHash == (common.Hash{}) { - return false, false, nil - } - - storedHash, stored, err := s.processor.getBlockHash(ctx, blockNum) - if err != nil { - return false, false, err - } - if !stored || storedHash != blockHash { - return false, false, nil - } - - _, err = s.reorgDetector.GetTrackedBlockByBlockNumber(s.reorgDetectorID, blockNum) - switch { - case err == nil: - // still tracked: its hash was not confirmed against a finalized block yet - return false, true, nil - case errors.Is(err, db.ErrNotFound): - return true, true, nil - default: - return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) - } + 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 index a366b69f7..81d93158e 100644 --- a/bridgesync/is_block_finalized_test.go +++ b/bridgesync/is_block_finalized_test.go @@ -73,8 +73,9 @@ func TestBridgeSyncIsBlockFinalized(t *testing.T) { require.False(t, finalized) }) - t.Run("hash differs from the stored one: unknown", func(t *testing.T) { - s, _ := newBlockFinalityTestSyncer(t) + 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) @@ -83,7 +84,7 @@ func TestBridgeSyncIsBlockFinalized(t *testing.T) { require.False(t, finalized) }) - t.Run("zero hash: unknown", func(t *testing.T) { + 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{}) @@ -91,22 +92,35 @@ func TestBridgeSyncIsBlockFinalized(t *testing.T) { require.False(t, found) }) - t.Run("block without stored hash: unknown", func(t *testing.T) { - s, _ := newBlockFinalityTestSyncer(t) + 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("block not stored: unknown", func(t *testing.T) { - s, _ := newBlockFinalityTestSyncer(t) + 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")) diff --git a/claimsync/claimsync.go b/claimsync/claimsync.go index e9a664da2..555c3cc9e 100644 --- a/claimsync/claimsync.go +++ b/claimsync/claimsync.go @@ -13,9 +13,9 @@ import ( claimsyncStorage "github.com/agglayer/aggkit/claimsync/storage" claimsynctypes "github.com/agglayer/aggkit/claimsync/types" aggkitcommon "github.com/agglayer/aggkit/common" - "github.com/agglayer/aggkit/db" "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" @@ -381,26 +381,8 @@ func (c *ClaimSync) GetClaimsByGER(ctx context.Context, globalExitRoot common.Ha // 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) { - if blockHash == (common.Hash{}) { - return false, false, nil - } - - storedHash, stored, err := c.reader.GetBlockHash(ctx, nil, blockNum) - if err != nil { - return false, false, err - } - if !stored || storedHash != blockHash { - return false, false, nil - } - - _, err = c.reorgDetector.GetTrackedBlockByBlockNumber(c.syncerID.String(), blockNum) - switch { - case err == nil: - // still tracked: its hash was not confirmed against a finalized block yet - return false, true, nil - case errors.Is(err, db.ErrNotFound): - return true, true, nil - default: - return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) - } + 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 index a6771385e..cbcadd936 100644 --- a/claimsync/is_block_finalized_test.go +++ b/claimsync/is_block_finalized_test.go @@ -32,8 +32,8 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { t.Run("stored and not tracked anymore: finalized", func(t *testing.T) { s, reader, rd := newSyncer(t) - reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) 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) @@ -42,8 +42,7 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { }) t.Run("still tracked by the reorg detector: not finalized", func(t *testing.T) { - s, reader, rd := newSyncer(t) - reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + s, _, rd := newSyncer(t) rd.EXPECT().GetTrackedBlockByBlockNumber(trackerID, uint64(10)). Return(&reorgdetector.Header{Num: 10, Hash: hash}, nil) @@ -53,8 +52,9 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { require.False(t, finalized) }) - t.Run("hash differs from the stored one: unknown", func(t *testing.T) { - s, reader, _ := newSyncer(t) + 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) @@ -63,8 +63,9 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { require.False(t, finalized) }) - t.Run("block without stored hash: unknown", func(t *testing.T) { - s, reader, _ := newSyncer(t) + 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) @@ -81,7 +82,8 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { }) t.Run("storage error", func(t *testing.T) { - s, reader, _ := newSyncer(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) @@ -90,12 +92,22 @@ func TestClaimSyncIsBlockFinalized(t *testing.T) { }) t.Run("reorg detector error", func(t *testing.T) { - s, reader, rd := newSyncer(t) - reader.EXPECT().GetBlockHash(mock.Anything, nil, uint64(10)).Return(hash, true, nil) + 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/l1infotreesync/is_block_finalized_test.go b/l1infotreesync/is_block_finalized_test.go index 495c7954a..74f7780ac 100644 --- a/l1infotreesync/is_block_finalized_test.go +++ b/l1infotreesync/is_block_finalized_test.go @@ -129,27 +129,48 @@ func TestIsBlockFinalizedLegacy(t *testing.T) { require.False(t, finalized) }) - t.Run("hash differs from the stored one: unknown", func(t *testing.T) { - syncer, _, _ := newLegacyTestSyncer(t) + 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, block without hash or not stored: unknown", func(t *testing.T) { - syncer, _, hash := newLegacyTestSyncer(t) + 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 - }{{10, common.Hash{}}, {11, hash}, {99, 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")) diff --git a/l1infotreesync/l1infotreesync.go b/l1infotreesync/l1infotreesync.go index 85b346082..f2af9fe7e 100644 --- a/l1infotreesync/l1infotreesync.go +++ b/l1infotreesync/l1infotreesync.go @@ -14,6 +14,7 @@ import ( "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" @@ -279,24 +280,8 @@ func (d *L1InfoTreeSync) isBlockFinalizedLegacy(ctx context.Context, blockNum ui return false, false, sync.ErrInconsistentState } - storedHash, stored, err := d.processor.getBlockHash(ctx, blockNum) - if err != nil { - return false, false, err - } - if !stored || storedHash != blockHash { - return false, false, nil - } - - _, err = d.reorgDetector.GetTrackedBlockByBlockNumber(syncerID, blockNum) - switch { - case err == nil: - // still tracked: its hash was not confirmed against a finalized block yet - return false, true, nil - case errors.Is(err, db.ErrNotFound): - return true, true, nil - default: - return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) - } + 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). diff --git a/l2gersync/is_block_finalized_test.go b/l2gersync/is_block_finalized_test.go index 779be8480..48afc31d9 100644 --- a/l2gersync/is_block_finalized_test.go +++ b/l2gersync/is_block_finalized_test.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -59,21 +60,31 @@ func TestL2GERSyncIsBlockFinalized(t *testing.T) { require.False(t, finalized) }) - t.Run("hash differs from the stored one: unknown", func(t *testing.T) { - s, _, _ := newBlockFinalityTestSyncer(t) + 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, block without hash or not stored: unknown", func(t *testing.T) { - s, _, hash := newBlockFinalityTestSyncer(t) + 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 - }{{10, common.Hash{}}, {11, hash}, {99, hash}} { + }{{11, hash}, {99, hash}} { _, found, err := s.IsBlockFinalized(ctx, tc.blockNum, tc.hash) require.NoError(t, err) require.False(t, found) @@ -89,6 +100,17 @@ func TestL2GERSyncIsBlockFinalized(t *testing.T) { 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")) diff --git a/l2gersync/l2_ger_syncer.go b/l2gersync/l2_ger_syncer.go index ffe8dbf08..6ff4d1eae 100644 --- a/l2gersync/l2_ger_syncer.go +++ b/l2gersync/l2_ger_syncer.go @@ -2,16 +2,15 @@ package l2gersync import ( "context" - "errors" "fmt" "strings" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerger" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayergerl2" - "github.com/agglayer/aggkit/db" "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" @@ -259,22 +258,6 @@ func (s *L2GERSync) IsBlockFinalized(ctx context.Context, blockNum uint64, return false, false, nil } - storedHash, stored, err := s.processor.getBlockHash(ctx, blockNum) - if err != nil { - return false, false, err - } - if !stored || storedHash != blockHash { - return false, false, nil - } - - _, err = s.reorgDetector.GetTrackedBlockByBlockNumber(reorgDetectorID, blockNum) - switch { - case err == nil: - // still tracked: its hash was not confirmed against a finalized block yet - return false, true, nil - case errors.Is(err, db.ErrNotFound): - return true, true, nil - default: - return false, false, fmt.Errorf("failed to check if block %d is tracked: %w", blockNum, err) - } + return reorgdetector.IsBlockFinalized(ctx, s.reorgDetector, reorgDetectorID, blockNum, blockHash, + s.processor.getBlockHash) } 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))} From 44e0740ce18fd30e088bff514e4c1162c21fca9e Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:18 +0100 Subject: [PATCH 12/12] docs(bridgeservice): clarify block_finalized omission and the api_revision bump rule Reword the BlockFinalized comment on the response types ("omitted when the syncer that served the item cannot determine it"). In API_REVISIONS.md, drop the exemption for optional fields from the bump rule (it contradicted the rule of the issue and the bridge tracker precedent, where adding an optional field bumped the revision), and state in the revision 1 entry that instances without api_revision predate the contract and have none of its fields, block_finalized included. Refs #1915 Refs #1920 Co-Authored-By: Claude Sonnet 5.5 --- bridgeservice/docs/API_REVISIONS.md | 20 +++++++++++-------- bridgeservice/docs/docs.go | 10 +++++----- bridgeservice/docs/swagger.json | 10 +++++----- bridgeservice/docs/swagger.yaml | 12 +++++------ bridgeservice/revision.go | 3 ++- bridgeservice/types/types.go | 12 +++++------ .../swagger/bridge_service/swagger.json | 10 +++++----- 7 files changed, 41 insertions(+), 36 deletions(-) diff --git a/bridgeservice/docs/API_REVISIONS.md b/bridgeservice/docs/API_REVISIONS.md index 3aea3eca1..499cf9693 100644 --- a/bridgeservice/docs/API_REVISIONS.md +++ b/bridgeservice/docs/API_REVISIONS.md @@ -21,8 +21,8 @@ For the full description of every endpoint see the swagger files in this directo Bump `CurrentAPIRevision` by one, in the same PR, whenever a change could break an existing client: -- a response field added, removed or renamed (a new field that clients can safely ignore is **not** a bump, - unless it is the only way to tell a behaviour change); +- 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; @@ -43,13 +43,17 @@ do about it. ### Revision 1 -The contract of the release that introduced `api_revision`; there is nothing to compare it with. +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: -Worth knowing for clients that have to deal with older instances (those that don't return `api_revision` -at all): +- 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 can't tell, so an absent value - must be read as "unknown", not as `false`. -- `GET /` and `GET /health` return `api_revision`. + `/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 54744c283..759ada249 100644 --- a/bridgeservice/docs/docs.go +++ b/bridgeservice/docs/docs.go @@ -1548,7 +1548,7 @@ const docTemplate = `{ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1718,7 +1718,7 @@ const docTemplate = `{ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1952,7 +1952,7 @@ const docTemplate = `{ "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", + "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 }, @@ -2294,7 +2294,7 @@ const docTemplate = `{ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -2369,7 +2369,7 @@ const docTemplate = `{ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, diff --git a/bridgeservice/docs/swagger.json b/bridgeservice/docs/swagger.json index 1666bc346..c84ddf22c 100644 --- a/bridgeservice/docs/swagger.json +++ b/bridgeservice/docs/swagger.json @@ -1541,7 +1541,7 @@ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1711,7 +1711,7 @@ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1945,7 +1945,7 @@ "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", + "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 }, @@ -2287,7 +2287,7 @@ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -2362,7 +2362,7 @@ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, diff --git a/bridgeservice/docs/swagger.yaml b/bridgeservice/docs/swagger.yaml index 876b9cb0a..24ca5ec43 100644 --- a/bridgeservice/docs/swagger.yaml +++ b/bridgeservice/docs/swagger.yaml @@ -237,7 +237,7 @@ definitions: block_finalized: description: |- BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. - It is omitted when finality could not be determined (e.g. older versions or finalized block unavailable). + It is omitted when the syncer that served the item cannot determine it. example: true type: boolean block_num: @@ -371,7 +371,7 @@ definitions: block_finalized: description: |- BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. - It is omitted when finality could not be determined (e.g. older versions or finalized block unavailable). + It is omitted when the syncer that served the item cannot determine it. example: true type: boolean block_num: @@ -563,8 +563,8 @@ definitions: 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 finality could not be determined (e.g. L2 chains synced by - l2gersync in Legacy mode, where the injection block is only approximate). + 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: @@ -853,7 +853,7 @@ definitions: block_finalized: description: |- BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. - It is omitted when finality could not be determined (e.g. older versions or finalized block unavailable). + It is omitted when the syncer that served the item cannot determine it. example: true type: boolean block_num: @@ -915,7 +915,7 @@ definitions: block_finalized: description: |- BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network. - It is omitted when finality could not be determined (e.g. older versions or finalized block unavailable). + It is omitted when the syncer that served the item cannot determine it. example: true type: boolean block_hash: diff --git a/bridgeservice/revision.go b/bridgeservice/revision.go index de3c4dd55..063a10024 100644 --- a/bridgeservice/revision.go +++ b/bridgeservice/revision.go @@ -15,6 +15,7 @@ package bridgeservice // with the bump. // // Revision history: -// - 1: first revision, the contract as of the release that introduced api_revision. Earlier changes +// - 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/types/types.go b/bridgeservice/types/types.go index a9a3b1a65..5bb2bb8f1 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -175,7 +175,7 @@ type BridgeResponse struct { 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 finality could not be determined (e.g. older versions or finalized block unavailable). + // It is omitted when the syncer that served the item cannot determine it. BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } @@ -244,7 +244,7 @@ type ClaimResponse struct { 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 finality could not be determined (e.g. older versions or finalized block unavailable). + // It is omitted when the syncer that served the item cannot determine it. BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } @@ -398,8 +398,8 @@ type L1InfoTreeLeafResponse struct { // 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 finality could not be determined (e.g. L2 chains synced by - // l2gersync in Legacy mode, where the injection block is only approximate). + // 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"` } @@ -582,7 +582,7 @@ type SettlementResponse struct { 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 finality could not be determined (e.g. older versions or finalized block unavailable). + // It is omitted when the syncer that served the item cannot determine it. BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } @@ -674,7 +674,7 @@ type RootByLERResponse struct { 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 finality could not be determined (e.g. older versions or finalized block unavailable). + // It is omitted when the syncer that served the item cannot determine it. BlockFinalized *bool `json:"block_finalized,omitempty" example:"true"` } diff --git a/docs/assets/swagger/bridge_service/swagger.json b/docs/assets/swagger/bridge_service/swagger.json index 1666bc346..c84ddf22c 100644 --- a/docs/assets/swagger/bridge_service/swagger.json +++ b/docs/assets/swagger/bridge_service/swagger.json @@ -1541,7 +1541,7 @@ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1711,7 +1711,7 @@ "example": "1000000000000000000" }, "block_finalized": { - "description": "BlockFinalized reports whether the block of this item is finalized (reorg-safe) on its network.\nIt is omitted when finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -1945,7 +1945,7 @@ "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 finality could not be determined (e.g. L2 chains synced by\nl2gersync in Legacy mode, where the injection block is only approximate).", + "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 }, @@ -2287,7 +2287,7 @@ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 }, @@ -2362,7 +2362,7 @@ "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 finality could not be determined (e.g. older versions or finalized block unavailable).", + "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 },