diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c0c4dc287 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Version control +.git/ +.gitignore + +# Sensitive / secret files +.env +.env.* +*.pem +*.key +*.p12 +*.pfx +*.crt +*.cer + +# Editor and OS artifacts +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Build artifacts (not needed — built inside the container) +/out/ + +# Test fixtures and E2E env (may contain keys/configs) +test/e2e/envs/ + +# Local developer configs +*.local diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..461f8295e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to aggkit in Docker (dlv)", + "type": "go", + "request": "attach", + "mode": "remote", + "host": "127.0.0.1", + "port": 40000, + "apiVersion": 2, + "showLog": true + } + ] +} \ No newline at end of file diff --git a/Dockerfile.debug b/Dockerfile.debug new file mode 100644 index 000000000..39d7ccdea --- /dev/null +++ b/Dockerfile.debug @@ -0,0 +1,47 @@ +# ================================ +# STAGE 1: Build debug binary + dlv +# ================================ +FROM golang:1.25.7-alpine AS builder + +RUN apk add --no-cache gcc musl-dev make sqlite-dev git + +WORKDIR /home/aigent/repos/aggkit + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Install delve +RUN go install github.com/go-delve/delve/cmd/dlv@latest + +# Build debug binary (disable inlining and optimizations for proper debugging) +RUN CGO_ENABLED=1 go build \ + -gcflags="all=-N -l" \ + -o /out/aggkit \ + ./cmd + +# ================================ +# STAGE 2: Debug runtime image +# ================================ +FROM alpine:3.22 + +RUN apk add --no-cache sqlite-libs ca-certificates \ + && addgroup -S appgroup \ + && adduser -S appuser -G appgroup + +COPY --from=builder /go/bin/dlv /usr/local/bin/dlv +COPY --from=builder /out/aggkit /usr/local/bin/aggkit + +# Run as non-root. Note: the container must be started with +# --cap-add=SYS_PTRACE so that delve can trace the target process. +USER appuser + +EXPOSE 5576/tcp +EXPOSE 40000/tcp + +# Default: run aggkit under delve headless. App args are provided by +# the compose file's `command:` block (appended after the `--` separator). +CMD ["dlv", "exec", "/usr/local/bin/aggkit", \ + "--headless", "--listen=:40000", "--api-version=2", \ + "--accept-multiclient", "--log"] diff --git a/Makefile b/Makefile index 81d8a88db..b6e0a796b 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,10 @@ build-docker-ci: ## Builds a docker image with the aggkit binary for CI (include build-docker-nc: ## Builds a docker image with the aggkit binary - but without build cache docker build --no-cache=true -t aggkit:local -f ./Dockerfile . +.PHONY: build-docker-debug +build-docker-debug: ## Builds a debug docker image (dlv headless on :40000, no optimizations) + docker build -t aggkit:local-debug -f ./Dockerfile.debug . + .PHONY: test-unit test-unit: ## Runs the unit tests trap '$(STOP)' EXIT; MallocNanoZone=0 go test -count=1 -short -race -p 1 -covermode=atomic -coverprofile=coverage.out -timeout 15m ./... diff --git a/agglayer/types/types.go b/agglayer/types/types.go index 4b03d3c24..258ecdaea 100644 --- a/agglayer/types/types.go +++ b/agglayer/types/types.go @@ -713,7 +713,13 @@ func (b *BridgeExit) UnmarshalJSON(data []byte) error { b.DestinationAddress = aux.DestinationAddress var ok bool if !strings.Contains(aux.Amount, nilStr) { - b.Amount, ok = new(big.Int).SetString(aux.Amount, base10) + base := base10 + amountStr := aux.Amount + if strings.HasPrefix(amountStr, "0x") || strings.HasPrefix(amountStr, "0X") { + base = 16 + amountStr = amountStr[2:] + } + b.Amount, ok = new(big.Int).SetString(amountStr, base) if !ok { return fmt.Errorf("failed to convert amount to big.Int: %s", aux.Amount) } diff --git a/agglayer/types/types_test.go b/agglayer/types/types_test.go index e57ea7eb5..1745dd91b 100644 --- a/agglayer/types/types_test.go +++ b/agglayer/types/types_test.go @@ -1897,3 +1897,50 @@ func createTreeDBForTest(t *testing.T) *sql.DB { require.NoError(t, err) return treeDB } + +// TestBridgeExit_UnmarshalJSON_HexAmount verifies that BridgeExit correctly deserializes +// amounts with a "0x"-prefixed hex string (added in this branch). +func TestBridgeExit_UnmarshalJSON_HexAmount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amountJSON string + expectedAmount *big.Int + }{ + { + name: "decimal amount", + amountJSON: `"1000"`, + expectedAmount: big.NewInt(1000), + }, + { + name: "0x-prefixed hex amount", + amountJSON: `"0x3e8"`, + expectedAmount: big.NewInt(1000), + }, + { + name: "0X-prefixed hex amount (uppercase)", + amountJSON: `"0X3E8"`, + expectedAmount: big.NewInt(1000), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + jsonData := fmt.Sprintf(`{ + "leaf_type": "Transfer", + "token_info": {"origin_network": 1, "origin_token_address": "0x0000000000000000000000000000000000000001"}, + "dest_network": 2, + "dest_address": "0x0000000000000000000000000000000000000002", + "amount": %s, + "metadata": null + }`, tc.amountJSON) + + var be BridgeExit + require.NoError(t, json.Unmarshal([]byte(jsonData), &be)) + require.Equal(t, tc.expectedAmount, be.Amount) + }) + } +} diff --git a/aggsender/aggsender.go b/aggsender/aggsender.go index 6370bf9df..9bf54bff6 100644 --- a/aggsender/aggsender.go +++ b/aggsender/aggsender.go @@ -133,6 +133,18 @@ func newAggsender( return nil, err } + aggchainFEPCaller, err := query.NewAggchainFEPQuerier(logger, cfg.Mode, + cfg.SovereignRollupAddr, l1Client) + if err != nil { + return nil, fmt.Errorf("error creating aggchain FEP caller: %w", err) + } + + certQuerier := query.NewCertificateQuerier( + l2Syncer, + aggchainFEPCaller, + aggLayerClient, + ) + flowManager, err := flows.NewBuilderFlow( ctx, cfg, @@ -144,6 +156,7 @@ func newAggsender( l2Syncer, rollupDataQuerier, committeeQuerier, + certQuerier, ) if err != nil { return nil, fmt.Errorf("error creating flow manager: %w", err) @@ -161,18 +174,6 @@ func newAggsender( compatibility.NewKeyValueToCompatibilityStorage[db.RuntimeData](storage, aggkitcommon.AGGSENDER), ) - aggchainFEPCaller, err := query.NewAggchainFEPQuerier(logger, cfg.Mode, - cfg.SovereignRollupAddr, l1Client) - if err != nil { - return nil, fmt.Errorf("error creating aggchain FEP caller: %w", err) - } - - certQuerier := query.NewCertificateQuerier( - l2Syncer, - aggchainFEPCaller, - aggLayerClient, - ) - verifierFlow, err := flows.NewLocalVerifier( ctx, cfg, @@ -252,8 +253,13 @@ func (a *AggSender) GetRPCServices() []jRPC.Service { logger := log.WithFields("module", "aggsender-rpc") return []jRPC.Service{ { - Name: "aggsender", - Service: aggsenderrpc.NewAggsenderRPC(logger, a.storage, a), + Name: "aggsender", + Service: aggsenderrpc.NewAggsenderRPC( + logger, a.storage, a, + a.cfg.EnableDebugSendCertificate, + a.cfg.DebugSendCertificateAuthAddress, + a.aggLayerClient, + ), }, } } @@ -264,6 +270,11 @@ func (a *AggSender) Start(ctx context.Context) { metrics.Register() a.status.Start(time.Now().UTC()) + if a.cfg.EnableDebugSendCertificate { + a.log.Warn("Debug send certificate endpoint enabled — aggsender certificate sending is DISABLED") + return + } + a.checkDBCompatibility(ctx) a.certStatusChecker.CheckInitialStatus(ctx, a.cfg.DelayBetweenRetries.Duration, a.status) if err := a.flow.CheckInitialStatus(ctx); err != nil { diff --git a/aggsender/aggsender_test.go b/aggsender/aggsender_test.go index 7a670114d..b04d80167 100644 --- a/aggsender/aggsender_test.go +++ b/aggsender/aggsender_test.go @@ -74,7 +74,8 @@ func TestConfigString(t *testing.T) { "RetriesToBuildAndSendCertificate: RetryPolicyConfig{Mode: , Config: RetryDelaysConfig{Delays: [], MaxRetries: NO RETRIES}}\n"+ "StorageRetainCertificatesPolicy: retain all certificates, keep history: false\n"+ "BlockFinalityForL1InfoTree: FinalizedBlock\n"+ - "TriggerCertMode: Auto\nTriggerEpochBased: EpochNotificationPercentage: 50\n", + "TriggerCertMode: Auto\nTriggerEpochBased: EpochNotificationPercentage: 50\n"+ + "EnableDebugSendCertificate: false\n", config.AgglayerClient.String()) require.Equal(t, expected, config.String()) @@ -250,7 +251,7 @@ func TestSendCertificate_NoClaims(t *testing.T) { localValidator: mockLocalValidator, flow: flows.NewPPBuilderFlow(logger, flows.NewBaseFlow(logger, mockL2BridgeQuerier, mockStorage, - mockL1Querier, mockLERQuerier, flows.NewBaseFlowConfigDefault()), + mockL1Querier, mockLERQuerier, nil, flows.NewBaseFlowConfigDefault()), mockStorage, mockL1Querier, mockL2BridgeQuerier, signer, true, 0), } @@ -862,7 +863,7 @@ func newAggsenderTestData(t *testing.T, creationFlags testDataFlags) *aggsenderT }, flow: flows.NewPPBuilderFlow(logger, flows.NewBaseFlow(logger, l2BridgeQuerier, storage, - l1InfoTreeQuerierMock, lerQuerier, flows.NewBaseFlowConfigDefault()), + l1InfoTreeQuerierMock, lerQuerier, nil, flows.NewBaseFlowConfigDefault()), storage, l1InfoTreeQuerierMock, l2BridgeQuerier, signer, true, 0), } var flowMock *mocks.AggsenderBuilderFlow diff --git a/aggsender/config/config.go b/aggsender/config/config.go index 9b18bf4a1..5170c61e5 100644 --- a/aggsender/config/config.go +++ b/aggsender/config/config.go @@ -150,6 +150,13 @@ type Config struct { TriggerEpochBased TriggerEpochBasedConfig `mapstructure:"TriggerEpochBased"` // TriggerASAP is the configuration for the ASAP trigger mode (TriggerCertMode==ASAP) TriggerASAP TriggerASAPConfig `mapstructure:"TriggerASAP"` + // EnableDebugSendCertificate enables the debug RPC endpoint for sending arbitrary certificates. + // When true, the aggsender's normal certificate-sending loop is disabled. + // Default false. NEVER enable in production. + EnableDebugSendCertificate bool `mapstructure:"EnableDebugSendCertificate"` + // DebugSendCertificateAuthAddress is the Ethereum address authorized to sign debug send requests. + // Only used when EnableDebugSendCertificate is true. + DebugSendCertificateAuthAddress ethCommon.Address `mapstructure:"DebugSendCertificateAuthAddress"` } func (c Config) CheckCertConfigBriefString() string { @@ -174,7 +181,8 @@ func (c Config) String() string { "StorageRetainCertificatesPolicy: " + c.StorageRetainCertificatesPolicy.String() + "\n" + "BlockFinalityForL1InfoTree: " + c.BlockFinalityForL1InfoTree.String() + "\n" + "TriggerCertMode: " + c.TriggerCertMode.String() + "\n" + - "TriggerEpochBased: " + c.TriggerEpochBased.String() + "\n" + "TriggerEpochBased: " + c.TriggerEpochBased.String() + "\n" + + "EnableDebugSendCertificate: " + fmt.Sprintf("%t", c.EnableDebugSendCertificate) + "\n" } // Validate checks if the configuration is valid @@ -200,5 +208,8 @@ func (c Config) Validate() error { if err := c.TriggerCertMode.Validate(); err != nil { return fmt.Errorf("invalid TriggerCertMode config: %w", err) } + if c.EnableDebugSendCertificate && c.DebugSendCertificateAuthAddress == (ethCommon.Address{}) { + return fmt.Errorf("DebugSendCertificateAuthAddress must be set when EnableDebugSendCertificate is enabled") + } return nil } diff --git a/aggsender/db/aggsender_db_storage.go b/aggsender/db/aggsender_db_storage.go index 8e5f7e6c4..df5f95b36 100644 --- a/aggsender/db/aggsender_db_storage.go +++ b/aggsender/db/aggsender_db_storage.go @@ -100,6 +100,8 @@ type AggSenderStorage interface { SaveOrUpdateCertificate(ctx context.Context, certificate types.Certificate) error // GetLastSettledCertificate returns the last settled certificate from the storage GetLastSettledCertificate() (*types.CertificateHeader, error) + // GetCertificateBridgeExits returns the bridge exits for the signed certificate at the given height + GetCertificateBridgeExits(height uint64) ([]*agglayertypes.BridgeExit, error) } var _ AggSenderStorage = (*AggSenderSQLStorage)(nil) @@ -286,6 +288,23 @@ func (a *AggSenderSQLStorage) GetLastSettledCertificate() (*types.CertificateHea return &certificateHeader, nil } +// GetCertificateBridgeExits returns the bridge exits for the signed certificate at the given height. +// Returns nil if no certificate exists at that height or the certificate has no signed certificate data. +func (a *AggSenderSQLStorage) GetCertificateBridgeExits(height uint64) ([]*agglayertypes.BridgeExit, error) { + cert, err := a.GetCertificateByHeight(height) + if err != nil { + return nil, err + } + if cert == nil || cert.SignedCertificate == nil { + return nil, nil + } + var agglayerCert agglayertypes.Certificate + if err := json.Unmarshal([]byte(*cert.SignedCertificate), &agglayerCert); err != nil { + return nil, fmt.Errorf("GetCertificateBridgeExits: failed to unmarshal certificate at height %d: %w", height, err) + } + return agglayerCert.BridgeExits, nil +} + // SaveOrUpdateCertificate saves the certificate in the storage // It will insert a new certificate or update the existing one if it has the same height and certificate ID func (a *AggSenderSQLStorage) SaveOrUpdateCertificate(ctx context.Context, certificate types.Certificate) error { diff --git a/aggsender/db/aggsender_db_storage_test.go b/aggsender/db/aggsender_db_storage_test.go index c4f49ec64..1677689ac 100644 --- a/aggsender/db/aggsender_db_storage_test.go +++ b/aggsender/db/aggsender_db_storage_test.go @@ -1450,3 +1450,78 @@ func Test_deleteCertificate(t *testing.T) { require.ErrorIs(t, err, db.ErrNotFound) }) } + +func TestGetCertificateBridgeExits(t *testing.T) { + ctx := context.Background() + dbPath := path.Join(t.TempDir(), "aggsenderTest_BridgeExits.sqlite") + cfg := AggSenderSQLStorageConfig{ + DBPath: dbPath, + CertificatesDir: filepath.Join(filepath.Dir(dbPath), "certificates"), + RetainCertificatesPolicy: *NewStorageRetainCertificatesPolicyDefault(), + } + storage, err := NewAggSenderSQLStorage(log.WithFields("aggsender-db"), cfg) + require.NoError(t, err) + + bridgeExits := []*agglayertypes.BridgeExit{ + { + LeafType: 0, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xdeadbeef"), + Amount: big.NewInt(1000), + }, + } + agglayerCert := agglayertypes.Certificate{ + NetworkID: 1, + Height: 100, + BridgeExits: bridgeExits, + } + signedCertJSON, err := json.Marshal(agglayerCert) + require.NoError(t, err) + signedCertStr := string(signedCertJSON) + + t.Run("found certificate with bridge exits", func(t *testing.T) { + cert := types.Certificate{ + Header: &types.CertificateHeader{ + Height: 100, + CertificateID: common.HexToHash("0xabc"), + Status: agglayertypes.Settled, + CreatedAt: 1000, + UpdatedAt: 1000, + }, + SignedCertificate: &signedCertStr, + } + require.NoError(t, storage.SaveLastSentCertificate(ctx, cert)) + + exits, err := storage.GetCertificateBridgeExits(100) + require.NoError(t, err) + require.Len(t, exits, 1) + require.Equal(t, bridgeExits[0].DestinationNetwork, exits[0].DestinationNetwork) + require.Equal(t, bridgeExits[0].DestinationAddress, exits[0].DestinationAddress) + require.NoError(t, storage.clean()) + }) + + t.Run("certificate not found returns nil", func(t *testing.T) { + exits, err := storage.GetCertificateBridgeExits(999) + require.ErrorIs(t, err, db.ErrNotFound) + require.Nil(t, exits) + }) + + t.Run("certificate with nil signed certificate returns nil", func(t *testing.T) { + cert := types.Certificate{ + Header: &types.CertificateHeader{ + Height: 101, + CertificateID: common.HexToHash("0xdef"), + Status: agglayertypes.Pending, + CreatedAt: 1000, + UpdatedAt: 1000, + }, + SignedCertificate: nil, + } + require.NoError(t, storage.SaveLastSentCertificate(ctx, cert)) + + exits, err := storage.GetCertificateBridgeExits(101) + require.NoError(t, err) + require.Nil(t, exits) + require.NoError(t, storage.clean()) + }) +} diff --git a/aggsender/flows/builder_flow_aggchain_prover_test.go b/aggsender/flows/builder_flow_aggchain_prover_test.go index 84e8252a2..04047f52e 100644 --- a/aggsender/flows/builder_flow_aggchain_prover_test.go +++ b/aggsender/flows/builder_flow_aggchain_prover_test.go @@ -360,6 +360,7 @@ func Test_AggchainProverFlow_GetCertificateBuildParams(t *testing.T) { mockStorage, mockL1InfoTreeDataQuerier, mockLERQuerier, + nil, NewBaseFlowConfig(0, 0, false, true)) flowBase.timeNowFunc = timeNowUTCForTest aggchainFlow := NewAggchainProverBuilderFlow( @@ -479,6 +480,7 @@ func Test_AggchainProverFlow_getLastProvenBlock(t *testing.T) { nil, // sotrage nil, // l1InfoTreeDataQuerier, nil, // lerQuerier + nil, // certQuerier NewBaseFlowConfig(0, tc.startL2Block, false, true), ) flow := NewAggchainProverBuilderFlow( @@ -516,7 +518,7 @@ func Test_AggchainProverFlow_BuildCertificate(t *testing.T) { { name: "error building certificate", mockFn: func(mockL2BridgeQuerier *mocks.BridgeQuerier, mockLERQuerier *mocks.LERQuerier) { - mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) mockL2BridgeQuerier.EXPECT().GetExitRootByIndex(mock.Anything, uint32(0)).Return(common.Hash{}, errors.New("some error")) }, buildParams: &types.CertificateBuildParams{ @@ -532,7 +534,7 @@ func Test_AggchainProverFlow_BuildCertificate(t *testing.T) { name: "success building certificate", mockFn: func(mockL2BridgeQuerier *mocks.BridgeQuerier, mockLERQuerier *mocks.LERQuerier) { mockL2BridgeQuerier.EXPECT().OriginNetwork().Return(uint32(1)) - mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) }, buildParams: &types.CertificateBuildParams{ FromBlock: 1, @@ -561,11 +563,11 @@ func Test_AggchainProverFlow_BuildCertificate(t *testing.T) { expectedResult: &agglayertypes.Certificate{ NetworkID: 1, Height: 0, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, CustomChainData: []byte("some-data"), BridgeExits: []*agglayertypes.BridgeExit{}, ImportedBridgeExits: []*agglayertypes.ImportedBridgeExit{}, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, L1InfoTreeLeafCount: 0, AggchainData: &agglayertypes.AggchainDataProof{ Proof: []byte("some-proof"), @@ -597,6 +599,7 @@ func Test_AggchainProverFlow_BuildCertificate(t *testing.T) { nil, // mockStorage nil, // mockL1InfoTreeDataQuerier mockLERQuerier, + nil, // certQuerier NewBaseFlowConfigDefault(), ) aggchainFlow := NewAggchainProverBuilderFlow( diff --git a/aggsender/flows/builder_flow_factory.go b/aggsender/flows/builder_flow_factory.go index 188ac4f03..99fdf335c 100644 --- a/aggsender/flows/builder_flow_factory.go +++ b/aggsender/flows/builder_flow_factory.go @@ -39,6 +39,7 @@ func NewBuilderFlow( l2Syncer types.L2BridgeSyncer, rollupDataQuerier types.RollupDataQuerier, committeeQuerier types.MultisigQuerier, + certQuerier types.CertificateQuerier, ) (types.AggsenderBuilderFlow, error) { switch cfg.Mode { case types.PessimisticProofMode: @@ -54,6 +55,7 @@ func NewBuilderFlow( cfg.UnsetClaimsMaxLogBlockRange, cfg.GlobalExitRootL1Addr, cfg.BlockFinalityForL1InfoTree, + certQuerier, ) if err != nil { return nil, fmt.Errorf("failed to create common flow components: %w", err) @@ -104,6 +106,7 @@ func NewBuilderFlow( cfg.UnsetClaimsMaxLogBlockRange, cfg.GlobalExitRootL1Addr, cfg.BlockFinalityForL1InfoTree, + certQuerier, ) if err != nil { return nil, fmt.Errorf("failed to create common flow components: %w", err) @@ -172,6 +175,7 @@ func CreateCommonFlowComponents( unsetClaimsMaxLogBlockRange uint64, globalExitRootL1Addr ethCommon.Address, blockFinalityForL1InfoTree aggkittypes.BlockNumberFinality, + certQuerier types.CertificateQuerier, ) (*CommonFlowComponents, error) { l2ChainID, err := rollupDataQuerier.GetRollupChainID() if err != nil { @@ -203,6 +207,7 @@ func CreateCommonFlowComponents( baseFlow := NewBaseFlow( logger, l2BridgeQuerier, storage, l1InfoTreeQuerier, lerQuerier, + certQuerier, NewBaseFlowConfig( maxCertSize, startL2Block, diff --git a/aggsender/flows/builder_flow_factory_test.go b/aggsender/flows/builder_flow_factory_test.go index 2e67ace4b..f390d3e98 100644 --- a/aggsender/flows/builder_flow_factory_test.go +++ b/aggsender/flows/builder_flow_factory_test.go @@ -197,6 +197,7 @@ func TestNewFlow(t *testing.T) { mockL2BridgeSyncer, mockRollupDataQuerier, mockCommitteeQuerier, + nil, // certQuerier ) if tc.expectedError != "" { diff --git a/aggsender/flows/builder_flow_pp_test.go b/aggsender/flows/builder_flow_pp_test.go index fb5d95943..ef465dfba 100644 --- a/aggsender/flows/builder_flow_pp_test.go +++ b/aggsender/flows/builder_flow_pp_test.go @@ -539,7 +539,7 @@ func Test_PPFlow_GetCertificateBuildParams(t *testing.T) { mockLERQuerier := mocks.NewLERQuerier(t) logger := log.WithFields("test", "Test_PPFlow_GetCertificateBuildParams") baseFlow := NewBaseFlow(logger, mockL2BridgeQuerier, - mockStorage, mockL1InfoTreeQuerier, mockLERQuerier, NewBaseFlowConfigDefault()) + mockStorage, mockL1InfoTreeQuerier, mockLERQuerier, nil, NewBaseFlowConfigDefault()) baseFlow.timeNowFunc = timeNowUTCForTest ppFlow := NewPPBuilderFlow( logger, diff --git a/aggsender/flows/flow_base.go b/aggsender/flows/flow_base.go index 9f527f6c3..c1daa45d3 100644 --- a/aggsender/flows/flow_base.go +++ b/aggsender/flows/flow_base.go @@ -76,6 +76,7 @@ type baseFlow struct { storage db.AggSenderStorage l1InfoTreeDataQuerier types.L1InfoTreeDataQuerier lerQuerier types.LERQuerier + certQuerier types.CertificateQuerier cfg BaseFlowConfig log types.Logger // TimeNowFunc is a function that returns the current time as a uint32 timestamp. @@ -89,6 +90,7 @@ func NewBaseFlow( storage db.AggSenderStorage, l1InfoTreeDataQuerier types.L1InfoTreeDataQuerier, lerQuerier types.LERQuerier, + certQuerier types.CertificateQuerier, cfg BaseFlowConfig, ) *baseFlow { return &baseFlow{ @@ -97,6 +99,7 @@ func NewBaseFlow( storage: storage, l1InfoTreeDataQuerier: l1InfoTreeDataQuerier, lerQuerier: lerQuerier, + certQuerier: certQuerier, cfg: cfg, timeNowFunc: TimeNowUTC, } @@ -116,6 +119,21 @@ func (f *baseFlow) NextCertificateBlockRange(ctx context.Context, } previousToBlock, retryCount := f.getLastSentBlockAndRetryCount(lastSentCertificate) + + // For settled certs, re-derive the boundary from on-chain/bridgesync data using the same + // logic as the local validator. This ensures the aggsender and validator always agree on + // fromBlock even when the stored ToBlock is stale (e.g. set to 0 by the debug endpoint). + if lastSentCertificate != nil && lastSentCertificate.Status.IsSettled() && f.certQuerier != nil { + agglayerCert := converters.ConvertAggsenderCertHeaderToAgglayer( + lastSentCertificate, f.l2BridgeQuerier.OriginNetwork()) + derivedToBlock, err := f.certQuerier.GetLastSettledCertificateToBlock(ctx, agglayerCert) + if err != nil { + return aggkitcommon.BlockRangeZero, 0, + fmt.Errorf("error deriving toBlock for settled cert %s: %w", lastSentCertificate.ID(), err) + } + previousToBlock = derivedToBlock + } + if previousToBlock >= lastL2BlockSynced { f.log.Infof("no new blocks to send a certificate, last certificate block: %d, last L2 block: %d", previousToBlock, lastL2BlockSynced) diff --git a/aggsender/flows/flow_base_test.go b/aggsender/flows/flow_base_test.go index 09feadd2d..bf61bd228 100644 --- a/aggsender/flows/flow_base_test.go +++ b/aggsender/flows/flow_base_test.go @@ -130,6 +130,7 @@ func Test_baseFlow_limitCertSize(t *testing.T) { nil, nil, nil, + nil, NewBaseFlowConfig(tt.maxCertSize, 0, false, true)) result, err := f.LimitCertSize(tt.fullCert) @@ -339,9 +340,9 @@ func Test_baseFlow_getNextHeightAndPreviousLER(t *testing.T) { name: "no last sent certificate - zero start LER", lastSentCert: nil, expectedHeight: 0, - expectedLER: types.EmptyLER, + expectedLER: bridgesynctypes.EmptyLER, mockFn: func(mockLERQuerier *mocks.LERQuerier, mockStorage *mocks.AggSenderStorage) { - mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) }, }, { @@ -402,9 +403,9 @@ func Test_baseFlow_getNextHeightAndPreviousLER(t *testing.T) { NewLocalExitRoot: common.HexToHash("0x789"), }, expectedHeight: 0, - expectedLER: types.EmptyLER, + expectedLER: bridgesynctypes.EmptyLER, mockFn: func(mockLERQuerier *mocks.LERQuerier, mockStorage *mocks.AggSenderStorage) { - mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) }, }, { @@ -1778,3 +1779,93 @@ func Test_baseFlow_adjustCertificateIfNonFinalizedClaims_UnclaimValidation(t *te }) } } + +// Test_baseFlow_NextCertificateBlockRange_CertQuerier tests the new certQuerier path +// in NextCertificateBlockRange for settled certificates. +func Test_baseFlow_NextCertificateBlockRange_CertQuerier(t *testing.T) { + t.Parallel() + + t.Run("settled cert with certQuerier success", func(t *testing.T) { + t.Parallel() + + mockBridgeQuerier := mocks.NewBridgeQuerier(t) + mockCertQuerier := mocks.NewCertificateQuerier(t) + + // lastSentCertificate is settled with ToBlock=0 (stale, as set by debug endpoint). + lastSentCert := &types.CertificateHeader{ + Status: agglayertypes.Settled, + ToBlock: 0, + Height: 3, + } + + // certQuerier re-derives the toBlock = 10. + mockBridgeQuerier.EXPECT().GetLastProcessedBlock(mock.Anything).Return(uint64(20), nil) + mockBridgeQuerier.EXPECT().OriginNetwork().Return(uint32(1)) + mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(mock.Anything, mock.Anything). + Return(uint64(10), nil) + + f := &baseFlow{ + l2BridgeQuerier: mockBridgeQuerier, + certQuerier: mockCertQuerier, + log: log.WithFields("test", t.Name()), + } + + blockRange, retryCount, err := f.NextCertificateBlockRange(context.Background(), lastSentCert) + require.NoError(t, err) + require.Equal(t, uint64(11), blockRange.FromBlock) + require.Equal(t, uint64(20), blockRange.ToBlock) + require.Equal(t, 0, retryCount) + }) + + t.Run("settled cert with certQuerier error", func(t *testing.T) { + t.Parallel() + + mockBridgeQuerier := mocks.NewBridgeQuerier(t) + mockCertQuerier := mocks.NewCertificateQuerier(t) + + lastSentCert := &types.CertificateHeader{ + Status: agglayertypes.Settled, + ToBlock: 5, + Height: 2, + } + + mockBridgeQuerier.EXPECT().GetLastProcessedBlock(mock.Anything).Return(uint64(20), nil) + mockBridgeQuerier.EXPECT().OriginNetwork().Return(uint32(1)) + mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(mock.Anything, mock.Anything). + Return(uint64(0), errors.New("query failed")) + + f := &baseFlow{ + l2BridgeQuerier: mockBridgeQuerier, + certQuerier: mockCertQuerier, + log: log.WithFields("test", t.Name()), + } + + _, _, err := f.NextCertificateBlockRange(context.Background(), lastSentCert) + require.Error(t, err) + require.Contains(t, err.Error(), "error deriving toBlock for settled cert") + }) + + t.Run("settled cert without certQuerier uses stored toBlock", func(t *testing.T) { + t.Parallel() + + mockBridgeQuerier := mocks.NewBridgeQuerier(t) + + lastSentCert := &types.CertificateHeader{ + Status: agglayertypes.Settled, + ToBlock: 8, + Height: 1, + } + + mockBridgeQuerier.EXPECT().GetLastProcessedBlock(mock.Anything).Return(uint64(20), nil) + + f := &baseFlow{ + l2BridgeQuerier: mockBridgeQuerier, + certQuerier: nil, // no certQuerier → falls back to stored toBlock + log: log.WithFields("test", t.Name()), + } + + blockRange, _, err := f.NextCertificateBlockRange(context.Background(), lastSentCert) + require.NoError(t, err) + require.Equal(t, uint64(9), blockRange.FromBlock) + }) +} diff --git a/aggsender/flows/verifier_flow_factory.go b/aggsender/flows/verifier_flow_factory.go index 741f5140e..de69c4ab5 100644 --- a/aggsender/flows/verifier_flow_factory.go +++ b/aggsender/flows/verifier_flow_factory.go @@ -40,6 +40,7 @@ func NewVerifierFlow( cfg.UnsetClaimsMaxLogBlockRange, cfg.GlobalExitRootL1Addr, cfg.BlockFinalityForL1InfoTree, + nil, // certQuerier not used in validator mode ) if err != nil { return nil, nil, fmt.Errorf("failed to create common flow components: %w", err) @@ -71,6 +72,7 @@ func NewVerifierFlow( cfg.UnsetClaimsMaxLogBlockRange, cfg.GlobalExitRootL1Addr, cfg.BlockFinalityForL1InfoTree, + nil, // certQuerier not used in validator mode ) if err != nil { return nil, nil, fmt.Errorf("failed to create common flow components: %w", err) diff --git a/aggsender/mocks/mock_agg_sender_storage.go b/aggsender/mocks/mock_agg_sender_storage.go index b0ff20665..aacc4bf5d 100644 --- a/aggsender/mocks/mock_agg_sender_storage.go +++ b/aggsender/mocks/mock_agg_sender_storage.go @@ -125,6 +125,64 @@ func (_c *AggSenderStorage_DeleteOldCertificates_Call) RunAndReturn(run func(typ return _c } +// GetCertificateBridgeExits provides a mock function with given fields: height +func (_m *AggSenderStorage) GetCertificateBridgeExits(height uint64) ([]*agglayertypes.BridgeExit, error) { + ret := _m.Called(height) + + if len(ret) == 0 { + panic("no return value specified for GetCertificateBridgeExits") + } + + var r0 []*agglayertypes.BridgeExit + var r1 error + if rf, ok := ret.Get(0).(func(uint64) ([]*agglayertypes.BridgeExit, error)); ok { + return rf(height) + } + if rf, ok := ret.Get(0).(func(uint64) []*agglayertypes.BridgeExit); ok { + r0 = rf(height) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*agglayertypes.BridgeExit) + } + } + + if rf, ok := ret.Get(1).(func(uint64) error); ok { + r1 = rf(height) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AggSenderStorage_GetCertificateBridgeExits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateBridgeExits' +type AggSenderStorage_GetCertificateBridgeExits_Call struct { + *mock.Call +} + +// GetCertificateBridgeExits is a helper method to define mock.On call +// - height uint64 +func (_e *AggSenderStorage_Expecter) GetCertificateBridgeExits(height interface{}) *AggSenderStorage_GetCertificateBridgeExits_Call { + return &AggSenderStorage_GetCertificateBridgeExits_Call{Call: _e.mock.On("GetCertificateBridgeExits", height)} +} + +func (_c *AggSenderStorage_GetCertificateBridgeExits_Call) Run(run func(height uint64)) *AggSenderStorage_GetCertificateBridgeExits_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint64)) + }) + return _c +} + +func (_c *AggSenderStorage_GetCertificateBridgeExits_Call) Return(_a0 []*agglayertypes.BridgeExit, _a1 error) *AggSenderStorage_GetCertificateBridgeExits_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AggSenderStorage_GetCertificateBridgeExits_Call) RunAndReturn(run func(uint64) ([]*agglayertypes.BridgeExit, error)) *AggSenderStorage_GetCertificateBridgeExits_Call { + _c.Call.Return(run) + return _c +} + // GetCertificateByHeight provides a mock function with given fields: height func (_m *AggSenderStorage) GetCertificateByHeight(height uint64) (*aggsendertypes.Certificate, error) { ret := _m.Called(height) diff --git a/aggsender/mocks/mock_aggsender_storer.go b/aggsender/mocks/mock_aggsender_storer.go index 157b9ce86..00c9e9f8b 100644 --- a/aggsender/mocks/mock_aggsender_storer.go +++ b/aggsender/mocks/mock_aggsender_storer.go @@ -3,8 +3,12 @@ package mocks import ( - types "github.com/agglayer/aggkit/aggsender/types" + context "context" + + aggsendertypes "github.com/agglayer/aggkit/aggsender/types" mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/agglayer/types" ) // AggsenderStorer is an autogenerated mock type for the AggsenderStorer type @@ -20,24 +24,82 @@ func (_m *AggsenderStorer) EXPECT() *AggsenderStorer_Expecter { return &AggsenderStorer_Expecter{mock: &_m.Mock} } +// GetCertificateBridgeExits provides a mock function with given fields: height +func (_m *AggsenderStorer) GetCertificateBridgeExits(height uint64) ([]*types.BridgeExit, error) { + ret := _m.Called(height) + + if len(ret) == 0 { + panic("no return value specified for GetCertificateBridgeExits") + } + + var r0 []*types.BridgeExit + var r1 error + if rf, ok := ret.Get(0).(func(uint64) ([]*types.BridgeExit, error)); ok { + return rf(height) + } + if rf, ok := ret.Get(0).(func(uint64) []*types.BridgeExit); ok { + r0 = rf(height) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*types.BridgeExit) + } + } + + if rf, ok := ret.Get(1).(func(uint64) error); ok { + r1 = rf(height) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AggsenderStorer_GetCertificateBridgeExits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateBridgeExits' +type AggsenderStorer_GetCertificateBridgeExits_Call struct { + *mock.Call +} + +// GetCertificateBridgeExits is a helper method to define mock.On call +// - height uint64 +func (_e *AggsenderStorer_Expecter) GetCertificateBridgeExits(height interface{}) *AggsenderStorer_GetCertificateBridgeExits_Call { + return &AggsenderStorer_GetCertificateBridgeExits_Call{Call: _e.mock.On("GetCertificateBridgeExits", height)} +} + +func (_c *AggsenderStorer_GetCertificateBridgeExits_Call) Run(run func(height uint64)) *AggsenderStorer_GetCertificateBridgeExits_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint64)) + }) + return _c +} + +func (_c *AggsenderStorer_GetCertificateBridgeExits_Call) Return(_a0 []*types.BridgeExit, _a1 error) *AggsenderStorer_GetCertificateBridgeExits_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AggsenderStorer_GetCertificateBridgeExits_Call) RunAndReturn(run func(uint64) ([]*types.BridgeExit, error)) *AggsenderStorer_GetCertificateBridgeExits_Call { + _c.Call.Return(run) + return _c +} + // GetCertificateByHeight provides a mock function with given fields: height -func (_m *AggsenderStorer) GetCertificateByHeight(height uint64) (*types.Certificate, error) { +func (_m *AggsenderStorer) GetCertificateByHeight(height uint64) (*aggsendertypes.Certificate, error) { ret := _m.Called(height) if len(ret) == 0 { panic("no return value specified for GetCertificateByHeight") } - var r0 *types.Certificate + var r0 *aggsendertypes.Certificate var r1 error - if rf, ok := ret.Get(0).(func(uint64) (*types.Certificate, error)); ok { + if rf, ok := ret.Get(0).(func(uint64) (*aggsendertypes.Certificate, error)); ok { return rf(height) } - if rf, ok := ret.Get(0).(func(uint64) *types.Certificate); ok { + if rf, ok := ret.Get(0).(func(uint64) *aggsendertypes.Certificate); ok { r0 = rf(height) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*types.Certificate) + r0 = ret.Get(0).(*aggsendertypes.Certificate) } } @@ -68,34 +130,34 @@ func (_c *AggsenderStorer_GetCertificateByHeight_Call) Run(run func(height uint6 return _c } -func (_c *AggsenderStorer_GetCertificateByHeight_Call) Return(_a0 *types.Certificate, _a1 error) *AggsenderStorer_GetCertificateByHeight_Call { +func (_c *AggsenderStorer_GetCertificateByHeight_Call) Return(_a0 *aggsendertypes.Certificate, _a1 error) *AggsenderStorer_GetCertificateByHeight_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *AggsenderStorer_GetCertificateByHeight_Call) RunAndReturn(run func(uint64) (*types.Certificate, error)) *AggsenderStorer_GetCertificateByHeight_Call { +func (_c *AggsenderStorer_GetCertificateByHeight_Call) RunAndReturn(run func(uint64) (*aggsendertypes.Certificate, error)) *AggsenderStorer_GetCertificateByHeight_Call { _c.Call.Return(run) return _c } // GetLastSentCertificate provides a mock function with no fields -func (_m *AggsenderStorer) GetLastSentCertificate() (*types.Certificate, error) { +func (_m *AggsenderStorer) GetLastSentCertificate() (*aggsendertypes.Certificate, error) { ret := _m.Called() if len(ret) == 0 { panic("no return value specified for GetLastSentCertificate") } - var r0 *types.Certificate + var r0 *aggsendertypes.Certificate var r1 error - if rf, ok := ret.Get(0).(func() (*types.Certificate, error)); ok { + if rf, ok := ret.Get(0).(func() (*aggsendertypes.Certificate, error)); ok { return rf() } - if rf, ok := ret.Get(0).(func() *types.Certificate); ok { + if rf, ok := ret.Get(0).(func() *aggsendertypes.Certificate); ok { r0 = rf() } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*types.Certificate) + r0 = ret.Get(0).(*aggsendertypes.Certificate) } } @@ -125,12 +187,59 @@ func (_c *AggsenderStorer_GetLastSentCertificate_Call) Run(run func()) *Aggsende return _c } -func (_c *AggsenderStorer_GetLastSentCertificate_Call) Return(_a0 *types.Certificate, _a1 error) *AggsenderStorer_GetLastSentCertificate_Call { +func (_c *AggsenderStorer_GetLastSentCertificate_Call) Return(_a0 *aggsendertypes.Certificate, _a1 error) *AggsenderStorer_GetLastSentCertificate_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *AggsenderStorer_GetLastSentCertificate_Call) RunAndReturn(run func() (*types.Certificate, error)) *AggsenderStorer_GetLastSentCertificate_Call { +func (_c *AggsenderStorer_GetLastSentCertificate_Call) RunAndReturn(run func() (*aggsendertypes.Certificate, error)) *AggsenderStorer_GetLastSentCertificate_Call { + _c.Call.Return(run) + return _c +} + +// SaveLastSentCertificate provides a mock function with given fields: ctx, certificate +func (_m *AggsenderStorer) SaveLastSentCertificate(ctx context.Context, certificate aggsendertypes.Certificate) error { + ret := _m.Called(ctx, certificate) + + if len(ret) == 0 { + panic("no return value specified for SaveLastSentCertificate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, aggsendertypes.Certificate) error); ok { + r0 = rf(ctx, certificate) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// AggsenderStorer_SaveLastSentCertificate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveLastSentCertificate' +type AggsenderStorer_SaveLastSentCertificate_Call struct { + *mock.Call +} + +// SaveLastSentCertificate is a helper method to define mock.On call +// - ctx context.Context +// - certificate aggsendertypes.Certificate +func (_e *AggsenderStorer_Expecter) SaveLastSentCertificate(ctx interface{}, certificate interface{}) *AggsenderStorer_SaveLastSentCertificate_Call { + return &AggsenderStorer_SaveLastSentCertificate_Call{Call: _e.mock.On("SaveLastSentCertificate", ctx, certificate)} +} + +func (_c *AggsenderStorer_SaveLastSentCertificate_Call) Run(run func(ctx context.Context, certificate aggsendertypes.Certificate)) *AggsenderStorer_SaveLastSentCertificate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(aggsendertypes.Certificate)) + }) + return _c +} + +func (_c *AggsenderStorer_SaveLastSentCertificate_Call) Return(_a0 error) *AggsenderStorer_SaveLastSentCertificate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *AggsenderStorer_SaveLastSentCertificate_Call) RunAndReturn(run func(context.Context, aggsendertypes.Certificate) error) *AggsenderStorer_SaveLastSentCertificate_Call { _c.Call.Return(run) return _c } diff --git a/aggsender/prover/proof_generation_tool.go b/aggsender/prover/proof_generation_tool.go index bd30fa247..39406d110 100644 --- a/aggsender/prover/proof_generation_tool.go +++ b/aggsender/prover/proof_generation_tool.go @@ -111,6 +111,7 @@ func NewAggchainProofGenerationTool( nil, // storage l1InfoTreeQuerier, nil, // lerQuerier + nil, // certQuerier flows.NewBaseFlowConfigDefault(), ) aggchainProofQuerier := query.NewAggchainProofQuery( diff --git a/aggsender/prover/proof_generation_tool_test.go b/aggsender/prover/proof_generation_tool_test.go index 9dcef8452..bb1e7df9f 100644 --- a/aggsender/prover/proof_generation_tool_test.go +++ b/aggsender/prover/proof_generation_tool_test.go @@ -147,6 +147,15 @@ func TestGetRPCServices(t *testing.T) { require.NotNil(t, services[0].Service) } +func TestOptimisticModeQuerierAlwaysOff(t *testing.T) { + t.Parallel() + + o := &OptimisticModeQuerierAlwaysOff{} + on, err := o.IsOptimisticModeOn() + require.NoError(t, err) + require.False(t, on) +} + func TestNewAggchainProofGenerationTool(t *testing.T) { mockL2Syncer := mocks.NewL2BridgeSyncer(t) mockL1Client := aggkittypesmocks.NewBaseEthereumClienter(t) diff --git a/aggsender/query/certificate_query.go b/aggsender/query/certificate_query.go index 6b3771825..17ee78896 100644 --- a/aggsender/query/certificate_query.go +++ b/aggsender/query/certificate_query.go @@ -9,6 +9,7 @@ import ( agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/aggsender/converters" "github.com/agglayer/aggkit/aggsender/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" "github.com/ethereum/go-ethereum/common" ) @@ -184,7 +185,7 @@ func (c *certificateQuerier) CalculateCertificateTypeFromToBlock(certToBlock uin } func (c *certificateQuerier) getBlockNumFromLER(ctx context.Context, localExitRoot common.Hash) (uint64, error) { - if localExitRoot == types.EmptyLER { + if localExitRoot == bridgesynctypes.EmptyLER { return 0, nil // Empty LER means no exit root, so return 0 } diff --git a/aggsender/query/certificate_query_test.go b/aggsender/query/certificate_query_test.go index 39fd60fc3..468e201b8 100644 --- a/aggsender/query/certificate_query_test.go +++ b/aggsender/query/certificate_query_test.go @@ -11,6 +11,7 @@ import ( "github.com/agglayer/aggkit/aggsender/mocks" "github.com/agglayer/aggkit/aggsender/types" "github.com/agglayer/aggkit/bridgesync" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" treetypes "github.com/agglayer/aggkit/tree/types" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -70,7 +71,7 @@ func TestGetLastSettledCertificateToBlock(t *testing.T) { name: "empty local exit root with imported bridge exit", certificate: &agglayertypes.CertificateHeader{ Status: agglayertypes.Settled, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, }, mockFn: func(aggchainQuerier *mocks.AggchainFEPRollupQuerier, agglayerClient *agglayermocks.AgglayerClientMock, bridgeSyncer *mocks.L2BridgeSyncer) { networkStatus := agglayertypes.NetworkInfo{ @@ -121,7 +122,7 @@ func TestGetLastSettledCertificateToBlock(t *testing.T) { name: "error getting latest settled imported bridge exit", certificate: &agglayertypes.CertificateHeader{ Status: agglayertypes.Settled, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, }, mockFn: func(aggchainQuerier *mocks.AggchainFEPRollupQuerier, agglayerClient *agglayermocks.AgglayerClientMock, bridgeSyncer *mocks.L2BridgeSyncer) { agglayerClient.EXPECT().GetNetworkInfo(ctx, uint32(0)).Return(agglayertypes.NetworkInfo{}, errors.New("agglayer error")) @@ -132,7 +133,7 @@ func TestGetLastSettledCertificateToBlock(t *testing.T) { name: "error getting claim by global index", certificate: &agglayertypes.CertificateHeader{ Status: agglayertypes.Settled, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, }, mockFn: func(aggchainQuerier *mocks.AggchainFEPRollupQuerier, agglayerClient *agglayermocks.AgglayerClientMock, bridgeSyncer *mocks.L2BridgeSyncer) { networkStatus := agglayertypes.NetworkInfo{ @@ -150,7 +151,7 @@ func TestGetLastSettledCertificateToBlock(t *testing.T) { name: "error getting last settled L2 block", certificate: &agglayertypes.CertificateHeader{ Status: agglayertypes.Settled, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, }, mockFn: func(aggchainQuerier *mocks.AggchainFEPRollupQuerier, agglayerClient *agglayermocks.AgglayerClientMock, bridgeSyncer *mocks.L2BridgeSyncer) { agglayerClient.EXPECT().GetNetworkInfo(ctx, uint32(0)).Return(agglayertypes.NetworkInfo{}, nil) @@ -162,7 +163,7 @@ func TestGetLastSettledCertificateToBlock(t *testing.T) { name: "all sources return zero values", certificate: &agglayertypes.CertificateHeader{ Status: agglayertypes.Settled, - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, }, mockFn: func(aggchainQuerier *mocks.AggchainFEPRollupQuerier, agglayerClient *agglayermocks.AgglayerClientMock, bridgeSyncer *mocks.L2BridgeSyncer) { agglayerClient.EXPECT().GetNetworkInfo(ctx, uint32(0)).Return(agglayertypes.NetworkInfo{}, nil) @@ -254,7 +255,7 @@ func TestGetNewCertificateToBlock(t *testing.T) { { name: "empty local exit root with imported bridge exits", certificate: &agglayertypes.Certificate{ - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, ImportedBridgeExits: []*agglayertypes.ImportedBridgeExit{testIbe}, }, mockFn: func(bridgeSyncer *mocks.L2BridgeSyncer) { @@ -280,7 +281,7 @@ func TestGetNewCertificateToBlock(t *testing.T) { { name: "empty local exit root with no imported bridge exits", certificate: &agglayertypes.Certificate{ - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, ImportedBridgeExits: []*agglayertypes.ImportedBridgeExit{}, }, expectedBlock: 0, // max of 0, 0 @@ -312,7 +313,7 @@ func TestGetNewCertificateToBlock(t *testing.T) { { name: "error getting claim by global index", certificate: &agglayertypes.Certificate{ - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, ImportedBridgeExits: []*agglayertypes.ImportedBridgeExit{testIbe}, }, mockFn: func(bridgeSyncer *mocks.L2BridgeSyncer) { @@ -323,7 +324,7 @@ func TestGetNewCertificateToBlock(t *testing.T) { { name: "multiple imported bridge exits uses last one", certificate: &agglayertypes.Certificate{ - NewLocalExitRoot: types.EmptyLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, ImportedBridgeExits: []*agglayertypes.ImportedBridgeExit{ {GlobalIndex: &agglayertypes.GlobalIndex{}}, // First one - should not be used {GlobalIndex: &agglayertypes.GlobalIndex{}}, // Second one - should not be used diff --git a/aggsender/query/ler_query.go b/aggsender/query/ler_query.go index b78ed860d..55426df7f 100644 --- a/aggsender/query/ler_query.go +++ b/aggsender/query/ler_query.go @@ -5,6 +5,7 @@ import ( "math/big" "github.com/agglayer/aggkit/aggsender/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" "github.com/ethereum/go-ethereum/common" ) @@ -50,7 +51,7 @@ func (l *lerDataQuerier) GetLastLocalExitRoot() (common.Hash, error) { } if rollupData.LastLocalExitRoot == aggkitcommon.ZeroHash { - return types.EmptyLER, nil + return bridgesynctypes.EmptyLER, nil } return rollupData.LastLocalExitRoot, nil diff --git a/aggsender/query/ler_query_test.go b/aggsender/query/ler_query_test.go index b7a478529..eb707da19 100644 --- a/aggsender/query/ler_query_test.go +++ b/aggsender/query/ler_query_test.go @@ -6,7 +6,7 @@ import ( "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" "github.com/agglayer/aggkit/aggsender/mocks" - "github.com/agglayer/aggkit/aggsender/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/mock" @@ -37,7 +37,7 @@ func TestGetLastLocalExitRoot(t *testing.T) { LastLocalExitRoot: aggkitcommon.ZeroHash, }, nil) }, - expectedLER: types.EmptyLER, + expectedLER: bridgesynctypes.EmptyLER, }, { name: "rollup manager contract returns valid data", diff --git a/aggsender/rpc/aggsender_rpc.go b/aggsender/rpc/aggsender_rpc.go index 7a1ba0181..d6aa2aa82 100644 --- a/aggsender/rpc/aggsender_rpc.go +++ b/aggsender/rpc/aggsender_rpc.go @@ -1,16 +1,25 @@ package aggsenderrpc import ( + "context" + "encoding/json" "fmt" + "time" "github.com/0xPolygon/cdk-rpc/rpc" + "github.com/agglayer/aggkit/agglayer" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/aggsender/types" "github.com/agglayer/aggkit/log" + ethCommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) type AggsenderStorer interface { GetCertificateByHeight(height uint64) (*types.Certificate, error) GetLastSentCertificate() (*types.Certificate, error) + GetCertificateBridgeExits(height uint64) ([]*agglayertypes.BridgeExit, error) + SaveLastSentCertificate(ctx context.Context, certificate types.Certificate) error } type AggsenderInterface interface { @@ -18,22 +27,38 @@ type AggsenderInterface interface { ForceTriggerCertificate() } +// DebugSendCertificateRequest is the request body for the debug send certificate endpoint. +type DebugSendCertificateRequest struct { + Certificate agglayertypes.Certificate `json:"certificate"` + Signature []byte `json:"signature"` // 65-byte Ethereum signature +} + // AggsenderRPC is the RPC interface for the aggsender type AggsenderRPC struct { - logger *log.Logger - storage AggsenderStorer - aggsender AggsenderInterface + logger *log.Logger + storage AggsenderStorer + aggsender AggsenderInterface + enableDebug bool + debugAuthAddress ethCommon.Address + agglayerClient agglayer.AgglayerClientInterface } +// NewAggsenderRPC creates a new AggsenderRPC instance. func NewAggsenderRPC( logger *log.Logger, storage AggsenderStorer, aggsender AggsenderInterface, + enableDebug bool, + debugAuthAddress ethCommon.Address, + agglayerClient agglayer.AgglayerClientInterface, ) *AggsenderRPC { return &AggsenderRPC{ - logger: logger, - storage: storage, - aggsender: aggsender, + logger: logger, + storage: storage, + aggsender: aggsender, + enableDebug: enableDebug, + debugAuthAddress: debugAuthAddress, + agglayerClient: agglayerClient, } } @@ -83,3 +108,95 @@ func (b *AggsenderRPC) GetCertificateHeaderPerHeight(height *uint64) (interface{ return cert, nil } + +// GetCertificateBridgeExits returns the bridge exits for the certificate at the given height. +// If height is nil, returns the bridge exits of the last sent certificate. +// +// curl -X POST http://localhost:5576/ -H "Content-Type: application/json" \ +// +// -d '{"method":"aggsender_getCertificateBridgeExits", "params":[], "id":1}' +// +// curl -X POST http://localhost:5576/ -H "Content-Type: application/json" \ +// +// -d '{"method":"aggsender_getCertificateBridgeExits", "params":[42], "id":1}' +func (b *AggsenderRPC) GetCertificateBridgeExits(height *uint64) (interface{}, rpc.Error) { + var resolvedHeight uint64 + if height == nil { + cert, err := b.storage.GetLastSentCertificate() + if err != nil { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, + fmt.Sprintf("error getting last sent certificate: %v", err)) + } + if cert == nil { + return nil, rpc.NewRPCError(rpc.NotFoundErrorCode, "no certificate found") + } + resolvedHeight = cert.Header.Height + } else { + resolvedHeight = *height + } + exits, err := b.storage.GetCertificateBridgeExits(resolvedHeight) + if err != nil { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, + fmt.Sprintf("error getting certificate bridge exits at height %d: %v", resolvedHeight, err)) + } + if exits == nil { + return nil, rpc.NewRPCError(rpc.NotFoundErrorCode, + fmt.Sprintf("certificate not found at height %d", resolvedHeight)) + } + return exits, nil +} + +// DebugSendCertificate sends an arbitrary certificate to AggLayer (test-only endpoint). +// Requires EnableDebugSendCertificate=true in config and a valid Ethereum signature. +func (b *AggsenderRPC) DebugSendCertificate(signedRequest DebugSendCertificateRequest) (interface{}, rpc.Error) { + if !b.enableDebug { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, "debug send certificate endpoint is disabled") + } + hash, err := HashCertificateForDebugAuth(&signedRequest.Certificate) + if err != nil { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, fmt.Sprintf("error hashing certificate: %v", err)) + } + pubKey, err := crypto.SigToPub(hash.Bytes(), signedRequest.Signature) + if err != nil { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, fmt.Sprintf("error recovering signer: %v", err)) + } + signer := crypto.PubkeyToAddress(*pubKey) + if b.debugAuthAddress == (ethCommon.Address{}) { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, + "debug endpoint requires DebugSendCertificateAuthAddress to be configured") + } + if signer != b.debugAuthAddress { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, + fmt.Sprintf("unauthorized: signer %s does not match auth address %s", signer.Hex(), b.debugAuthAddress.Hex())) + } + b.logger.Infof("debug: sending certificate height=%d signer=%s", signedRequest.Certificate.Height, signer.Hex()) + ctx := context.Background() + certHash, err := b.agglayerClient.SendCertificate(ctx, &signedRequest.Certificate) + if err != nil { + return nil, rpc.NewRPCError(rpc.DefaultErrorCode, fmt.Sprintf("error sending certificate to AggLayer: %v", err)) + } + // Store in DB so getCertificateBridgeExits can later retrieve the bridge exits + jsonCert, err := json.Marshal(&signedRequest.Certificate) + if err != nil { + b.logger.Warnf("debug: failed to marshal certificate for storage: %v", err) + } else { + jsonCertStr := string(jsonCert) + now := uint32(time.Now().Unix()) + cert := types.Certificate{ + Header: &types.CertificateHeader{ + Height: signedRequest.Certificate.Height, + CertificateID: signedRequest.Certificate.CertificateID(), + NewLocalExitRoot: signedRequest.Certificate.NewLocalExitRoot, + Status: agglayertypes.Pending, + CreatedAt: now, + UpdatedAt: now, + CertSource: types.CertificateSourceLocal, + }, + SignedCertificate: &jsonCertStr, + } + if err := b.storage.SaveLastSentCertificate(ctx, cert); err != nil { + b.logger.Warnf("debug: failed to store certificate in DB: %v", err) + } + } + return certHash, nil +} diff --git a/aggsender/rpc/aggsender_rpc_test.go b/aggsender/rpc/aggsender_rpc_test.go index 69f2fbcba..028eb78ef 100644 --- a/aggsender/rpc/aggsender_rpc_test.go +++ b/aggsender/rpc/aggsender_rpc_test.go @@ -2,10 +2,17 @@ package aggsenderrpc import ( "fmt" + "math/big" "testing" + agglayermocks "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/aggsender/mocks" "github.com/agglayer/aggkit/aggsender/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -91,6 +98,216 @@ func TestAggsenderRPCGetCertificateHeaderPerHeight(t *testing.T) { } } +func TestAggsenderRPCGetCertificateBridgeExits(t *testing.T) { + height := uint64(42) + bridgeExits := []*agglayertypes.BridgeExit{ + { + LeafType: 0, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xdeadbeef"), + Amount: big.NewInt(1000), + }, + } + + cases := []struct { + name string + height *uint64 + lastCertResult *types.Certificate + lastCertError error + bridgeExitsResult []*agglayertypes.BridgeExit + bridgeExitsError error + expectedErrorCode int + expectedErrorContains string + expectNil bool + }{ + { + name: "nil height, resolves last cert then returns exits", + height: nil, + lastCertResult: &types.Certificate{Header: &types.CertificateHeader{Height: height}}, + bridgeExitsResult: bridgeExits, + }, + { + name: "nil height, GetLastSentCertificate error", + height: nil, + lastCertError: fmt.Errorf("db error"), + expectedErrorContains: "db error", + expectNil: true, + }, + { + name: "nil height, no last cert found", + height: nil, + lastCertResult: nil, + expectedErrorContains: "no certificate found", + expectNil: true, + }, + { + name: "specific height, returns exits", + height: &height, + bridgeExitsResult: bridgeExits, + }, + { + name: "specific height, bridge exits nil (not found)", + height: &height, + bridgeExitsResult: nil, + expectedErrorContains: fmt.Sprintf("certificate not found at height %d", height), + expectNil: true, + }, + { + name: "specific height, storage error", + height: &height, + bridgeExitsError: fmt.Errorf("storage error"), + expectedErrorContains: "storage error", + expectNil: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + testData := newAggsenderData(t) + var resolvedHeight uint64 + if tt.height == nil { + testData.mockStore.EXPECT().GetLastSentCertificate(). + Return(tt.lastCertResult, tt.lastCertError).Once() + if tt.lastCertResult != nil && tt.lastCertError == nil { + resolvedHeight = tt.lastCertResult.Header.Height + testData.mockStore.EXPECT().GetCertificateBridgeExits(resolvedHeight). + Return(tt.bridgeExitsResult, tt.bridgeExitsError).Once() + } + } else { + testData.mockStore.EXPECT().GetCertificateBridgeExits(*tt.height). + Return(tt.bridgeExitsResult, tt.bridgeExitsError).Once() + } + + res, rpcErr := testData.sut.GetCertificateBridgeExits(tt.height) + if tt.expectedErrorContains != "" { + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Error(), tt.expectedErrorContains) + } else { + require.Nil(t, rpcErr) + } + if tt.expectNil { + require.Nil(t, res) + } else { + require.NotNil(t, res) + } + }) + } +} + +func TestDebugSendCertificate_Disabled(t *testing.T) { + testData := newAggsenderData(t) + req := DebugSendCertificateRequest{ + Certificate: agglayertypes.Certificate{}, + Signature: []byte{}, + } + res, rpcErr := testData.sut.DebugSendCertificate(req) + require.Nil(t, res) + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Error(), "disabled") +} + +func TestDebugSendCertificate_InvalidSignature(t *testing.T) { + authKey, err := crypto.GenerateKey() + require.NoError(t, err) + authAddr := crypto.PubkeyToAddress(authKey.PublicKey) + + wrongKey, err := crypto.GenerateKey() + require.NoError(t, err) + + mockAgglayer := agglayermocks.NewAgglayerClientMock(t) + testData := newDebugAggsenderData(t, true, authAddr, mockAgglayer) + + cert := agglayertypes.Certificate{Height: 1} + hash, err := HashCertificateForDebugAuth(&cert) + require.NoError(t, err) + + sig, err := crypto.Sign(hash.Bytes(), wrongKey) + require.NoError(t, err) + + req := DebugSendCertificateRequest{Certificate: cert, Signature: sig} + res, rpcErr := testData.sut.DebugSendCertificate(req) + require.Nil(t, res) + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Error(), "unauthorized") +} + +func TestDebugSendCertificate_Success(t *testing.T) { + authKey, err := crypto.GenerateKey() + require.NoError(t, err) + authAddr := crypto.PubkeyToAddress(authKey.PublicKey) + + mockAgglayer := agglayermocks.NewAgglayerClientMock(t) + testData := newDebugAggsenderData(t, true, authAddr, mockAgglayer) + + cert := agglayertypes.Certificate{Height: 5} + hash, err := HashCertificateForDebugAuth(&cert) + require.NoError(t, err) + + sig, err := crypto.Sign(hash.Bytes(), authKey) + require.NoError(t, err) + + expectedCertHash := common.HexToHash("0xabcdef") + mockAgglayer.EXPECT().SendCertificate(mock.Anything, &cert).Return(expectedCertHash, nil).Once() + testData.mockStore.EXPECT().SaveLastSentCertificate(mock.Anything, mock.Anything).Return(nil).Once() + + req := DebugSendCertificateRequest{Certificate: cert, Signature: sig} + res, rpcErr := testData.sut.DebugSendCertificate(req) + require.Nil(t, rpcErr) + require.Equal(t, expectedCertHash, res) +} + +func TestDebugSendCertificate_SendCertificateError(t *testing.T) { + authKey, err := crypto.GenerateKey() + require.NoError(t, err) + authAddr := crypto.PubkeyToAddress(authKey.PublicKey) + + mockAgglayer := agglayermocks.NewAgglayerClientMock(t) + testData := newDebugAggsenderData(t, true, authAddr, mockAgglayer) + + cert := agglayertypes.Certificate{Height: 7} + hash, err := HashCertificateForDebugAuth(&cert) + require.NoError(t, err) + + sig, err := crypto.Sign(hash.Bytes(), authKey) + require.NoError(t, err) + + mockAgglayer.EXPECT().SendCertificate(mock.Anything, &cert). + Return(common.Hash{}, fmt.Errorf("agglayer error")).Once() + + req := DebugSendCertificateRequest{Certificate: cert, Signature: sig} + res, rpcErr := testData.sut.DebugSendCertificate(req) + require.Nil(t, res) + require.NotNil(t, rpcErr) + require.Contains(t, rpcErr.Error(), "agglayer error") +} + +func TestDebugSendCertificate_SaveCertificateError(t *testing.T) { + authKey, err := crypto.GenerateKey() + require.NoError(t, err) + authAddr := crypto.PubkeyToAddress(authKey.PublicKey) + + mockAgglayer := agglayermocks.NewAgglayerClientMock(t) + testData := newDebugAggsenderData(t, true, authAddr, mockAgglayer) + + cert := agglayertypes.Certificate{Height: 9} + hash, err := HashCertificateForDebugAuth(&cert) + require.NoError(t, err) + + sig, err := crypto.Sign(hash.Bytes(), authKey) + require.NoError(t, err) + + expectedCertHash := common.HexToHash("0x123456") + mockAgglayer.EXPECT().SendCertificate(mock.Anything, &cert).Return(expectedCertHash, nil).Once() + testData.mockStore.EXPECT().SaveLastSentCertificate(mock.Anything, mock.Anything). + Return(fmt.Errorf("db error")).Once() + + req := DebugSendCertificateRequest{Certificate: cert, Signature: sig} + res, rpcErr := testData.sut.DebugSendCertificate(req) + // DB save error is a warning (not fatal) — cert hash is still returned. + require.Nil(t, rpcErr) + require.Equal(t, expectedCertHash, res) +} + type aggsenderRPCTestData struct { sut *AggsenderRPC mockStore *mocks.AggsenderStorer @@ -101,6 +318,20 @@ func newAggsenderData(t *testing.T) *aggsenderRPCTestData { t.Helper() mockStore := mocks.NewAggsenderStorer(t) mockAggsender := mocks.NewAggsenderInterface(t) - sut := NewAggsenderRPC(nil, mockStore, mockAggsender) + sut := NewAggsenderRPC(nil, mockStore, mockAggsender, false, common.Address{}, nil) + return &aggsenderRPCTestData{sut, mockStore, mockAggsender} +} + +func newDebugAggsenderData( + t *testing.T, + enableDebug bool, + authAddr common.Address, + mockAgglayer *agglayermocks.AgglayerClientMock, +) *aggsenderRPCTestData { + t.Helper() + mockStore := mocks.NewAggsenderStorer(t) + mockAggsender := mocks.NewAggsenderInterface(t) + logger := log.WithFields("module", "test") + sut := NewAggsenderRPC(logger, mockStore, mockAggsender, enableDebug, authAddr, mockAgglayer) return &aggsenderRPCTestData{sut, mockStore, mockAggsender} } diff --git a/aggsender/rpc/debug_cert_hash.go b/aggsender/rpc/debug_cert_hash.go new file mode 100644 index 000000000..06743206f --- /dev/null +++ b/aggsender/rpc/debug_cert_hash.go @@ -0,0 +1,20 @@ +package aggsenderrpc + +import ( + "encoding/json" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// HashCertificateForDebugAuth serializes the certificate to JSON and returns its Keccak256 hash. +// Used to produce the message digest that the caller signs when using the debug send certificate endpoint. +func HashCertificateForDebugAuth(cert *agglayertypes.Certificate) (common.Hash, error) { + data, err := json.Marshal(cert) + if err != nil { + return common.Hash{}, fmt.Errorf("HashCertificateForDebugAuth: marshal error: %w", err) + } + return crypto.Keccak256Hash(data), nil +} diff --git a/aggsender/rpcclient/client.go b/aggsender/rpcclient/client.go index 9bf75455f..8ffbc02ad 100644 --- a/aggsender/rpcclient/client.go +++ b/aggsender/rpcclient/client.go @@ -1,11 +1,16 @@ package rpcclient import ( + "crypto/ecdsa" "encoding/json" "fmt" "github.com/0xPolygon/cdk-rpc/rpc" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + aggsenderrpc "github.com/agglayer/aggkit/aggsender/rpc" "github.com/agglayer/aggkit/aggsender/types" + ethCommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) var jSONRPCCall = rpc.JSONRPCCall @@ -56,3 +61,51 @@ func (c *Client) GetCertificateHeaderPerHeight(height *uint64) (*types.Certifica } return &cert, nil } + +// GetCertificateBridgeExits returns the bridge exits for the certificate at the given height. +// If height is nil, returns the bridge exits of the last sent certificate. +func (c *Client) GetCertificateBridgeExits(height *uint64) ([]*agglayertypes.BridgeExit, error) { + response, err := jSONRPCCall(c.url, "aggsender_getCertificateBridgeExits", height) + if err != nil { + return nil, err + } + if response.Error != nil { + return nil, fmt.Errorf("error in the response calling aggsender_getCertificateBridgeExits: %v", response.Error) + } + var exits []*agglayertypes.BridgeExit + if err := json.Unmarshal(response.Result, &exits); err != nil { + return nil, err + } + return exits, nil +} + +// DebugSendCertificate signs the certificate with the given private key and sends it via the debug endpoint. +// The hashing and signing are handled internally; callers just pass the cert and key. +func (c *Client) DebugSendCertificate( + cert *agglayertypes.Certificate, privateKey *ecdsa.PrivateKey, +) (ethCommon.Hash, error) { + hash, err := aggsenderrpc.HashCertificateForDebugAuth(cert) + if err != nil { + return ethCommon.Hash{}, fmt.Errorf("DebugSendCertificate: hash error: %w", err) + } + sig, err := crypto.Sign(hash.Bytes(), privateKey) + if err != nil { + return ethCommon.Hash{}, fmt.Errorf("DebugSendCertificate: sign error: %w", err) + } + req := aggsenderrpc.DebugSendCertificateRequest{ + Certificate: *cert, + Signature: sig, + } + response, err := jSONRPCCall(c.url, "aggsender_debugSendCertificate", req) + if err != nil { + return ethCommon.Hash{}, err + } + if response.Error != nil { + return ethCommon.Hash{}, fmt.Errorf("error in response for aggsender_debugSendCertificate: %v", response.Error) + } + var certHash ethCommon.Hash + if err := json.Unmarshal(response.Result, &certHash); err != nil { + return ethCommon.Hash{}, err + } + return certHash, nil +} diff --git a/aggsender/rpcclient/client_test.go b/aggsender/rpcclient/client_test.go index 051fc4120..22fb13841 100644 --- a/aggsender/rpcclient/client_test.go +++ b/aggsender/rpcclient/client_test.go @@ -2,10 +2,15 @@ package rpcclient import ( "encoding/json" + "fmt" + "math/big" "testing" "github.com/0xPolygon/cdk-rpc/rpc" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/aggsender/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" ) @@ -27,6 +32,32 @@ func TestGetCertificateHeaderPerHeight(t *testing.T) { require.Equal(t, responseCert, *cert) } +func TestGetCertificateBridgeExits(t *testing.T) { + sut := NewClient("url") + height := uint64(42) + responseExits := []*agglayertypes.BridgeExit{ + { + LeafType: 0, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xdeadbeef"), + Amount: big.NewInt(1000), + }, + } + responseExitsJSON, err := json.Marshal(responseExits) + require.NoError(t, err) + response := rpc.Response{ + Result: responseExitsJSON, + } + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return response, nil + } + exits, err := sut.GetCertificateBridgeExits(&height) + require.NoError(t, err) + require.Len(t, exits, 1) + require.Equal(t, responseExits[0].DestinationNetwork, exits[0].DestinationNetwork) + require.Equal(t, responseExits[0].DestinationAddress, exits[0].DestinationAddress) +} + func TestGetStatus(t *testing.T) { sut := NewClient("url") responseData := types.AggsenderInfo{} @@ -43,3 +74,147 @@ func TestGetStatus(t *testing.T) { require.NotNil(t, result) require.Equal(t, responseData, *result) } + +func TestDebugSendCertificate(t *testing.T) { + sut := NewClient("url") + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + cert := &agglayertypes.Certificate{Height: 3} + expectedHash := common.HexToHash("0xdeadbeef") + expectedHashJSON, err := json.Marshal(expectedHash) + require.NoError(t, err) + + response := rpc.Response{Result: expectedHashJSON} + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return response, nil + } + + certHash, err := sut.DebugSendCertificate(cert, privateKey) + require.NoError(t, err) + require.Equal(t, expectedHash, certHash) +} + +func TestGetStatus_Errors(t *testing.T) { + sut := NewClient("url") + + t.Run("rpc call error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{}, fmt.Errorf("network error") + } + _, err := sut.GetStatus() + require.Error(t, err) + }) + + t.Run("response error field set", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Error: &rpc.ErrorObject{Message: "rpc error"}}, nil + } + _, err := sut.GetStatus() + require.Error(t, err) + require.Contains(t, err.Error(), "aggsender_status") + }) + + t.Run("unmarshal error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Result: json.RawMessage("not-json")}, nil + } + _, err := sut.GetStatus() + require.Error(t, err) + }) +} + +//nolint:dupl +func TestGetCertificateHeaderPerHeight_Errors(t *testing.T) { + sut := NewClient("url") + height := uint64(1) + + t.Run("rpc call error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{}, fmt.Errorf("network error") + } + _, err := sut.GetCertificateHeaderPerHeight(&height) + require.Error(t, err) + }) + + t.Run("response error field set", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Error: &rpc.ErrorObject{Message: "rpc error"}}, nil + } + _, err := sut.GetCertificateHeaderPerHeight(&height) + require.Error(t, err) + require.Contains(t, err.Error(), "aggsender_getCertificateHeaderPerHeight") + }) + + t.Run("unmarshal error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Result: json.RawMessage("not-json")}, nil + } + _, err := sut.GetCertificateHeaderPerHeight(&height) + require.Error(t, err) + }) +} + +//nolint:dupl +func TestGetCertificateBridgeExits_Errors(t *testing.T) { + sut := NewClient("url") + height := uint64(5) + + t.Run("rpc call error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{}, fmt.Errorf("network error") + } + _, err := sut.GetCertificateBridgeExits(&height) + require.Error(t, err) + }) + + t.Run("response error field set", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Error: &rpc.ErrorObject{Message: "rpc error"}}, nil + } + _, err := sut.GetCertificateBridgeExits(&height) + require.Error(t, err) + require.Contains(t, err.Error(), "aggsender_getCertificateBridgeExits") + }) + + t.Run("unmarshal error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Result: json.RawMessage("not-json")}, nil + } + _, err := sut.GetCertificateBridgeExits(&height) + require.Error(t, err) + }) +} + +func TestDebugSendCertificate_Errors(t *testing.T) { + sut := NewClient("url") + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + cert := &agglayertypes.Certificate{Height: 1} + + t.Run("rpc call error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{}, fmt.Errorf("network error") + } + _, err := sut.DebugSendCertificate(cert, privateKey) + require.Error(t, err) + }) + + t.Run("response error field set", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Error: &rpc.ErrorObject{Message: "rpc error"}}, nil + } + _, err := sut.DebugSendCertificate(cert, privateKey) + require.Error(t, err) + require.Contains(t, err.Error(), "aggsender_debugSendCertificate") + }) + + t.Run("unmarshal error", func(t *testing.T) { + jSONRPCCall = func(_, _ string, _ ...interface{}) (rpc.Response, error) { + return rpc.Response{Result: json.RawMessage("not-json")}, nil + } + _, err := sut.DebugSendCertificate(cert, privateKey) + require.Error(t, err) + }) +} diff --git a/aggsender/types/types.go b/aggsender/types/types.go index f55fafece..46f3d87e3 100644 --- a/aggsender/types/types.go +++ b/aggsender/types/types.go @@ -10,8 +10,6 @@ import ( "github.com/ethereum/go-ethereum/common" ) -var EmptyLER = common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") - const ( NilStr = "nil" NAStr = "N/A" diff --git a/aggsender/validator/validate_certificate_test.go b/aggsender/validator/validate_certificate_test.go index a12e9e0c7..6cae9121e 100644 --- a/aggsender/validator/validate_certificate_test.go +++ b/aggsender/validator/validate_certificate_test.go @@ -8,6 +8,7 @@ import ( agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/aggsender/mocks" "github.com/agglayer/aggkit/aggsender/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" "github.com/agglayer/aggkit/log" treetypes "github.com/agglayer/aggkit/tree/types" @@ -79,7 +80,7 @@ func TestValidateCertificate(t *testing.T) { t.Run("first cert bad previous LER", func(t *testing.T) { testData := newTestDataCertificateValidator(t) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) err := testData.sut.ValidateCertificate(testData.ctx, types.VerifyIncomingRequest{ @@ -119,7 +120,7 @@ func TestValidateCertificate(t *testing.T) { testData := newTestDataCertificateValidator(t) testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().CalculateCertificateType(mock.Anything, uint64(10)).Return(types.CertificateTypePP) testData.mockL1InfoTreeQuerier.EXPECT(). GetL1InfoRootByLeafIndex(testData.ctx, uint32(9)).Return(nil, errGenericForTesting) @@ -127,7 +128,7 @@ func TestValidateCertificate(t *testing.T) { Certificate: &agglayertypes.Certificate{ Height: 0, L1InfoTreeLeafCount: 10, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, }, PreviousCertificate: nil, LastL2BlockInCert: 10, @@ -139,7 +140,7 @@ func TestValidateCertificate(t *testing.T) { testData := newTestDataCertificateValidator(t) testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().CalculateCertificateType(mock.Anything, uint64(10)).Return(types.CertificateTypePP) testData.mockL1InfoTreeQuerier.EXPECT(). GetL1InfoRootByLeafIndex(testData.ctx, uint32(9)).Return(&testTreeRootIndex9, nil).Maybe() @@ -149,7 +150,7 @@ func TestValidateCertificate(t *testing.T) { Certificate: &agglayertypes.Certificate{ Height: 0, L1InfoTreeLeafCount: 10, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, }, PreviousCertificate: nil, LastL2BlockInCert: 10, @@ -161,7 +162,7 @@ func TestValidateCertificate(t *testing.T) { testData := newTestDataCertificateValidator(t) testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().CalculateCertificateType(mock.Anything, uint64(10)).Return(types.CertificateTypePP) testData.mockL1InfoTreeQuerier.EXPECT(). GetL1InfoRootByLeafIndex(testData.ctx, uint32(9)).Return(&testTreeRootIndex9, nil).Maybe() @@ -173,7 +174,7 @@ func TestValidateCertificate(t *testing.T) { Certificate: &agglayertypes.Certificate{ Height: 0, L1InfoTreeLeafCount: 10, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, }, PreviousCertificate: nil, LastL2BlockInCert: 10, @@ -185,7 +186,7 @@ func TestValidateCertificate(t *testing.T) { testData := newTestDataCertificateValidator(t) testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().CalculateCertificateType(mock.Anything, uint64(10)).Return(types.CertificateTypePP) testData.mockL1InfoTreeQuerier.EXPECT(). GetL1InfoRootByLeafIndex(testData.ctx, uint32(9)).Return(&testTreeRootIndex9, nil).Maybe() @@ -197,7 +198,7 @@ func TestValidateCertificate(t *testing.T) { Certificate: &agglayertypes.Certificate{ Height: 0, L1InfoTreeLeafCount: 10, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, }, PreviousCertificate: nil, LastL2BlockInCert: 10, @@ -210,12 +211,12 @@ func TestValidateCertificate(t *testing.T) { certificate := &agglayertypes.Certificate{ Height: 0, L1InfoTreeLeafCount: 10, - PrevLocalExitRoot: types.EmptyLER, + PrevLocalExitRoot: bridgesynctypes.EmptyLER, } testData.mockCertQuerier.EXPECT().GetLastSettledCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(0), nil) testData.mockCertQuerier.EXPECT().GetNewCertificateToBlock(testData.ctx, mock.Anything).Return(uint64(10), nil) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) testData.mockCertQuerier.EXPECT().CalculateCertificateType(mock.Anything, uint64(10)).Return(types.CertificateTypePP) testData.mockL1InfoTreeQuerier.EXPECT(). GetL1InfoRootByLeafIndex(testData.ctx, uint32(9)).Return(&testTreeRootIndex9, nil).Maybe() @@ -240,7 +241,7 @@ func TestValidateCertificate(t *testing.T) { func TestCheckContigousCertificates(t *testing.T) { t.Run("Nil PreviousCertificate, err getting start LER", func(t *testing.T) { testData := newTestDataCertificateValidator(t) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, errors.New("some error")) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, errors.New("some error")) err := testData.sut.checkContigousCertificates(types.VerifyIncomingRequest{ Certificate: &agglayertypes.Certificate{ Height: 0, @@ -263,7 +264,7 @@ func TestCheckContigousCertificates(t *testing.T) { t.Run("Nil PreviousCertificate, cert height == 0, non expected LER", func(t *testing.T) { testData := newTestDataCertificateValidator(t) - testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(types.EmptyLER, nil) + testData.mockLERQuerier.EXPECT().GetLastLocalExitRoot().Return(bridgesynctypes.EmptyLER, nil) err := testData.sut.checkContigousCertificates(types.VerifyIncomingRequest{ Certificate: &agglayertypes.Certificate{ Height: 0, diff --git a/bridgesync/processor.go b/bridgesync/processor.go index 7c932b698..acfbeda29 100644 --- a/bridgesync/processor.go +++ b/bridgesync/processor.go @@ -14,6 +14,7 @@ import ( bridgetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgesync/migrations" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" aggkitcommon "github.com/agglayer/aggkit/common" "github.com/agglayer/aggkit/db" "github.com/agglayer/aggkit/db/compatibility" @@ -113,11 +114,13 @@ const ( " ORDER BY block_num ASC, block_pos ASC" // 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 AND origin_network = 0 LIMIT 1" + " WHERE deposit_count = $1 LIMIT 1" // archiveByDepositCountSQL is the query used by GetBridgeByDepositCount for bridge_archive. - archiveByDepositCountSQL = `SELECT * FROM bridge_archive WHERE deposit_count = $1 AND origin_network = 0 LIMIT 1` + archiveByDepositCountSQL = `SELECT * FROM bridge_archive WHERE 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" + @@ -1535,7 +1538,7 @@ func (p *processor) restoreBackwardLETBridges(tx dbtypes.Txer, backwardLETs []*B restoreQuery := ` SELECT * FROM bridge_archive - WHERE deposit_count > $1 AND deposit_count <= $2 + WHERE deposit_count >= $1 AND deposit_count <= $2 ORDER BY deposit_count ASC ` @@ -1579,7 +1582,7 @@ func (p *processor) restoreBackwardLETBridges(tx dbtypes.Txer, backwardLETs []*B // cleanup bridge_archive if _, err := tx.Exec(` DELETE FROM bridge_archive - WHERE deposit_count > $1 AND deposit_count <= $2 + WHERE deposit_count >= $1 AND deposit_count <= $2 `, next, prev); err != nil { return err } @@ -1622,11 +1625,7 @@ func (p *processor) ProcessBlock(ctx context.Context, block sync.Block) error { return sync.ErrInconsistentState } - // Create a context with database timeout for the transaction - dbCtx, cancel := p.withDatabaseTimeout(ctx) - defer cancel() - - tx, err := db.NewTx(dbCtx, p.db) + tx, err := db.NewTx(ctx, p.db) if err != nil { p.log.Errorf("failed to start transaction for block %d: %v", block.Num, err) return err @@ -1734,19 +1733,31 @@ func (p *processor) ProcessBlock(ctx context.Context, block sync.Block) error { return err } - // 1. archive and remove all the bridges whose - // deposit_count is greater than the one captured by the BackwardLET event + // 1. archive and remove all bridges at deposit_count >= newDepositCount. + // After BackwardLET to NewDepositCount=N, leaves 0..N-1 remain valid; + // any bridge at DC=N or above is no longer in the exit tree. err = p.archiveAndDeleteBridgesAbove(ctx, tx, newDepositCount) if err != nil { return fmt.Errorf("failed to delete bridges above deposit count %d: %w", newDepositCount, err) } - // 2. remove all leafs from the exit tree with indices greater than leafIndex in the exit tree - if err := p.exitTree.BackwardToIndex(ctx, tx, leafIndex); err != nil { - p.log.Errorf("failed to backward local exit tree to leaf index %d (deposit count: %d)", - leafIndex, newDepositCount) - return err + // 2. Remove leaves from the exit tree so that exactly newDepositCount leaves remain. + // BackwardToIndex(N) keeps positions 0..N (N+1 leaves). To keep exactly newDepositCount + // leaves (positions 0..newDepositCount-1), we call BackwardToIndex(newDepositCount-1). + // Special case: for newDepositCount==0 the tree must be fully cleared, so use Reorg(0) + // which deletes all root entries (block_num >= 0 = all rows). + if leafIndex == 0 { + if err := p.exitTree.Reorg(tx, 0); err != nil { + p.log.Errorf("failed to clear exit tree for BackwardLET to DC=0: %v", err) + return err + } + } else { + if err := p.exitTree.BackwardToIndex(ctx, tx, leafIndex-1); err != nil { + p.log.Errorf("failed to backward local exit tree to leaf index %d (deposit count: %d)", + leafIndex, newDepositCount) + return err + } } // 4. sanity check that the new root matches the latest one in the exit tree @@ -1820,10 +1831,12 @@ func normalizeDepositCount(depositCount *big.Int) (uint64, uint32, error) { return u64, u32, nil } -// archiveAndDeleteBridgesAbove archives and removes all the bridges whose depositCount is greater than the provided one +// archiveAndDeleteBridgesAbove archives and removes all the bridges whose depositCount is greater than or equal to +// the provided one. After a BackwardLET to DC=N, leaves 0..N-1 remain valid; any bridge at deposit_count>=N +// is no longer present in the exit tree and must be archived and removed. func (p *processor) archiveAndDeleteBridgesAbove(ctx context.Context, tx dbtypes.Txer, depositCount uint64) error { // 1. Load candidates - query := fmt.Sprintf(`SELECT * FROM %s WHERE deposit_count > $1`, bridgeTableName) + query := fmt.Sprintf(`SELECT * FROM %s WHERE deposit_count >= $1`, bridgeTableName) var bridges []*Bridge if err := meddler.QueryAll(tx, &bridges, query, depositCount); err != nil { return err @@ -1836,9 +1849,20 @@ func (p *processor) archiveAndDeleteBridgesAbove(ctx context.Context, tx dbtypes deletedDepositCounts := make([]uint32, 0, len(bridges)) // 2. Archive for _, b := range bridges { - b.Source = BridgeSourceBackwardLET - if err := meddler.Insert(tx, "bridge_archive", b); err != nil { - return err + // Skip if already archived (can happen when a ForwardLET re-inserts a bridge that + // was previously archived by an earlier BackwardLET, and then a new BackwardLET + // targets the same deposit_count again). + var count int + if err := tx.QueryRowContext(ctx, + "SELECT COUNT(*) FROM bridge_archive WHERE deposit_count = ?", b.DepositCount, + ).Scan(&count); err != nil { + return fmt.Errorf("failed to check bridge_archive for deposit_count %d: %w", b.DepositCount, err) + } + if count == 0 { + b.Source = BridgeSourceBackwardLET + if err := meddler.Insert(tx, "bridge_archive", b); err != nil { + return err + } } deletedDepositCounts = append(deletedDepositCounts, b.DepositCount) } @@ -1846,7 +1870,7 @@ func (p *processor) archiveAndDeleteBridgesAbove(ctx context.Context, tx dbtypes // 3. Delete originals deleteQuery := fmt.Sprintf(` DELETE FROM %s - WHERE deposit_count > $1`, + WHERE deposit_count >= $1`, bridgeTableName) _, err := tx.ExecContext(ctx, deleteQuery, depositCount) @@ -1855,7 +1879,7 @@ func (p *processor) archiveAndDeleteBridgesAbove(ctx context.Context, tx dbtypes } if len(deletedDepositCounts) > 0 { - p.log.Debugf("BackwardLET archived + removed %d bridges with deposit_count > %d: %v", + p.log.Debugf("BackwardLET archived + removed %d bridges with deposit_count >= %d: %v", len(deletedDepositCounts), depositCount, deletedDepositCounts, ) } @@ -1877,6 +1901,15 @@ func (p *processor) sanityCheckLatestLER(tx dbtypes.Txer, ler common.Hash) error lastRootHash = root.Hash } + if ler == bridgesynctypes.EmptyLER { + // if the provided LER is the empty hash, the LER on the DB should be 0x00...0 + if lastRootHash != aggkitcommon.ZeroHash { + return fmt.Errorf("local exit root mismatch: expected %s, got %s. Note that %s is used to represent the empty LER", + aggkitcommon.ZeroHash.String(), lastRootHash.String(), bridgesynctypes.EmptyLER.String()) + } + return nil + } + if lastRootHash != ler { return fmt.Errorf("local exit root mismatch: expected %s, got %s", ler.String(), lastRootHash.String()) @@ -1898,7 +1931,13 @@ func (p *processor) handleForwardLETEvent(tx dbtypes.Txer, event *ForwardLET, bl return 0, fmt.Errorf("failed to decode new leaves in forward LET: %w", err) } - newDepositCount := uint32(event.PreviousDepositCount.Uint64()) + 1 + // PreviousDepositCount is the number of leaves already in the tree before this ForwardLET, + // which equals the deposit_count (leaf index) to assign to the first new leaf. + // When PreviousRoot is EmptyLER the tree is empty, so the first leaf index is 0 (Go zero value). + var newDepositCount uint32 + if event.PreviousRoot != bridgesynctypes.EmptyLER { + newDepositCount = uint32(event.PreviousDepositCount.Uint64()) + } newBlockPos := event.BlockPos if blockPos != nil { newBlockPos = *blockPos @@ -1937,20 +1976,26 @@ func (p *processor) handleForwardLETEvent(tx dbtypes.Txer, event *ForwardLET, bl fromAddrPtr *common.Address ) - // let's see if we have exactly one archived bridge that matches the forward LET leaf + // let's see if we have exactly one archived bridge that matches the forward LET leaf. // usually we should have exactly one match since to recover the LET on L2, // we must have a backwards LET done which archives the bridges, - // and then a forward LET that re-adds them to the exit tree after fixing it - // however, in case of multiple matches, we cannot be sure which one to use, - // so we will just log and leave the txnSender and fromAddr fields empty - if len(archivedBridges) == 1 { + // and then a forward LET that re-adds them to the exit tree after fixing it. + // however, this is not always the case (e.g. when a ForwardLET is issued without + // a preceding BackwardLET). When no match is found, or when there are multiple matches + // (in which case we cannot determine which one to use), we leave the txnSender and + // fromAddr fields empty. + switch len(archivedBridges) { + case 1: archivedBridge := archivedBridges[0] txnHash = archivedBridge.TxHash txnSender = archivedBridge.TxnSender // It copies the fromAddr pointer, which could be nil fromAddrPtr = archivedBridge.FromAddress - } else if len(archivedBridges) > 1 { - p.log.Warnf("multiple archived bridges found that match forward LET leaf %s;"+ + case 0: + p.log.Warnf("no archived bridge found that matches forward LET leaf %s; "+ + "txnSender and fromAddr fields will be left empty", leaf.String()) + default: + p.log.Warnf("multiple archived bridges found that match forward LET leaf %s; "+ "cannot set txnSender and fromAddr fields to the bridge", leaf.String()) } diff --git a/bridgesync/processor_test.go b/bridgesync/processor_test.go index b140a658e..06399429f 100644 --- a/bridgesync/processor_test.go +++ b/bridgesync/processor_test.go @@ -490,7 +490,7 @@ var ( PreviousDepositCount: big.NewInt(3), NewDepositCount: big.NewInt(2), PreviousRoot: common.HexToHash("0x15cd4b94cacc2cf50d055e1adb5fbfe5cd95485e121a5c411d73e263f2a66685"), - NewRoot: common.HexToHash("0xa03113d9ce128863f29479689c82d0b37ebc9432c569c3a57f22d6c008256c5b"), + NewRoot: common.HexToHash("0x3edb955a657301c8007f91a0e8d2fcf7017f3dadd194aad8340018b5a5a580fa"), }}, }, } @@ -5545,15 +5545,15 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(3), NewDepositCount: big.NewInt(2), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), + NewRoot: common.HexToHash("0x0cc5d7d6281795bc0a4d3dff706ef63097c4eb288a311aa2b3098e838f9d9248"), }}, }, }) return blocks }, - targetDepositCount: 2, - archivedDepositCounts: []uint32{3}, + targetDepositCount: 1, + archivedDepositCounts: []uint32{2, 3, 4, 5}, }, { name: "backward let event with all the bridges, except the first one", @@ -5567,7 +5567,7 @@ func TestProcessor_BackwardLET(t *testing.T) { BlockNum: uint64(len(blocks) + 1), BlockPos: 0, PreviousDepositCount: big.NewInt(5), - NewDepositCount: big.NewInt(0), + NewDepositCount: big.NewInt(1), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), NewRoot: common.HexToHash("0x283c52c3d10a22d01f95f5bcab5e823675c9855bd40b1e82f32b0437b3b6a446"), }}, @@ -5593,7 +5593,7 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(5), NewDepositCount: big.NewInt(4), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0x44e1bf8449ecec2b8b1d123fab00d33c9acb308e590605adf5f6e2de4d1c1133"), + NewRoot: common.HexToHash("0x7533c9ef58edd0bea7959a20c33ed47e5548d35f4ff140c5c915740fe6800fb8"), }}, }, } @@ -5601,13 +5601,13 @@ func TestProcessor_BackwardLET(t *testing.T) { return blocks }, - targetDepositCount: 4, - archivedDepositCounts: []uint32{5}, + targetDepositCount: 3, + archivedDepositCounts: []uint32{4, 5}, }, { name: "backward let event in the middle of bridges", setupBlocks: func() []sync.Block { - blocks := buildBlocksWithSequentialBridges(2, 3, 0, 0) + blocks := buildBlocksWithSequentialBridges(3, 2, 0, 0) backwardLETBlock := sync.Block{ Num: uint64(len(blocks) + 1), Hash: common.HexToHash(fmt.Sprintf("0x%x", len(blocks)+1)), @@ -5618,18 +5618,18 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(5), NewDepositCount: big.NewInt(2), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), + NewRoot: common.HexToHash("0x0cc5d7d6281795bc0a4d3dff706ef63097c4eb288a311aa2b3098e838f9d9248"), }}, }, } blocks = append(blocks, backwardLETBlock) - blocks = append(blocks, buildBlocksWithSequentialBridges(3, 2, uint64(len(blocks)), 3)...) + blocks = append(blocks, buildBlocksWithSequentialBridges(3, 2, uint64(len(blocks)), 2)...) return blocks }, - targetDepositCount: 8, + targetDepositCount: 7, skipBlocks: []uint64{2, 3}, // all the bridges from these blocks were backwarded - archivedDepositCounts: []uint32{3, 4, 5}, + archivedDepositCounts: []uint32{2, 3, 4, 5}, }, { name: "overlapping backward let events", @@ -5645,7 +5645,7 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(5), NewDepositCount: big.NewInt(3), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0x7533c9ef58edd0bea7959a20c33ed47e5548d35f4ff140c5c915740fe6800fb8"), + NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), }}, }, }) @@ -5658,16 +5658,16 @@ func TestProcessor_BackwardLET(t *testing.T) { BlockPos: 0, PreviousDepositCount: big.NewInt(4), NewDepositCount: big.NewInt(3), - PreviousRoot: common.HexToHash("0x7533c9ef58edd0bea7959a20c33ed47e5548d35f4ff140c5c915740fe6800fb8"), - NewRoot: common.HexToHash("0x7533c9ef58edd0bea7959a20c33ed47e5548d35f4ff140c5c915740fe6800fb8"), + PreviousRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), + NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), }}, }, }) return blocks }, - targetDepositCount: 3, - archivedDepositCounts: []uint32{4, 5}, + targetDepositCount: 2, + archivedDepositCounts: []uint32{3, 4, 5}, }, { name: "backward let on empty bridge table", @@ -5740,7 +5740,7 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(5), NewDepositCount: big.NewInt(2), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), + NewRoot: common.HexToHash("0x0cc5d7d6281795bc0a4d3dff706ef63097c4eb288a311aa2b3098e838f9d9248"), }}, }, } @@ -5750,12 +5750,12 @@ func TestProcessor_BackwardLET(t *testing.T) { }, firstReorgedBlock: uint64Ptr(3), targetDepositCount: 3, - archivedDepositCounts: []uint32{3}, + archivedDepositCounts: []uint32{2, 3, 4, 5}, }, { name: "backward let event in the middle of bridges + reorg backward let", setupBlocks: func() []sync.Block { - blocks := buildBlocksWithSequentialBridges(2, 3, 0, 0) + blocks := buildBlocksWithSequentialBridges(3, 2, 0, 0) backwardLETBlock := sync.Block{ Num: uint64(len(blocks) + 1), Hash: common.HexToHash(fmt.Sprintf("0x%x", len(blocks)+1)), @@ -5766,18 +5766,18 @@ func TestProcessor_BackwardLET(t *testing.T) { PreviousDepositCount: big.NewInt(5), NewDepositCount: big.NewInt(2), PreviousRoot: common.HexToHash("0x9ba667158a062be548e5c1b2e8a9a2ad03b693e562535b0723880627c6664b02"), - NewRoot: common.HexToHash("0xa9d31ebbb97c7cd7c7103bee8af7d0b4c83771939baba0b415b0f94c4c39fd84"), + NewRoot: common.HexToHash("0x0cc5d7d6281795bc0a4d3dff706ef63097c4eb288a311aa2b3098e838f9d9248"), }}, }, } blocks = append(blocks, backwardLETBlock) - blocks = append(blocks, buildBlocksWithSequentialBridges(3, 2, uint64(len(blocks)), 3)...) + blocks = append(blocks, buildBlocksWithSequentialBridges(3, 2, uint64(len(blocks)), 2)...) return blocks }, firstReorgedBlock: uint64Ptr(3), - targetDepositCount: 5, - archivedDepositCounts: []uint32{3, 4, 5}, + targetDepositCount: 3, + archivedDepositCounts: []uint32{2, 3, 4, 5}, }, } @@ -5983,7 +5983,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 5, BlockTimestamp: 1234567890, TxnHash: common.HexToHash("0xabc123"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewLeaves: encodedLeaves, @@ -6088,7 +6088,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 10, BlockTimestamp: 1234567900, TxnHash: common.HexToHash("0xdef456"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + uint32(len(leaves)))), NewLeaves: encodedLeaves, @@ -6187,7 +6187,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 20, BlockTimestamp: 1234567950, TxnHash: common.HexToHash("0xforward789"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewLeaves: encodedLeaves, @@ -6303,7 +6303,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 30, BlockTimestamp: 1234567999, TxnHash: common.HexToHash("0xforward999"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewLeaves: encodedLeaves, @@ -6364,7 +6364,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 5, BlockTimestamp: 1234567890, TxnHash: common.HexToHash("0xabc123"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: common.HexToHash("0xWRONG"), // Wrong root NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewRoot: common.HexToHash("0x999"), @@ -6421,7 +6421,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 5, BlockTimestamp: 1234567890, TxnHash: common.HexToHash("0xabc123"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewRoot: common.HexToHash("0xWRONG"), // Wrong new root @@ -6454,7 +6454,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 5, BlockTimestamp: 1234567890, TxnHash: common.HexToHash("0xabc123"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewRoot: common.Hash{}, @@ -6509,7 +6509,7 @@ func TestHandleForwardLETEvent(t *testing.T) { BlockPos: 5, BlockTimestamp: 1234567890, TxnHash: common.HexToHash("0xabc123"), - PreviousDepositCount: big.NewInt(int64(initialDepositCount)), + PreviousDepositCount: big.NewInt(int64(initialDepositCount + 1)), PreviousRoot: initialRoot, NewDepositCount: big.NewInt(int64(initialDepositCount + 1)), NewLeaves: encodedLeaves, @@ -6530,6 +6530,86 @@ func TestHandleForwardLETEvent(t *testing.T) { require.Len(t, bridges, 1) require.Equal(t, event.BlockPos, bridges[0].BlockPos) }) + + t.Run("ForwardLET after genesis assigns deposit_count starting at 0", func(t *testing.T) { + // Covers the EmptyLER branch: when the tree is empty (PreviousRoot == EmptyLER), + // newDepositCount must start at 0 (the Go zero value), independent of PreviousDepositCount. + p, tx := setupProcessorWithTransaction(t) + defer tx.Rollback() //nolint:errcheck + + // Insert block for the ForwardLET event (no prior leaves — tree is empty) + _, err := tx.Exec(`INSERT INTO block (num) VALUES ($1)`, uint64(200)) + require.NoError(t, err) + + leaves := []LeafData{ + { + LeafType: 0, + OriginNetwork: 1, + OriginAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + DestinationNetwork: 2, + DestinationAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), + Amount: big.NewInt(500), + Metadata: []byte("genesis leaf"), + }, + { + LeafType: 0, + OriginNetwork: 1, + OriginAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + DestinationNetwork: 2, + DestinationAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + Amount: big.NewInt(750), + Metadata: []byte("genesis leaf 2"), + }, + } + encodedLeaves := encodeLeafDataArrayForTest(t, leaves) + + event := &ForwardLET{ + BlockNum: 200, + BlockPos: 0, + BlockTimestamp: 9999999, + TxnHash: common.HexToHash("0xgenesis"), + PreviousDepositCount: big.NewInt(0), + PreviousRoot: bridgesynctypes.EmptyLER, // tree is empty + NewDepositCount: big.NewInt(2), + NewLeaves: encodedLeaves, + } + + // Compute expected root by inserting leaves into a temp tree starting at index 0 + tempDBPath := filepath.Join(t.TempDir(), "temp_genesis.db") + err = migrations.RunMigrations(tempDBPath) + require.NoError(t, err) + tempP, err := newProcessor(tempDBPath, "test-genesis", log.WithFields("module", "test-genesis"), dbQueryTimeout) + require.NoError(t, err) + tempTx, err := db.NewTx(t.Context(), tempP.db) + require.NoError(t, err) + defer tempTx.Rollback() //nolint:errcheck + _, err = tempTx.Exec(`INSERT INTO block (num) VALUES ($1)`, uint64(200)) + require.NoError(t, err) + var expectedRoot common.Hash + for i, leaf := range leaves { + bridge := leaf.ToBridge(200, uint64(i), 9999999, uint32(i), event.TxnHash, common.Address{}, nil) + expectedRoot, err = tempP.exitTree.PutLeaf(tempTx, 200, uint64(i), types.Leaf{ + Index: uint32(i), + Hash: bridge.Hash(), + }) + require.NoError(t, err) + } + event.NewRoot = expectedRoot + + blockPos := event.BlockPos + newBlockPos, err := p.handleForwardLETEvent(tx, event, &blockPos) + require.NoError(t, err) + require.Equal(t, uint64(len(leaves)), newBlockPos) + + var bridges []*Bridge + err = meddler.QueryAll(tx, &bridges, "SELECT * FROM bridge WHERE block_num = $1 ORDER BY deposit_count", event.BlockNum) + require.NoError(t, err) + require.Len(t, bridges, 2) + + // First leaf must get deposit_count=0, second must get deposit_count=1 + require.Equal(t, uint32(0), bridges[0].DepositCount) + require.Equal(t, uint32(1), bridges[1].DepositCount) + }) } // setupProcessorWithTransaction creates a processor and begins a transaction for testing diff --git a/bridgesync/types/types.go b/bridgesync/types/types.go index 3a1dc75cb..ae839f6ee 100644 --- a/bridgesync/types/types.go +++ b/bridgesync/types/types.go @@ -4,8 +4,12 @@ import ( "fmt" "math/big" "strings" + + "github.com/ethereum/go-ethereum/common" ) +var EmptyLER = common.HexToHash("0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757") + type Unclaim struct { GlobalIndex *big.Int `json:"global_index"` BlockNumber uint64 `json:"block_number"` diff --git a/docs/aggsender.md b/docs/aggsender.md index 976227af7..53d4e9cbb 100644 --- a/docs/aggsender.md +++ b/docs/aggsender.md @@ -195,6 +195,8 @@ The certificate is the data submitted to `Agglayer`. Must be signed to be accept | MaxL2BlockNumber | uint64 | Set the last block to be included in a certificate (0 = disabled) |StopOnFinishedSendingAllCertificates| bool | Stop when there are no more certificates to send due to MaxL2BlockNumber |StorageRetainCertificatesPolicy| [StorageRetainCertificatesPolicy](#storageretaincertificatespolicy) | Configure the certificate retain policy +| EnableDebugSendCertificate | bool | Enables the `aggsender_debugSendCertificate` RPC endpoint for sending arbitrary certificates. When `true`, the normal certificate-sending loop is **disabled**. Default `false`. **Never enable in production.** | +| DebugSendCertificateAuthAddress | Address | Ethereum address whose signature is required to authorize calls to the debug send endpoint. Only used when `EnableDebugSendCertificate` is `true`. | | UnsetClaimsMaxLogBlockRange | uint64 | Proactive max block range for `eth_getLogs` queries when fetching unset claims. 0 means disabled (fallback to reactive chunking on error) ## StorageRetainCertificatesPolicy diff --git a/docs/backward_forward_let_runbook.md b/docs/backward_forward_let_runbook.md new file mode 100644 index 000000000..62299c834 --- /dev/null +++ b/docs/backward_forward_let_runbook.md @@ -0,0 +1,984 @@ +# Backward and Forward LET runbook + +## Introduction + +The **Local Exit Tree (LET)** is a Merkle tree maintained on L2 that tracks all bridge deposits originating from a given chain. Every time a bridge operation occurs on L2, a new leaf is appended to the LET. Periodically, the `aggsender` component bundles these leaves into a certificate and sends it to the AggLayer, which settles the resulting **Local Exit Root (LER)** on L1. + +Under normal operation, the LET on L2 and the LER settled on L1 stay in sync. However, certain failure scenarios can cause them to **diverge**: L1 has a settled LER that does not match the actual state of the LET on L2. When this happens, the L2 network must reconcile its LET to match what was settled on L1, otherwise future certificates will be rejected by the AggLayer because the LER will not match. + +To handle these cases, two admin smart contract functions are provided on the [`AgglayerBridgeL2`](https://agglayer.github.io/protocol-team-docs/smart-contracts/v12/AgglayerBridgeL2/) contract: + +- **[`backwardLET`](https://agglayer.github.io/protocol-team-docs/smart-contracts/v12/AgglayerBridgeL2/#13-backwardlet)**: Rolls the LET backward to a previous state with fewer deposits. This is used to remove leaves that were added on L2 but do not match what was settled on L1. ([source](https://github.com/agglayer/agglayer-contracts/blob/v12.2.0/contracts/sovereignChains/AgglayerBridgeL2.sol#L732)) +- **[`forwardLET`](https://agglayer.github.io/protocol-team-docs/smart-contracts/v12/AgglayerBridgeL2/#14-forwardlet)**: Advances the LET by adding one or more leaves in a single transaction. This is used to insert leaves that were settled on L1 but are missing from the L2 tree. ([source](https://github.com/agglayer/agglayer-contracts/blob/v12.2.0/contracts/sovereignChains/AgglayerBridgeL2.sol#L797)) + +Both functions can **only** be called while the `AgglayerBridgeL2` contract is in **emergency mode**, and only by an account holding the `GlobalExitRootRemover` role. + +## Prerequisites + +Before starting, ensure you have these environment variables set. They are referenced throughout the runbook: + +```bash +# ── Network RPC endpoints ── +export L2_RPC_URL="" + +# ── Contract addresses (L2) ── +export BRIDGE_L2_ADDR="" +export GER_L2_ADDR="" + +# ── AggLayer endpoints ── +export AGGLAYER_GRPC="" + +# ── Bridge service endpoint ── +export BRIDGE_SERVICE_URL="" # e.g. http://localhost:8080/bridge/v1 + +# ── Network ID of the affected L2 chain ── +export NETWORK_ID="" + +# ── Private key of the account holding the GlobalExitRootRemover role ── +# This same account is used for backwardLET and forwardLET calls. +# For activateEmergencyState/deactivateEmergencyState, the emergencyBridgePauser +# and emergencyBridgeUnpauser keys are needed respectively (may be different accounts). +export GER_REMOVER_PK="" +export EMERGENCY_PAUSER_PK="" +export EMERGENCY_UNPAUSER_PK="" +``` + +### Verify role addresses + +Before proceeding, confirm which accounts hold each role: + +```bash +# Who can call backwardLET / forwardLET (GlobalExitRootRemover)? +cast call $GER_L2_ADDR "globalExitRootRemover()(address)" --rpc-url $L2_RPC_URL + +# Who can activate emergency state? +cast call $BRIDGE_L2_ADDR "emergencyBridgePauser()(address)" --rpc-url $L2_RPC_URL + +# Who can deactivate emergency state? +cast call $BRIDGE_L2_ADDR "emergencyBridgeUnpauser()(address)" --rpc-url $L2_RPC_URL +``` + +## Detection + +A backward/forward LET operation is needed when the LER settled on L1 diverges from the LET state on L2. This can be detected through the following indicators: + +### 1. Certificate rejected by the AggLayer + +The `aggsender` submits a certificate to the AggLayer, which rejects it because the `PrevLocalExitRoot` in the certificate does not match the last settled LER on L1. This is the most common first signal of divergence. + +The certificate transitions to `InError` status on the AggLayer side. The `aggsender` detects this via its periodic status checker and logs: + +| File | Line | Level | Message | +|------|------|-------|---------| +| `aggsender/statuschecker/cert_status_checker.go` | 187 | `INFO` | `certificate changed status from [] to [InError] elapsed time: full_cert (agglayer): ` | +| `aggsender/statuschecker/cert_status_checker.go` | 169 | `INFO` | `found InError certificate(s) with no pending certs, enabling retry` | +| `aggsender/aggsender.go` | 332 | `INFO` | `An InError cert exists. Sending a new one ()` | +| `aggsender/aggsender.go` | 365 | `ERROR` | `Certificate send trigger: error sending certificate: ` | +| `aggsender/aggsender.go` | 536 | `ERROR` | `error creating non accepted certificate: . Err: ` | +| `aggsender/aggsender.go` | 541 | `ERROR` | `error saving non accepted certificate: . Err: ` | + +**Recommended alarms**: alert on the `InError` status transition (`INFO` log at `cert_status_checker.go:187` matching `"changed status from.*to \[InError\]"`) and on the `ERROR` at `aggsender.go:365` (`"Certificate send trigger: error sending certificate"`). + +### 2. LER mismatch detected during certificate validation + +When the `aggsender` attempts to build and validate a new certificate, the local validator compares the certificate's `PrevLocalExitRoot` against the expected value. A mismatch surfaces as an error in the following paths: + +| File | Line | Level | Message | +|------|------|-------|---------| +| `aggsender/validator/validate_certificate.go` | 155 | `ERROR` (via `fmt.Errorf`) | `certificate PrevLocalExitRoot is not equal to previous certificate NewLocalExitRoot ` | +| `aggsender/validator/validate_certificate.go` | 196 | `ERROR` (via `fmt.Errorf`) | `first certificate must have correct starting PrevLocalExitRoot: , but got: ` | +| `aggsender/aggsender.go` | 432 | `WARN` | `error validating certificate locally: ` | +| `aggsender/aggsender.go` | 329 | `ERROR` | `error checking last certificate from agglayer: ` | + +**Recommended alarms**: alert on `WARN` at `aggsender.go:432` (`"error validating certificate locally"`) and on any log containing `"PrevLocalExitRoot"` and `"is not equal"` or `"but got"`. + +### 3. AggSender unable to build or send certificates + +When the `aggsender` repeatedly fails to build or submit a valid certificate (e.g., after a restart following a key compromise), it logs continuously on each retry cycle: + +| File | Line | Level | Message | +|------|------|-------|---------| +| `aggsender/aggsender.go` | 419 | `ERROR` (via `fmt.Errorf`) | `error getting certificate build params: ` | +| `aggsender/aggsender.go` | 428 | `ERROR` (via `fmt.Errorf`) | `error building certificate: ` | +| `aggsender/aggsender.go` | 460 | `ERROR` (via `fmt.Errorf`) | `error sending certificate: ` | +| `aggsender/aggsender.go` | 365 | `ERROR` | `Certificate send trigger: error sending certificate: ` | +| `aggsender/aggsender.go` | 359 | `ERROR` | `Certificate send trigger: error checking certificate status: ` | + +**Recommended alarms**: alert on repeated occurrences of `ERROR` at `aggsender.go:365` (`"Certificate send trigger: error sending certificate"`). A single occurrence may be transient; sustained repetition indicates a structural issue requiring investigation. + +--- + +**Root causes** that can trigger this divergence include: + +- **Compromised or buggy `aggsender`**: The `aggsender` private key is compromised or the component has a bug, causing it to craft and submit a certificate with leaves that do not correspond to actual L2 bridge events. +- **L2 network reorg (outpost networks)**: The L2 network reorgs after a certificate has already been settled on L1, meaning the block that contained certain bridge events no longer exists or has different contents. + +## Diagnosis + +Once detection signals indicate a divergence, the next step is to **determine the exact state on both sides** and identify which recovery case applies. This section provides concrete commands to gather all the data needed. + +### Step 1: Query the AggLayer for settled state (L1 truth) + +The AggLayer's `GetNetworkInfo` gRPC call returns the last settled certificate details including the settled LER and leaf count: + +```bash +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo +``` + +From the response, extract: +- `settled_ler` — the LER that L1 considers as truth +- `settled_let_leaf_count` — the deposit count at which L1 settled (this is the **L1 deposit count**) +- `settled_height` — the certificate height of the last settled certificate +- `settled_certificate_id` — the ID of that certificate + +To get the full details of the last settled certificate: + +```bash +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID, \"type\": \"LATEST_CERTIFICATE_REQUEST_TYPE_SETTLED\"}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetLatestCertificateHeader +``` + +This returns a `CertificateHeader` with: +- `prev_local_exit_root` — what the AggLayer expected as the starting LER +- `new_local_exit_root` — the LER after applying this certificate's leaves +- `height` — certificate height +- `status` — should be `SETTLED` (5) + +If there is also a pending (possibly InError) certificate: + +```bash +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID, \"type\": \"LATEST_CERTIFICATE_REQUEST_TYPE_PENDING\"}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetLatestCertificateHeader +``` + +If `status` is `IN_ERROR` (4), the `error` field will contain the rejection reason. + +### Step 2: Query the L2 bridge contract for current state + +```bash +# Current deposit count on L2 +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL + +# Current LER (Merkle root of the LET) on L2 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL + +# Is the bridge in emergency state? +cast call $BRIDGE_L2_ADDR "isEmergencyState()(bool)" --rpc-url $L2_RPC_URL + +# Network ID (sanity check) +cast call $BRIDGE_L2_ADDR "networkID()(uint32)" --rpc-url $L2_RPC_URL +``` + +### Step 3: Query the bridge service for sync status + +The bridge service exposes a sync status endpoint that compares on-chain deposit counts with its local database: + +```bash +curl -s "$BRIDGE_SERVICE_URL/sync-status" | jq . +``` + +The response includes: +- `l2_info.contract_deposit_count` — on-chain deposit count +- `l2_info.synchronized_deposit_count` — how far the bridge service has synced +- `l2_info.is_synced` — whether the syncer is caught up + +### Step 4: Compare L1 vs L2 and determine the case + +Save the key values: + +```bash +# From AggLayer (Step 1) +L1_SETTLED_LER="" +L1_DEPOSIT_COUNT="" + +# From L2 contract (Step 2) +L2_LER=$(cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL) +L2_DEPOSIT_COUNT=$(cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL) + +echo "L1 settled LER: $L1_SETTLED_LER" +echo "L1 settled deposit count: $L1_DEPOSIT_COUNT" +echo "L2 current LER: $L2_LER" +echo "L2 current deposit count: $L2_DEPOSIT_COUNT" +``` + +**Important**: `L2_LER != L1_SETTLED_LER` does **not** by itself indicate divergence. Under normal operation L2 is ahead of L1 (the `aggsender` posts certificates periodically), so `L2_DEPOSIT_COUNT > L1_DEPOSIT_COUNT` and a different current root is perfectly expected. + +The key validation is to check whether `L1_SETTLED_LER` **exists in L2's history** — i.e., whether L2's tree ever had that root at `L1_DEPOSIT_COUNT` deposits. + +#### Quick checks (no archive node needed) + +```bash +# If L2 has fewer deposits than L1 settled, divergence is certain. +# L1 should never settle leaves that don't exist on L2. +if [ "$L2_DEPOSIT_COUNT" -lt "$L1_DEPOSIT_COUNT" ]; then + echo "DIVERGENCE: L1 settled $L1_DEPOSIT_COUNT deposits but L2 only has $L2_DEPOSIT_COUNT" +fi + +# If deposit counts match, a simple root comparison suffices. +if [ "$L2_DEPOSIT_COUNT" -eq "$L1_DEPOSIT_COUNT" ]; then + if [ "$L2_LER" == "$L1_SETTLED_LER" ]; then + echo "No divergence — roots match at same deposit count" + else + echo "DIVERGENCE: same deposit count ($L2_DEPOSIT_COUNT) but different roots" + fi +fi +``` + +#### When L2 is ahead (`L2_DEPOSIT_COUNT > L1_DEPOSIT_COUNT`) + +L2 being ahead is normal. To confirm divergence, verify that `L1_SETTLED_LER` matches the L2 tree's historical root at `L1_DEPOSIT_COUNT`. This requires an **archive node** for the L2 RPC. + +Use the bridge service to find the block boundary, then query the historical root: + +```bash +# deposit_count in the bridge service is 0-indexed. +# L1_DEPOSIT_COUNT is the total leaf count, so the last settled deposit is at index L1_DEPOSIT_COUNT - 1. +# The first deposit AFTER the settled set is at index L1_DEPOSIT_COUNT. +FIRST_POST_SETTLE=$(curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=$L1_DEPOSIT_COUNT" | jq -r '.block_num') + +if [ "$FIRST_POST_SETTLE" != "null" ] && [ -n "$FIRST_POST_SETTLE" ]; then + # Read the L2 root at the block BEFORE the first post-settlement deposit. + # At this point, L2 should have had exactly L1_DEPOSIT_COUNT leaves. + HISTORY_BLOCK=$((FIRST_POST_SETTLE - 1)) + L2_HISTORICAL_LER=$(cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" \ + --rpc-url $L2_RPC_URL --block $HISTORY_BLOCK) + + echo "L2 historical LER at block $HISTORY_BLOCK: $L2_HISTORICAL_LER" + echo "L1 settled LER: $L1_SETTLED_LER" + + if [ "$L2_HISTORICAL_LER" == "$L1_SETTLED_LER" ]; then + echo "No divergence — L1 settled LER exists in L2 history" + else + echo "DIVERGENCE CONFIRMED — L1 settled LER does NOT match L2 tree at deposit count $L1_DEPOSIT_COUNT" + fi +else + echo "Bridge at deposit_count=$L1_DEPOSIT_COUNT not found on L2 — verify bridge service sync status" +fi +``` + +> **Note**: The archive-node query above assumes the first deposit after the settled set is in a different block than the last settled deposit. If multiple deposits land in the same block, the block boundary may not be exact. In that case, use the block of the last settled deposit (`deposit_count = L1_DEPOSIT_COUNT - 1`) and verify the deposit count at that block: +> ```bash +> LAST_SETTLED_BLOCK=$(curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=$((L1_DEPOSIT_COUNT - 1))" | jq -r '.block_num') +> DEPOSIT_AT_BLOCK=$(cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL --block $LAST_SETTLED_BLOCK) +> # If DEPOSIT_AT_BLOCK == L1_DEPOSIT_COUNT, the root at this block is the one to compare. +> # If DEPOSIT_AT_BLOCK > L1_DEPOSIT_COUNT, more deposits landed in the same block — you'll need +> # to trace the transaction to get the intermediate root. +> ``` + +#### Summary + +| Condition | Result | +|-----------|--------| +| `L2_DEPOSIT_COUNT < L1_DEPOSIT_COUNT` | **Divergence** — L1 settled leaves that don't exist on L2 | +| `L2_DEPOSIT_COUNT == L1_DEPOSIT_COUNT` and `L2_LER == L1_SETTLED_LER` | **No divergence** | +| `L2_DEPOSIT_COUNT == L1_DEPOSIT_COUNT` and `L2_LER != L1_SETTLED_LER` | **Divergence** — same count, different roots | +| `L2_DEPOSIT_COUNT > L1_DEPOSIT_COUNT` and L1_SETTLED_LER **found** in L2 history | **No divergence** — L2 is simply ahead | +| `L2_DEPOSIT_COUNT > L1_DEPOSIT_COUNT` and L1_SETTLED_LER **NOT found** in L2 history | **Divergence** — L1 settled a root that L2 never had | + +### Step 5: List the L2 bridges (leaves) from the divergence point + +To understand which bridges exist on L2 after the last matching point, query the bridge service for each deposit count from the divergence point onwards: + +```bash +# Get the bridge at a specific deposit count on L2 +# Repeat for each deposit count from (last_matching_count + 1) to L2_DEPOSIT_COUNT +DEPOSIT_IDX=3 # example: first divergent position +curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=$DEPOSIT_IDX" | jq . +``` + +The response contains the full leaf data for that bridge: +- `leaf_type` (0=asset, 1=message) +- `origin_network` +- `origin_address` +- `destination_network` +- `destination_address` +- `amount` +- `metadata` + +Loop through all positions to build the list of L2 leaves: + +```bash +# Collect all L2 bridges from divergence point to current deposit count +DIVERGENCE_POINT=2 # last matching deposit count +for i in $(seq $((DIVERGENCE_POINT + 1)) $L2_DEPOSIT_COUNT); do + echo "=== Deposit $i ===" + curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=$i" | jq '{ + deposit_count, + leaf_type, + origin_network, + origin_address, + destination_network, + destination_address, + amount, + metadata + }' +done +``` + +### Step 6: List the L1-settled leaves (divergent leaves) + +The divergent leaves (BX, BY, ...) are the ones that were included in certificates settled on L1 but do not exist on L2. These leaves are part of the `bridge_exits` field of the settled certificates. + +The AggLayer gRPC API only exposes certificate **headers** (`GetCertificateHeader`), not full certificate bodies — it does not return the individual bridge exits. Retrieving the actual leaf data requires one of the following options. + +#### Option 1: aggsender certificate API (preferred) + +The `aggsender` stores the full body of every certificate it submits, including the `bridge_exits` array. A dedicated endpoint is being added to the `aggsender` to expose this data. It will be available before this runbook is released. + +The endpoint will accept a certificate ID (or height) and return the full list of bridge exits for that certificate, including the leaf data needed for `forwardLET`: + +```bash +# Retrieve bridge exits for a specific certificate height +# The aggsender API base URL depends on your deployment configuration +AGGSENDER_API_URL="" +CERT_HEIGHT="" + +curl -s "$AGGSENDER_API_URL/certificate/$CERT_HEIGHT/bridge-exits" | jq . +``` + +The response will contain an array of bridge exit objects, each with: +- `leaf_type` (0=asset, 1=message) +- `origin_network` +- `origin_token_address` +- `dest_network` +- `dest_address` +- `amount` +- `metadata` + +These map directly to the `LeafData` fields required by `forwardLET`. + +> **Prerequisite**: The aggsender must be the same instance that submitted the divergent certificate (its DB holds that certificate's data). If the aggsender was replaced or its database was lost, fall back to Option 2. + +#### Option 2: contact the AggLayer node admin (fallback) + +If Option 1 is unavailable (aggsender DB lost, different aggsender instance, or the API is unreachable), contact the operator of the AggLayer node and request the full certificate body for the divergent certificate ID. + +Provide them with the certificate ID obtained in Step 1: + +```bash +# Certificate ID from GetNetworkInfo (settled_certificate_id) +echo "Certificate ID: $CERT_ID" +echo "Network ID: $NETWORK_ID" +echo "Height: " +``` + +The AggLayer node operator can retrieve the full certificate body — including all `bridge_exits` — from their internal storage and share the leaf data needed to construct the `forwardLET` call. + +### Summary: determining the recovery case + +After collecting the data above: + +| L2 has extra leaves beyond divergence? | L1 settled extra leaves beyond divergence? | Case | +|----------------------------------------|-------------------------------------------|------| +| No | No (single divergent leaf) | **Case 1** — forwardLET only | +| Yes | No (single divergent leaf) | **Case 2** — backwardLET then forwardLET | +| No | Yes (multiple divergent leaves) | **Case 3** — forwardLET only (multiple leaves) | +| Yes | Yes (multiple divergent leaves) | **Case 4** — backwardLET then forwardLET | + +## Recovery + +### Using the tool + +A dedicated tool to automate the recovery process is **under development**. Once available, this tool will: + +- Query the AggLayer node for the expected LER on L1 +- Compare it against the current LET state on L2 +- Determine the required sequence of `backwardLET` and `forwardLET` calls +- Compute the necessary Merkle proofs, frontiers, and leaf data +- Execute the smart contract calls in the correct order + +Until the tool is available, recovery must be performed manually as described below. + +### Contract function signatures reference + +Before proceeding, here are the exact Solidity function signatures (from [`AgglayerBridgeL2.sol` v12.2.0](https://github.com/agglayer/agglayer-contracts/blob/v12.2.0/contracts/sovereignChains/AgglayerBridgeL2.sol)): + +```solidity +// Roll the LET backward to a previous state +// Modifiers: onlyGlobalExitRootRemover, ifEmergencyState +function backwardLET( + uint256 newDepositCount, + bytes32[32] calldata newFrontier, + bytes32 nextLeaf, + bytes32[32] calldata proof +) external virtual onlyGlobalExitRootRemover ifEmergencyState; + +// Advance the LET by adding new leaves in bulk +// Modifiers: onlyGlobalExitRootRemover, ifEmergencyState +function forwardLET( + LeafData[] calldata newLeaves, + bytes32 expectedLER +) external virtual onlyGlobalExitRootRemover ifEmergencyState; + +struct LeafData { + uint8 leafType; // 0 = asset, 1 = message + uint32 originNetwork; + address originAddress; + uint32 destinationNetwork; + address destinationAddress; + uint256 amount; + bytes metadata; +} + +// Emergency state management +// Modifier: onlyEmergencyBridgePauser +function activateEmergencyState() external onlyEmergencyBridgePauser; + +// Modifier: onlyEmergencyBridgeUnpauser +function deactivateEmergencyState() external onlyEmergencyBridgeUnpauser; +``` + +### Manually + +The manual recovery process follows these steps. Each step includes the exact CLI commands to execute. + +#### Step 1: Stop the `aggsender` + +Before performing any recovery operations, stop the `aggsender` to prevent it from interfering (e.g., attempting to send certificates while the bridge is in emergency mode). + +```bash +# Stop the aggsender process/container. +# The exact command depends on your deployment (systemd, docker, kubernetes, etc.) +# Example for docker: +docker stop aggsender + +# Example for systemd: +sudo systemctl stop aggsender +``` + +#### Step 2: Activate emergency mode + +Call `activateEmergencyState` on the bridge contract. This is a prerequisite for both `backwardLET` and `forwardLET`. + +```bash +# Verify emergency state is NOT already active +cast call $BRIDGE_L2_ADDR "isEmergencyState()(bool)" --rpc-url $L2_RPC_URL + +# Activate emergency state (requires emergencyBridgePauser key) +cast send $BRIDGE_L2_ADDR "activateEmergencyState()" \ + --private-key $EMERGENCY_PAUSER_PK \ + --rpc-url $L2_RPC_URL + +# Confirm activation +cast call $BRIDGE_L2_ADDR "isEmergencyState()(bool)" --rpc-url $L2_RPC_URL +# Expected: true +``` + +#### Step 3: Roll back the LET if needed (`backwardLET`) + +This step is only needed if L2 has extra leaves beyond the divergence point (**Cases 2 and 4**). If only `forwardLET` is needed (**Cases 1 and 3**), skip to Step 4. + +The `backwardLET` function requires: +- `newDepositCount` — the target deposit count to roll back to (the divergence point) +- `newFrontier` — 32-element Merkle tree frontier array at the target deposit count +- `nextLeaf` — the leaf hash at position `newDepositCount` in the current tree (proof of inclusion) +- `proof` — Merkle proof that `nextLeaf` exists at position `newDepositCount` + +> **Computing `newFrontier`, `nextLeaf`, and `proof`**: These values require off-chain computation from the Merkle tree state. The recovery tool (when available) will compute these automatically. For manual computation, you need access to the full tree state (all leaves up to the current deposit count) to generate the frontier at the target count, the leaf hash at the boundary position, and a Merkle inclusion proof. + +```bash +# Example: roll back from deposit count 4 to deposit count 2 +# NEW_DEPOSIT_COUNT, NEW_FRONTIER, NEXT_LEAF, and PROOF must be computed off-chain +NEW_DEPOSIT_COUNT=2 +NEW_FRONTIER="[0x...,0x...,...]" # 32-element bytes32 array +NEXT_LEAF="0x..." # leaf hash at position newDepositCount +PROOF="[0x...,0x...,...]" # 32-element bytes32 Merkle proof + +cast send $BRIDGE_L2_ADDR \ + "backwardLET(uint256,bytes32[32],bytes32,bytes32[32])" \ + $NEW_DEPOSIT_COUNT \ + "$NEW_FRONTIER" \ + $NEXT_LEAF \ + "$PROOF" \ + --private-key $GER_REMOVER_PK \ + --rpc-url $L2_RPC_URL + +# Verify the rollback +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +# Expected: 2 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL +# Should match the LER at deposit count 2 +``` + +#### Step 4: Advance the LET (`forwardLET`) + +Call `forwardLET` to add the required leaves. This includes: +- The divergent leaf(s) settled on L1 (BX, BY, ...) +- If a `backwardLET` was performed in Step 3, the legitimate L2 bridges that were rolled back (B3, B4, ...) + +The leaves must be passed as an array of `LeafData` structs **in the correct order**: divergent leaves first, then the re-added legitimate L2 bridges. + +The `expectedLER` is the expected Merkle root after all leaves are inserted. It acts as a health check — if the computed root doesn't match, the transaction reverts. + +```bash +# Build the leaf data array. +# Each leaf is a tuple: (leafType, originNetwork, originAddress, destinationNetwork, destinationAddress, amount, metadata) +# +# Example for Case 2: insert BX (divergent), then B3 and B4 (legitimate) +# The leaf data comes from the diagnosis phase (Step 5 and Step 6 above) + +EXPECTED_LER="0x..." # the expected LER after all leaves are inserted + +cast send $BRIDGE_L2_ADDR \ + "forwardLET((uint8,uint32,address,uint32,address,uint256,bytes)[],bytes32)" \ + "[(0,1,0xOrigAddr1,2,0xDestAddr1,1000000000000000000,0x),(0,1,0xOrigAddr2,3,0xDestAddr2,2000000000000000000,0x),(0,1,0xOrigAddr3,3,0xDestAddr3,500000000000000000,0x)]" \ + $EXPECTED_LER \ + --private-key $GER_REMOVER_PK \ + --rpc-url $L2_RPC_URL + +# Verify the new state +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL +# The root should match EXPECTED_LER +``` + +**Computing `expectedLER`**: This is the Merkle root you expect after inserting all the leaves. It must be computed off-chain from the full leaf set. For **Cases 1 and 3** (forward-only), the expected LER after inserting all missing leaves should match the L1 settled LER if you're inserting exactly the leaves that were settled. For **Cases 2 and 4** (backward + forward), the expected LER must account for both the divergent leaves and the re-added legitimate leaves. + +#### Step 5: Deactivate emergency mode + +```bash +# Deactivate emergency state (requires emergencyBridgeUnpauser key) +cast send $BRIDGE_L2_ADDR "deactivateEmergencyState()" \ + --private-key $EMERGENCY_UNPAUSER_PK \ + --rpc-url $L2_RPC_URL + +# Confirm deactivation +cast call $BRIDGE_L2_ADDR "isEmergencyState()(bool)" --rpc-url $L2_RPC_URL +# Expected: false +``` + +#### Step 6: Rebalance the chain (if needed) + +The bridge will be **undercollateralized** by the sum of amounts of all divergent leaves (BX, BY, ...). The AggLayer tracks a Local Balance Tree (LBT) for each chain, and if the LBT shows a negative balance, the next certificate will be rejected. + +Check whether rebalancing is urgent by computing the total amount of divergent leaves: + +```bash +# Sum of amounts of all divergent leaves (BX, BY, ...) +# If this amount is significant, rebalancing must happen BEFORE starting the aggsender. + +# Rebalancing steps: +# 1. Bridge the required amount from another network (LX) into this chain +# 2. Claim the bridge on L2 +# 3. Burn the claimed amount on L2 +# +# These are standard bridge operations and depend on the specific token and network involved. +``` + +#### Step 7: Start the `aggsender` + +Once the LET is corrected and rebalancing is complete (if needed), restart the `aggsender`: + +```bash +# Start the aggsender process/container +# Example for docker: +docker start aggsender + +# Example for systemd: +sudo systemctl start aggsender +``` + +After starting, the `aggsender` must craft a certificate covering the block range that includes the `BackwardLET` and `ForwardLET` events. Monitor its logs to verify: + +```bash +# Watch for successful certificate submission +# Look for log lines indicating successful certificate send +# and absence of the error patterns listed in the Detection section +``` + +The `aggsender` handles `BackwardLET` events (removing leaves from its internal DB) and `ForwardLET` events (adding leaves to its internal DB) automatically. + +#### Post-recovery verification + +After the `aggsender` resumes and submits a new certificate, verify everything is in sync: + +```bash +# 1. Check that the latest certificate is settled (not InError) +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID, \"type\": \"LATEST_CERTIFICATE_REQUEST_TYPE_SETTLED\"}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetLatestCertificateHeader + +# 2. Verify L2 LER matches what AggLayer expects +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo + +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL +# These should be consistent + +# 3. Check bridge service sync status +curl -s "$BRIDGE_SERVICE_URL/sync-status" | jq . + +# 4. Verify no pending InError certificates +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID, \"type\": \"LATEST_CERTIFICATE_REQUEST_TYPE_PENDING\"}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetLatestCertificateHeader +``` + +### Cases + +The key factor determining the recovery steps is not just the root cause of the divergence, but the **combination of events that occurred after the LET diverged**. Specifically: + +- Did further bridges occur on L2 after the divergence point? +- Did further settlements occur on L1 after the first invalid one? + +The following scenarios use this notation: + +``` +L2: B1 -> LET_1, B2 -> LET_2, B3 -> LET_3, B4 -> LET_4 +L1: B1 -> LET_1, B2 -> LET_2, BX -> LET_X + ^ divergence point +``` + +Where `B1..B4` are bridge events, `BX` is a divergent leaf (settled on L1 but not matching L2), and `LET_N` is the LET root after leaf N. + +--- + +#### Case 1: Divergence with no further L2 bridges and no further L1 settlements + +**Scenario**: A single divergent leaf was settled on L1, no additional bridges have occurred on L2 since, and no further settlements have been made on L1. + +``` +L2: B1 -> LET_1, B2 -> LET_2 +L1: B1 -> LET_1, B2 -> LET_2, BX -> LET_X +``` + +**Diagnosis check**: + +```bash +# Confirm: L2 deposit count == L1 divergence point (e.g., 2) +# L1 settled deposit count == divergence point + number of divergent leaves (e.g., 3) +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +# Expected: 2 + +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo +# settled_let_leaf_count expected: 3 +``` + +**Recovery steps**: + +```bash +# 1. Stop the aggsender +# 2. Activate emergency state +cast send $BRIDGE_L2_ADDR "activateEmergencyState()" \ + --private-key $EMERGENCY_PAUSER_PK --rpc-url $L2_RPC_URL + +# 3. forwardLET — add BX to match L1 +# BX leaf data must be obtained from the settled certificate (see Diagnosis Step 6) +cast send $BRIDGE_L2_ADDR \ + "forwardLET((uint8,uint32,address,uint32,address,uint256,bytes)[],bytes32)" \ + "[(BX_LEAF_TYPE,BX_ORIGIN_NET,BX_ORIGIN_ADDR,BX_DEST_NET,BX_DEST_ADDR,BX_AMOUNT,BX_METADATA)]" \ + $LET_X \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# 4. Verify +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 3 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL # Expected: LET_X + +# 5. Deactivate emergency state +cast send $BRIDGE_L2_ADDR "deactivateEmergencyState()" \ + --private-key $EMERGENCY_UNPAUSER_PK --rpc-url $L2_RPC_URL + +# 6. (Optional) Re-collateralize, then start the aggsender +``` + +This is the simplest case: no backward operation is needed since L2 has no extra leaves beyond the divergence point. + +**Collateralization**: The bridge is **undercollateralized** by `amount(BX)` — L1 has credited those assets as having left L2, but they were never actually burned on L2. + +**Optional re-collateralization steps**: + +1. Bridge `amount(BX)` from another network into this chain +2. Claim the bridged funds on L2 +3. Burn the claimed amount on L2 + +This realigns the LBT on L2 with the LBT tracked by the AggLayer node. If the amount is significant, this must be done before starting the `aggsender` (step 6 above), as the AggLayer will reject the next certificate if the LBT shows a negative balance. + +--- + +#### Case 2: Divergence with further L2 bridges but no further L1 settlements + +**Scenario**: After the divergent leaf was settled on L1, additional bridges happened on L2 (but no further settlements occurred on L1). + +``` +L2: B1 -> LET_1, B2 -> LET_2, B3 -> LET_3, B4 -> LET_4 +L1: B1 -> LET_1, B2 -> LET_2, BX -> LET_X +``` + +L2 has leaves B3 and B4 that were added after the divergence point. These must be removed, the divergent leaf inserted, and then the legitimate leaves re-added. + +**Diagnosis check**: + +```bash +# L2 has more deposits than the divergence point +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +# Expected: 4 (divergence point 2 + 2 extra L2 bridges) + +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo +# settled_let_leaf_count expected: 3 (divergence point 2 + 1 divergent leaf) + +# Collect leaf data for B3 and B4 (the L2 bridges to re-add) +curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=3" | jq . +curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=4" | jq . +``` + +**Recovery steps**: + +```bash +# 1. Stop the aggsender +# 2. Activate emergency state +cast send $BRIDGE_L2_ADDR "activateEmergencyState()" \ + --private-key $EMERGENCY_PAUSER_PK --rpc-url $L2_RPC_URL + +# 3. backwardLET — roll back to deposit count 2 (removing B3 and B4) +# NEW_FRONTIER, NEXT_LEAF, PROOF must be computed off-chain +cast send $BRIDGE_L2_ADDR \ + "backwardLET(uint256,bytes32[32],bytes32,bytes32[32])" \ + 2 \ + "$NEW_FRONTIER" \ + $NEXT_LEAF \ + "$PROOF" \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# Verify rollback +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 2 + +# 4. forwardLET — add BX, then B3, B4 in a single call +cast send $BRIDGE_L2_ADDR \ + "forwardLET((uint8,uint32,address,uint32,address,uint256,bytes)[],bytes32)" \ + "[(BX_LEAF...),(B3_LEAF...),(B4_LEAF...)]" \ + $EXPECTED_LER \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# Verify +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 5 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL # Expected: EXPECTED_LER + +# 5. Deactivate emergency state +cast send $BRIDGE_L2_ADDR "deactivateEmergencyState()" \ + --private-key $EMERGENCY_UNPAUSER_PK --rpc-url $L2_RPC_URL + +# 6. (Optional) Re-collateralize, then start the aggsender +``` + +After recovery, the L2 LET will contain: B1, B2, BX, B3, B4 — with the first three matching L1's settled state. + +**Collateralization**: Same exposure as Case 1 — the bridge is **undercollateralized** by `amount(BX)`. The legitimate re-added leaves (B3, B4) correspond to real L2 events and do not contribute to undercollateralization. + +**Optional re-collateralization steps**: + +1. Bridge `amount(BX)` from another network into this chain +2. Claim the bridged funds on L2 +3. Burn the claimed amount on L2 + +This must be done before starting the `aggsender` if the resulting negative LBT balance would cause the next certificate to be rejected. + +--- + +#### Case 3: Divergence with no further L2 bridges but continued L1 settlements + +**Scenario**: Multiple settlements have occurred on L1 after the first divergent one, but no additional bridges happened on L2. + +``` +L2: B1 -> LET_1, B2 -> LET_2 +L1: B1 -> LET_1, B2 -> LET_2, BX -> LET_X, BY -> LET_Y +``` + +**Diagnosis check**: + +```bash +# L2 deposit count == divergence point +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +# Expected: 2 + +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo +# settled_let_leaf_count expected: 4 (divergence point 2 + 2 divergent leaves) +``` + +**Recovery steps**: + +```bash +# 1. Stop the aggsender +# 2. Activate emergency state +cast send $BRIDGE_L2_ADDR "activateEmergencyState()" \ + --private-key $EMERGENCY_PAUSER_PK --rpc-url $L2_RPC_URL + +# 3. forwardLET — add BX and BY to match L1 +cast send $BRIDGE_L2_ADDR \ + "forwardLET((uint8,uint32,address,uint32,address,uint256,bytes)[],bytes32)" \ + "[(BX_LEAF...),(BY_LEAF...)]" \ + $LET_Y \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# Verify +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 4 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL # Expected: LET_Y + +# 4. Deactivate emergency state +cast send $BRIDGE_L2_ADDR "deactivateEmergencyState()" \ + --private-key $EMERGENCY_UNPAUSER_PK --rpc-url $L2_RPC_URL + +# 5. Re-collateralize (URGENT), then start the aggsender +``` + +No backward operation is needed since L2 has no extra leaves. The `forwardLET` call can batch-insert all missing leaves in a single transaction. + +**Collateralization**: The bridge is **undercollateralized** by `amount(BX) + amount(BY)`. This is the most collateralization-sensitive case among those with no backward step, as multiple bad settlements have accumulated. + +**Optional re-collateralization steps**: + +1. Bridge `amount(BX) + amount(BY)` from another network into this chain +2. Claim the bridged funds on L2 +3. Burn the claimed amount on L2 + +This is **urgent** — the AggLayer will reject the next certificate if the LBT shows a negative balance, so this must be done before starting the `aggsender`. + +--- + +#### Case 4: Divergence with both further L2 bridges and continued L1 settlements + +**Scenario**: This is the most complex case. After the divergence, both additional bridges occurred on L2 and additional settlements were made on L1. + +``` +L2: B1 -> LET_1, B2 -> LET_2, B3 -> LET_3, B4 -> LET_4 +L1: B1 -> LET_1, B2 -> LET_2, BX -> LET_X, BY -> LET_Y +``` + +L2 has extra leaves (B3, B4) and L1 has settled additional leaves (BX, BY) beyond the divergence point. + +**Diagnosis check**: + +```bash +# L2 has more deposits than the divergence point +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL +# Expected: 4 + +grpcurl -plaintext -d "{\"network_id\": $NETWORK_ID}" \ + $AGGLAYER_GRPC \ + agglayer.node.v1.NodeStateService/GetNetworkInfo +# settled_let_leaf_count expected: 4 (2 matching + 2 divergent) + +# The LERs will differ +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL +# L2 root != L1 settled_ler, even though deposit counts may match + +# Collect leaf data for B3 and B4 +curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=3" | jq . +curl -s "$BRIDGE_SERVICE_URL/bridge-by-deposit-count?network_id=$NETWORK_ID&deposit_count=4" | jq . +``` + +**Recovery steps**: + +```bash +# 1. Stop the aggsender +# 2. Activate emergency state +cast send $BRIDGE_L2_ADDR "activateEmergencyState()" \ + --private-key $EMERGENCY_PAUSER_PK --rpc-url $L2_RPC_URL + +# 3. backwardLET — roll back to deposit count 2 (removing B3 and B4) +cast send $BRIDGE_L2_ADDR \ + "backwardLET(uint256,bytes32[32],bytes32,bytes32[32])" \ + 2 \ + "$NEW_FRONTIER" \ + $NEXT_LEAF \ + "$PROOF" \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# Verify rollback +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 2 + +# 4. forwardLET — add BX, BY (divergent), then B3, B4 (legitimate) in a single call +cast send $BRIDGE_L2_ADDR \ + "forwardLET((uint8,uint32,address,uint32,address,uint256,bytes)[],bytes32)" \ + "[(BX_LEAF...),(BY_LEAF...),(B3_LEAF...),(B4_LEAF...)]" \ + $EXPECTED_LER \ + --private-key $GER_REMOVER_PK --rpc-url $L2_RPC_URL + +# Verify +cast call $BRIDGE_L2_ADDR "depositCount()(uint256)" --rpc-url $L2_RPC_URL # Expected: 6 +cast call $BRIDGE_L2_ADDR "getRoot()(bytes32)" --rpc-url $L2_RPC_URL # Expected: EXPECTED_LER + +# 5. Deactivate emergency state +cast send $BRIDGE_L2_ADDR "deactivateEmergencyState()" \ + --private-key $EMERGENCY_UNPAUSER_PK --rpc-url $L2_RPC_URL + +# 6. Re-collateralize (URGENT), then start the aggsender +``` + +After recovery, the L2 LET will contain: B1, B2, BX, BY, B3, B4 — with the first four matching L1's settled state. + +**Collateralization**: The bridge is **undercollateralized** by `amount(BX) + amount(BY)`. This is the worst-case scenario: multiple bad settlements on L1 combined with legitimate L2 bridge activity. The legitimate re-added leaves (B3, B4) correspond to real L2 events and do not add to the undercollateralization. + +**Optional re-collateralization steps**: + +1. Bridge `amount(BX) + amount(BY)` from another network into this chain +2. Claim the bridged funds on L2 +3. Burn the claimed amount on L2 + +This must be done before starting the `aggsender`. Given that multiple invalid settlements have occurred, this is the case where the negative LBT balance is most likely to block the very next certificate. + +--- + +#### Important considerations across all cases +- **Re-collateralization**: The bridge will always be undercollateralized after recovery by the sum of amounts of all divergent leaves. Re-collateralization (bridge from another chain -> claim on L2 -> burn) must be completed before starting the `aggsender` whenever the resulting negative LBT balance would cause the next certificate to be rejected. See each case above for the specific amounts involved. +- **Stop aggsender first**: Always stop the `aggsender` before starting any recovery operations and only start it again after everything is complete (including deactivating emergency mode and re-collateralizing if needed). +- **Certificate crafting**: After recovery, the `aggsender` must craft a certificate that covers the block range containing all the `BackwardLET` and `ForwardLET` events. The certificate's initial block must be correct and all events in the range must be included. +- **Event parsing**: The `aggsender` must correctly handle `BackwardLET` events (removing leaves from its DB) and `ForwardLET` events (adding leaves to its DB) to maintain internal consistency. +- **Single `forwardLET` call**: Since `forwardLET` accepts an array of leaves, the divergent leaves and the re-added legitimate bridges should be combined into a single call when possible (e.g., `forwardLET([BX, B3, B4], ...)`), reducing the number of transactions. +- **Order of operations matters**: The `backwardLET` must always come before `forwardLET` when both are needed, since `backwardLET` requires the current tree state to compute valid Merkle proofs. After a `forwardLET`, the tree state has changed and any previously computed proofs for `backwardLET` would be invalid. + +## Appendix: API and gRPC reference + +### AggLayer gRPC — `NodeStateService` + +**Proto package**: `agglayer.node.v1` + +| RPC Method | Description | Key response fields | +|------------|-------------|---------------------| +| `GetNetworkInfo` | Current network state and settlement info | `settled_ler`, `settled_let_leaf_count`, `settled_height`, `settled_certificate_id`, `network_status` | +| `GetLatestCertificateHeader` | Latest certificate (settled or pending) | `prev_local_exit_root`, `new_local_exit_root`, `height`, `status`, `error` | +| `GetCertificateHeader` | Specific certificate by ID | Same as above | + +**`CertificateStatus` enum values**: `PENDING` (1), `PROVEN` (2), `CANDIDATE` (3), `IN_ERROR` (4), `SETTLED` (5) + +**`LatestCertificateRequestType` enum values**: `LATEST_CERTIFICATE_REQUEST_TYPE_SETTLED`, `LATEST_CERTIFICATE_REQUEST_TYPE_PENDING` + +### Bridge Service REST API + +**Base path**: `/bridge/v1` + +| Endpoint | Method | Key params | Description | +|----------|--------|------------|-------------| +| `/bridge-by-deposit-count` | GET | `network_id`, `deposit_count` | Get a single bridge by deposit count and network | +| `/bridges` | GET | `network_id`, `page_number`, `page_size` | Paginated list of bridges for a network | +| `/sync-status` | GET | — | Compare on-chain vs synced deposit counts | +| `/claim-proof` | GET | `network_id`, `leaf_index`, `deposit_count` | Merkle proofs for local and rollup exit roots | +| `/l1-info-tree-index` | GET | `network_id`, `deposit_count` | First L1 info tree index after a deposit count | + +### Smart contract view functions (`AgglayerBridgeL2`) + +| Function | Returns | Description | +|----------|---------|-------------| +| `depositCount()` | `uint256` | Current number of deposits in the LET | +| `getRoot()` | `bytes32` | Current Merkle root (LER) of the LET | +| `isEmergencyState()` | `bool` | Whether emergency mode is active | +| `networkID()` | `uint32` | Network ID of this L2 chain | +| `emergencyBridgePauser()` | `address` | Account that can activate emergency state | +| `emergencyBridgeUnpauser()` | `address` | Account that can deactivate emergency state | + +### Smart contract view functions (`AgglayerGERL2`) + +| Function | Returns | Description | +|----------|---------|-------------| +| `globalExitRootRemover()` | `address` | Account that can call `backwardLET`/`forwardLET` | +| `globalExitRootUpdater()` | `address` | Account that can insert global exit roots | diff --git a/log/log.go b/log/log.go index 7c416eb14..1417bcf90 100644 --- a/log/log.go +++ b/log/log.go @@ -135,7 +135,7 @@ func sprintStackTrace(st []tracerr.Frame) string { st = st[:len(st)-1] } for _, f := range st { - builder.WriteString(fmt.Sprintf("\n%s:%d %s()", f.Path, f.Line, f.Func)) + fmt.Fprintf(&builder, "\n%s:%d %s()", f.Path, f.Line, f.Func) } builder.WriteString("\n") return builder.String() diff --git a/multidownloader/types/set_sync_segment.go b/multidownloader/types/set_sync_segment.go index 7f669fcba..b4180a4f8 100644 --- a/multidownloader/types/set_sync_segment.go +++ b/multidownloader/types/set_sync_segment.go @@ -22,7 +22,7 @@ func (s *SetSyncSegment) String() string { var builder strings.Builder builder.WriteString("SetSyncSegment: ") for i, segment := range s.segments { - builder.WriteString(fmt.Sprintf("SyncSegment[%d]=%s\n", i, segment.BlockRange.String())) + fmt.Fprintf(&builder, "SyncSegment[%d]=%s\n", i, segment.BlockRange.String()) } return builder.String() } diff --git a/test/contracts/abi/mintableerc20.abi b/test/contracts/abi/mintableerc20.abi new file mode 100644 index 000000000..cf046e119 --- /dev/null +++ b/test/contracts/abi/mintableerc20.abi @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"string","name":"_name","type":"string"},{"internalType":"string","name":"_symbol","type":"string"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"mint","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}] \ No newline at end of file diff --git a/test/contracts/bin/mintableerc20.bin b/test/contracts/bin/mintableerc20.bin new file mode 100644 index 000000000..fe1f33c18 --- /dev/null +++ b/test/contracts/bin/mintableerc20.bin @@ -0,0 +1 @@ +60806040523480156200001157600080fd5b50604051620009763803806200097683398101604081905262000034916200011f565b600062000042838262000218565b50600162000051828262000218565b505050620002e4565b634e487b7160e01b600052604160045260246000fd5b600082601f8301126200008257600080fd5b81516001600160401b03808211156200009f576200009f6200005a565b604051601f8301601f19908116603f01168101908282118183101715620000ca57620000ca6200005a565b81604052838152602092508683858801011115620000e757600080fd5b600091505b838210156200010b5785820183015181830184015290820190620000ec565b600093810190920192909252949350505050565b600080604083850312156200013357600080fd5b82516001600160401b03808211156200014b57600080fd5b620001598683870162000070565b935060208501519150808211156200017057600080fd5b506200017f8582860162000070565b9150509250929050565b600181811c908216806200019e57607f821691505b602082108103620001bf57634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200021357600081815260208120601f850160051c81016020861015620001ee5750805b601f850160051c820191505b818110156200020f57828155600101620001fa565b5050505b505050565b81516001600160401b038111156200023457620002346200005a565b6200024c8162000245845462000189565b84620001c5565b602080601f8311600181146200028457600084156200026b5750858301515b600019600386901b1c1916600185901b1785556200020f565b600085815260208120601f198616915b82811015620002b55788860151825594840194600190910190840162000294565b5085821015620002d45787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b61068280620002f46000396000f3fe608060405234801561001057600080fd5b506004361061009e5760003560e01c806340c10f191161006657806340c10f191461012857806370a082311461013d57806395d89b411461015d578063a9059cbb14610165578063dd62ed3e1461017857600080fd5b806306fdde03146100a3578063095ea7b3146100c157806318160ddd146100e457806323b872dd146100fb578063313ce5671461010e575b600080fd5b6100ab6101a3565b6040516100b891906104b1565b60405180910390f35b6100d46100cf36600461051b565b610231565b60405190151581526020016100b8565b6100ed60025481565b6040519081526020016100b8565b6100d4610109366004610545565b61029e565b610116601281565b60405160ff90911681526020016100b8565b61013b61013636600461051b565b61038b565b005b6100ed61014b366004610581565b60036020526000908152604090205481565b6100ab610414565b6100d461017336600461051b565b610421565b6100ed6101863660046105a3565b600460209081526000928352604080842090915290825290205481565b600080546101b0906105d6565b80601f01602080910402602001604051908101604052809291908181526020018280546101dc906105d6565b80156102295780601f106101fe57610100808354040283529160200191610229565b820191906000526020600020905b81548152906001019060200180831161020c57829003601f168201915b505050505081565b3360008181526004602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259061028c9086815260200190565b60405180910390a35060015b92915050565b6001600160a01b03831660009081526004602090815260408083203384529091528120805483919083906102d3908490610626565b90915550506001600160a01b03841660009081526003602052604081208054849290610300908490610626565b90915550506001600160a01b0383166000908152600360205260408120805484929061032d908490610639565b92505081905550826001600160a01b0316846001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161037991815260200190565b60405180910390a35060019392505050565b806002600082825461039d9190610639565b90915550506001600160a01b038216600090815260036020526040812080548392906103ca908490610639565b90915550506040518181526001600160a01b038316906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b600180546101b0906105d6565b33600090815260036020526040812080548391908390610442908490610626565b90915550506001600160a01b0383166000908152600360205260408120805484929061046f908490610639565b90915550506040518281526001600160a01b0384169033907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200161028c565b600060208083528351808285015260005b818110156104de578581018301518582016040015282016104c2565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461051657600080fd5b919050565b6000806040838503121561052e57600080fd5b610537836104ff565b946020939093013593505050565b60008060006060848603121561055a57600080fd5b610563846104ff565b9250610571602085016104ff565b9150604084013590509250925092565b60006020828403121561059357600080fd5b61059c826104ff565b9392505050565b600080604083850312156105b657600080fd5b6105bf836104ff565b91506105cd602084016104ff565b90509250929050565b600181811c908216806105ea57607f821691505b60208210810361060a57634e487b7160e01b600052602260045260246000fd5b50919050565b634e487b7160e01b600052601160045260246000fd5b8181038181111561029857610298610610565b808201808211156102985761029861061056fea26469706673582212202dd5c467bf43fd8ac18a8a61ba0d72bd972fe5578b9423a313202c31db709b5264736f6c63430008120033 \ No newline at end of file diff --git a/test/contracts/bind.sh b/test/contracts/bind.sh index 5a4d44d60..a80a847c4 100755 --- a/test/contracts/bind.sh +++ b/test/contracts/bind.sh @@ -12,4 +12,5 @@ gen verifybatchesmock gen claimmock gen claimmockcaller gen claimmocktest -gen logemitter \ No newline at end of file +gen logemitter +gen mintableerc20 \ No newline at end of file diff --git a/test/contracts/compile.sh b/test/contracts/compile.sh index ae3d1cd3a..2f7803e85 100755 --- a/test/contracts/compile.sh +++ b/test/contracts/compile.sh @@ -22,6 +22,10 @@ docker run --rm -v $(pwd):/contracts ethereum/solc:0.8.18-alpine - /contracts/lo mv -f LogEmitter.abi abi/logemitter.abi mv -f LogEmitter.bin bin/logemitter.bin +docker run --rm -v $(pwd):/contracts ethereum/solc:0.8.18-alpine - /contracts/mintableerc20/MintableERC20.sol -o /contracts --abi --bin --overwrite --optimize +mv -f MintableERC20.abi abi/mintableerc20.abi +mv -f MintableERC20.bin bin/mintableerc20.bin + rm -f IClaimMock.abi rm -f IClaimMock.bin diff --git a/test/contracts/mintableerc20/MintableERC20.sol b/test/contracts/mintableerc20/MintableERC20.sol new file mode 100644 index 000000000..3ccbd1a52 --- /dev/null +++ b/test/contracts/mintableerc20/MintableERC20.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +/// @title MintableERC20 +/// @notice Minimal ERC20 token with an open mint function for use in E2E tests. +/// Tokens are native to the deploying chain, so they can be bridged out freely +/// without triggering the Local Balance Tree underflow check. +contract MintableERC20 { + string public name; + string public symbol; + uint8 public constant decimals = 18; + + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + /// @notice Mint tokens to any address. Open for test use. + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } +} diff --git a/test/contracts/mintableerc20/mintableerc20.go b/test/contracts/mintableerc20/mintableerc20.go new file mode 100644 index 000000000..2f53c48dc --- /dev/null +++ b/test/contracts/mintableerc20/mintableerc20.go @@ -0,0 +1,781 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package mintableerc20 + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// Mintableerc20MetaData contains all meta data concerning the Mintableerc20 contract. +var Mintableerc20MetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[{\"internalType\":\"string\",\"name\":\"_name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"_symbol\",\"type\":\"string\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Approval\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"value\",\"type\":\"uint256\"}],\"name\":\"Transfer\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"allowance\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"spender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"approve\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"mint\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"name\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"symbol\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"totalSupply\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transfer\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"from\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"to\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"}],\"name\":\"transferFrom\",\"outputs\":[{\"internalType\":\"bool\",\"name\":\"\",\"type\":\"bool\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + Bin: "0x60806040523480156200001157600080fd5b50604051620009763803806200097683398101604081905262000034916200011f565b600062000042838262000218565b50600162000051828262000218565b505050620002e4565b634e487b7160e01b600052604160045260246000fd5b600082601f8301126200008257600080fd5b81516001600160401b03808211156200009f576200009f6200005a565b604051601f8301601f19908116603f01168101908282118183101715620000ca57620000ca6200005a565b81604052838152602092508683858801011115620000e757600080fd5b600091505b838210156200010b5785820183015181830184015290820190620000ec565b600093810190920192909252949350505050565b600080604083850312156200013357600080fd5b82516001600160401b03808211156200014b57600080fd5b620001598683870162000070565b935060208501519150808211156200017057600080fd5b506200017f8582860162000070565b9150509250929050565b600181811c908216806200019e57607f821691505b602082108103620001bf57634e487b7160e01b600052602260045260246000fd5b50919050565b601f8211156200021357600081815260208120601f850160051c81016020861015620001ee5750805b601f850160051c820191505b818110156200020f57828155600101620001fa565b5050505b505050565b81516001600160401b038111156200023457620002346200005a565b6200024c8162000245845462000189565b84620001c5565b602080601f8311600181146200028457600084156200026b5750858301515b600019600386901b1c1916600185901b1785556200020f565b600085815260208120601f198616915b82811015620002b55788860151825594840194600190910190840162000294565b5085821015620002d45787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b61068280620002f46000396000f3fe608060405234801561001057600080fd5b506004361061009e5760003560e01c806340c10f191161006657806340c10f191461012857806370a082311461013d57806395d89b411461015d578063a9059cbb14610165578063dd62ed3e1461017857600080fd5b806306fdde03146100a3578063095ea7b3146100c157806318160ddd146100e457806323b872dd146100fb578063313ce5671461010e575b600080fd5b6100ab6101a3565b6040516100b891906104b1565b60405180910390f35b6100d46100cf36600461051b565b610231565b60405190151581526020016100b8565b6100ed60025481565b6040519081526020016100b8565b6100d4610109366004610545565b61029e565b610116601281565b60405160ff90911681526020016100b8565b61013b61013636600461051b565b61038b565b005b6100ed61014b366004610581565b60036020526000908152604090205481565b6100ab610414565b6100d461017336600461051b565b610421565b6100ed6101863660046105a3565b600460209081526000928352604080842090915290825290205481565b600080546101b0906105d6565b80601f01602080910402602001604051908101604052809291908181526020018280546101dc906105d6565b80156102295780601f106101fe57610100808354040283529160200191610229565b820191906000526020600020905b81548152906001019060200180831161020c57829003601f168201915b505050505081565b3360008181526004602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259061028c9086815260200190565b60405180910390a35060015b92915050565b6001600160a01b03831660009081526004602090815260408083203384529091528120805483919083906102d3908490610626565b90915550506001600160a01b03841660009081526003602052604081208054849290610300908490610626565b90915550506001600160a01b0383166000908152600360205260408120805484929061032d908490610639565b92505081905550826001600160a01b0316846001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161037991815260200190565b60405180910390a35060019392505050565b806002600082825461039d9190610639565b90915550506001600160a01b038216600090815260036020526040812080548392906103ca908490610639565b90915550506040518181526001600160a01b038316906000907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b600180546101b0906105d6565b33600090815260036020526040812080548391908390610442908490610626565b90915550506001600160a01b0383166000908152600360205260408120805484929061046f908490610639565b90915550506040518281526001600160a01b0384169033907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200161028c565b600060208083528351808285015260005b818110156104de578581018301518582016040015282016104c2565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461051657600080fd5b919050565b6000806040838503121561052e57600080fd5b610537836104ff565b946020939093013593505050565b60008060006060848603121561055a57600080fd5b610563846104ff565b9250610571602085016104ff565b9150604084013590509250925092565b60006020828403121561059357600080fd5b61059c826104ff565b9392505050565b600080604083850312156105b657600080fd5b6105bf836104ff565b91506105cd602084016104ff565b90509250929050565b600181811c908216806105ea57607f821691505b60208210810361060a57634e487b7160e01b600052602260045260246000fd5b50919050565b634e487b7160e01b600052601160045260246000fd5b8181038181111561029857610298610610565b808201808211156102985761029861061056fea26469706673582212202dd5c467bf43fd8ac18a8a61ba0d72bd972fe5578b9423a313202c31db709b5264736f6c63430008120033", +} + +// Mintableerc20ABI is the input ABI used to generate the binding from. +// Deprecated: Use Mintableerc20MetaData.ABI instead. +var Mintableerc20ABI = Mintableerc20MetaData.ABI + +// Mintableerc20Bin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use Mintableerc20MetaData.Bin instead. +var Mintableerc20Bin = Mintableerc20MetaData.Bin + +// DeployMintableerc20 deploys a new Ethereum contract, binding an instance of Mintableerc20 to it. +func DeployMintableerc20(auth *bind.TransactOpts, backend bind.ContractBackend, _name string, _symbol string) (common.Address, *types.Transaction, *Mintableerc20, error) { + parsed, err := Mintableerc20MetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(Mintableerc20Bin), backend, _name, _symbol) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &Mintableerc20{Mintableerc20Caller: Mintableerc20Caller{contract: contract}, Mintableerc20Transactor: Mintableerc20Transactor{contract: contract}, Mintableerc20Filterer: Mintableerc20Filterer{contract: contract}}, nil +} + +// Mintableerc20 is an auto generated Go binding around an Ethereum contract. +type Mintableerc20 struct { + Mintableerc20Caller // Read-only binding to the contract + Mintableerc20Transactor // Write-only binding to the contract + Mintableerc20Filterer // Log filterer for contract events +} + +// Mintableerc20Caller is an auto generated read-only Go binding around an Ethereum contract. +type Mintableerc20Caller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// Mintableerc20Transactor is an auto generated write-only Go binding around an Ethereum contract. +type Mintableerc20Transactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// Mintableerc20Filterer is an auto generated log filtering Go binding around an Ethereum contract events. +type Mintableerc20Filterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// Mintableerc20Session is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type Mintableerc20Session struct { + Contract *Mintableerc20 // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// Mintableerc20CallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type Mintableerc20CallerSession struct { + Contract *Mintableerc20Caller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// Mintableerc20TransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type Mintableerc20TransactorSession struct { + Contract *Mintableerc20Transactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// Mintableerc20Raw is an auto generated low-level Go binding around an Ethereum contract. +type Mintableerc20Raw struct { + Contract *Mintableerc20 // Generic contract binding to access the raw methods on +} + +// Mintableerc20CallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type Mintableerc20CallerRaw struct { + Contract *Mintableerc20Caller // Generic read-only contract binding to access the raw methods on +} + +// Mintableerc20TransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type Mintableerc20TransactorRaw struct { + Contract *Mintableerc20Transactor // Generic write-only contract binding to access the raw methods on +} + +// NewMintableerc20 creates a new instance of Mintableerc20, bound to a specific deployed contract. +func NewMintableerc20(address common.Address, backend bind.ContractBackend) (*Mintableerc20, error) { + contract, err := bindMintableerc20(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &Mintableerc20{Mintableerc20Caller: Mintableerc20Caller{contract: contract}, Mintableerc20Transactor: Mintableerc20Transactor{contract: contract}, Mintableerc20Filterer: Mintableerc20Filterer{contract: contract}}, nil +} + +// NewMintableerc20Caller creates a new read-only instance of Mintableerc20, bound to a specific deployed contract. +func NewMintableerc20Caller(address common.Address, caller bind.ContractCaller) (*Mintableerc20Caller, error) { + contract, err := bindMintableerc20(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &Mintableerc20Caller{contract: contract}, nil +} + +// NewMintableerc20Transactor creates a new write-only instance of Mintableerc20, bound to a specific deployed contract. +func NewMintableerc20Transactor(address common.Address, transactor bind.ContractTransactor) (*Mintableerc20Transactor, error) { + contract, err := bindMintableerc20(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &Mintableerc20Transactor{contract: contract}, nil +} + +// NewMintableerc20Filterer creates a new log filterer instance of Mintableerc20, bound to a specific deployed contract. +func NewMintableerc20Filterer(address common.Address, filterer bind.ContractFilterer) (*Mintableerc20Filterer, error) { + contract, err := bindMintableerc20(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &Mintableerc20Filterer{contract: contract}, nil +} + +// bindMintableerc20 binds a generic wrapper to an already deployed contract. +func bindMintableerc20(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := Mintableerc20MetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Mintableerc20 *Mintableerc20Raw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Mintableerc20.Contract.Mintableerc20Caller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Mintableerc20 *Mintableerc20Raw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Mintableerc20.Contract.Mintableerc20Transactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Mintableerc20 *Mintableerc20Raw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Mintableerc20.Contract.Mintableerc20Transactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_Mintableerc20 *Mintableerc20CallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _Mintableerc20.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_Mintableerc20 *Mintableerc20TransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _Mintableerc20.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_Mintableerc20 *Mintableerc20TransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _Mintableerc20.Contract.contract.Transact(opts, method, params...) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address , address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20Caller) Allowance(opts *bind.CallOpts, arg0 common.Address, arg1 common.Address) (*big.Int, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "allowance", arg0, arg1) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address , address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20Session) Allowance(arg0 common.Address, arg1 common.Address) (*big.Int, error) { + return _Mintableerc20.Contract.Allowance(&_Mintableerc20.CallOpts, arg0, arg1) +} + +// Allowance is a free data retrieval call binding the contract method 0xdd62ed3e. +// +// Solidity: function allowance(address , address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20CallerSession) Allowance(arg0 common.Address, arg1 common.Address) (*big.Int, error) { + return _Mintableerc20.Contract.Allowance(&_Mintableerc20.CallOpts, arg0, arg1) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20Caller) BalanceOf(opts *bind.CallOpts, arg0 common.Address) (*big.Int, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "balanceOf", arg0) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20Session) BalanceOf(arg0 common.Address) (*big.Int, error) { + return _Mintableerc20.Contract.BalanceOf(&_Mintableerc20.CallOpts, arg0) +} + +// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. +// +// Solidity: function balanceOf(address ) view returns(uint256) +func (_Mintableerc20 *Mintableerc20CallerSession) BalanceOf(arg0 common.Address) (*big.Int, error) { + return _Mintableerc20.Contract.BalanceOf(&_Mintableerc20.CallOpts, arg0) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_Mintableerc20 *Mintableerc20Caller) Decimals(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "decimals") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_Mintableerc20 *Mintableerc20Session) Decimals() (uint8, error) { + return _Mintableerc20.Contract.Decimals(&_Mintableerc20.CallOpts) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_Mintableerc20 *Mintableerc20CallerSession) Decimals() (uint8, error) { + return _Mintableerc20.Contract.Decimals(&_Mintableerc20.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_Mintableerc20 *Mintableerc20Caller) Name(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "name") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_Mintableerc20 *Mintableerc20Session) Name() (string, error) { + return _Mintableerc20.Contract.Name(&_Mintableerc20.CallOpts) +} + +// Name is a free data retrieval call binding the contract method 0x06fdde03. +// +// Solidity: function name() view returns(string) +func (_Mintableerc20 *Mintableerc20CallerSession) Name() (string, error) { + return _Mintableerc20.Contract.Name(&_Mintableerc20.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_Mintableerc20 *Mintableerc20Caller) Symbol(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "symbol") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_Mintableerc20 *Mintableerc20Session) Symbol() (string, error) { + return _Mintableerc20.Contract.Symbol(&_Mintableerc20.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_Mintableerc20 *Mintableerc20CallerSession) Symbol() (string, error) { + return _Mintableerc20.Contract.Symbol(&_Mintableerc20.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_Mintableerc20 *Mintableerc20Caller) TotalSupply(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _Mintableerc20.contract.Call(opts, &out, "totalSupply") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_Mintableerc20 *Mintableerc20Session) TotalSupply() (*big.Int, error) { + return _Mintableerc20.Contract.TotalSupply(&_Mintableerc20.CallOpts) +} + +// TotalSupply is a free data retrieval call binding the contract method 0x18160ddd. +// +// Solidity: function totalSupply() view returns(uint256) +func (_Mintableerc20 *Mintableerc20CallerSession) TotalSupply() (*big.Int, error) { + return _Mintableerc20.Contract.TotalSupply(&_Mintableerc20.CallOpts) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Transactor) Approve(opts *bind.TransactOpts, spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.contract.Transact(opts, "approve", spender, amount) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Session) Approve(spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Approve(&_Mintableerc20.TransactOpts, spender, amount) +} + +// Approve is a paid mutator transaction binding the contract method 0x095ea7b3. +// +// Solidity: function approve(address spender, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20TransactorSession) Approve(spender common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Approve(&_Mintableerc20.TransactOpts, spender, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x40c10f19. +// +// Solidity: function mint(address to, uint256 amount) returns() +func (_Mintableerc20 *Mintableerc20Transactor) Mint(opts *bind.TransactOpts, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.contract.Transact(opts, "mint", to, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x40c10f19. +// +// Solidity: function mint(address to, uint256 amount) returns() +func (_Mintableerc20 *Mintableerc20Session) Mint(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Mint(&_Mintableerc20.TransactOpts, to, amount) +} + +// Mint is a paid mutator transaction binding the contract method 0x40c10f19. +// +// Solidity: function mint(address to, uint256 amount) returns() +func (_Mintableerc20 *Mintableerc20TransactorSession) Mint(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Mint(&_Mintableerc20.TransactOpts, to, amount) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Transactor) Transfer(opts *bind.TransactOpts, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.contract.Transact(opts, "transfer", to, amount) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Session) Transfer(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Transfer(&_Mintableerc20.TransactOpts, to, amount) +} + +// Transfer is a paid mutator transaction binding the contract method 0xa9059cbb. +// +// Solidity: function transfer(address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20TransactorSession) Transfer(to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.Transfer(&_Mintableerc20.TransactOpts, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Transactor) TransferFrom(opts *bind.TransactOpts, from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.contract.Transact(opts, "transferFrom", from, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20Session) TransferFrom(from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.TransferFrom(&_Mintableerc20.TransactOpts, from, to, amount) +} + +// TransferFrom is a paid mutator transaction binding the contract method 0x23b872dd. +// +// Solidity: function transferFrom(address from, address to, uint256 amount) returns(bool) +func (_Mintableerc20 *Mintableerc20TransactorSession) TransferFrom(from common.Address, to common.Address, amount *big.Int) (*types.Transaction, error) { + return _Mintableerc20.Contract.TransferFrom(&_Mintableerc20.TransactOpts, from, to, amount) +} + +// Mintableerc20ApprovalIterator is returned from FilterApproval and is used to iterate over the raw logs and unpacked data for Approval events raised by the Mintableerc20 contract. +type Mintableerc20ApprovalIterator struct { + Event *Mintableerc20Approval // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *Mintableerc20ApprovalIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(Mintableerc20Approval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(Mintableerc20Approval) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *Mintableerc20ApprovalIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *Mintableerc20ApprovalIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// Mintableerc20Approval represents a Approval event raised by the Mintableerc20 contract. +type Mintableerc20Approval struct { + Owner common.Address + Spender common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterApproval is a free log retrieval operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) FilterApproval(opts *bind.FilterOpts, owner []common.Address, spender []common.Address) (*Mintableerc20ApprovalIterator, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _Mintableerc20.contract.FilterLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return &Mintableerc20ApprovalIterator{contract: _Mintableerc20.contract, event: "Approval", logs: logs, sub: sub}, nil +} + +// WatchApproval is a free log subscription operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) WatchApproval(opts *bind.WatchOpts, sink chan<- *Mintableerc20Approval, owner []common.Address, spender []common.Address) (event.Subscription, error) { + + var ownerRule []interface{} + for _, ownerItem := range owner { + ownerRule = append(ownerRule, ownerItem) + } + var spenderRule []interface{} + for _, spenderItem := range spender { + spenderRule = append(spenderRule, spenderItem) + } + + logs, sub, err := _Mintableerc20.contract.WatchLogs(opts, "Approval", ownerRule, spenderRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(Mintableerc20Approval) + if err := _Mintableerc20.contract.UnpackLog(event, "Approval", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseApproval is a log parse operation binding the contract event 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925. +// +// Solidity: event Approval(address indexed owner, address indexed spender, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) ParseApproval(log types.Log) (*Mintableerc20Approval, error) { + event := new(Mintableerc20Approval) + if err := _Mintableerc20.contract.UnpackLog(event, "Approval", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + +// Mintableerc20TransferIterator is returned from FilterTransfer and is used to iterate over the raw logs and unpacked data for Transfer events raised by the Mintableerc20 contract. +type Mintableerc20TransferIterator struct { + Event *Mintableerc20Transfer // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *Mintableerc20TransferIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(Mintableerc20Transfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(Mintableerc20Transfer) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *Mintableerc20TransferIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *Mintableerc20TransferIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// Mintableerc20Transfer represents a Transfer event raised by the Mintableerc20 contract. +type Mintableerc20Transfer struct { + From common.Address + To common.Address + Value *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterTransfer is a free log retrieval operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) FilterTransfer(opts *bind.FilterOpts, from []common.Address, to []common.Address) (*Mintableerc20TransferIterator, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _Mintableerc20.contract.FilterLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return &Mintableerc20TransferIterator{contract: _Mintableerc20.contract, event: "Transfer", logs: logs, sub: sub}, nil +} + +// WatchTransfer is a free log subscription operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) WatchTransfer(opts *bind.WatchOpts, sink chan<- *Mintableerc20Transfer, from []common.Address, to []common.Address) (event.Subscription, error) { + + var fromRule []interface{} + for _, fromItem := range from { + fromRule = append(fromRule, fromItem) + } + var toRule []interface{} + for _, toItem := range to { + toRule = append(toRule, toItem) + } + + logs, sub, err := _Mintableerc20.contract.WatchLogs(opts, "Transfer", fromRule, toRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(Mintableerc20Transfer) + if err := _Mintableerc20.contract.UnpackLog(event, "Transfer", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseTransfer is a log parse operation binding the contract event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef. +// +// Solidity: event Transfer(address indexed from, address indexed to, uint256 value) +func (_Mintableerc20 *Mintableerc20Filterer) ParseTransfer(log types.Log) (*Mintableerc20Transfer, error) { + event := new(Mintableerc20Transfer) + if err := _Mintableerc20.contract.UnpackLog(event, "Transfer", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/test/e2e/backwardforwardlet_test.go b/test/e2e/backwardforwardlet_test.go new file mode 100644 index 000000000..55a6b167b --- /dev/null +++ b/test/e2e/backwardforwardlet_test.go @@ -0,0 +1,1139 @@ +package e2e + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "flag" + "fmt" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/0xPolygon/cdk-rpc/rpc" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/aggsender/validator" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + bfl "github.com/agglayer/aggkit/tools/backward_forward_let" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +const ( + bflCertSettleTimeout = 2 * time.Minute + bflRestartTimeout = 2 * time.Minute + bflBridgeIndexWait = 2 * time.Minute +) + +// bflRunNonce is a unique value computed once per test binary run. +// It is injected as Metadata into fake bridge exits so that cert IDs (which +// include a hash of bridge-exit leaves) are unique across test runs. This +// prevents a stale agglayer "InError" cert from a previous run blocking the +// current run, since the agglayer deduplicates certs by their CertificateID. +var bflRunNonce = big.NewInt(time.Now().UnixNano()).Bytes() + +// summaryForBFLToolConfig is a minimal struct for reading summary.json to build the bfl tool config. +type summaryForBFLToolConfig struct { + Networks struct { + L1 struct { + Services struct { + Geth struct { + HTTPRpc struct { + External string `json:"external"` + } `json:"http_rpc"` + } `json:"geth"` + } `json:"services"` + } `json:"l1"` + Agglayer struct { + Services struct { + GrpcRPC struct { + External string `json:"external"` + } `json:"grpc_rpc"` + AdminAPI struct { + External string `json:"external"` + } `json:"admin_api"` + } `json:"services"` + } `json:"agglayer"` + L2Networks map[string]struct { + Services struct { + Aggkit struct { + BridgeService struct { + External string `json:"external"` + } `json:"rest_api"` + } `json:"aggkit"` + OpGeth struct { + HTTPRpc struct { + External string `json:"external"` + } `json:"http_rpc"` + } `json:"op-geth"` + } `json:"services"` + } `json:"l2_networks"` + } `json:"networks"` +} + +// bflOriginalConfig stores the backed-up aggkit config content for restoration. +// Only valid between enableDebugSendCertEndpoint and disableDebugSendCertEndpoint calls. +var bflOriginalConfig []byte + +// ============================================================================= +// Test functions +// ============================================================================= + +// TestBackwardForwardLET_NoDivergence verifies that Diagnose returns NoDivergence on a healthy system. +func TestBackwardForwardLET_NoDivergence(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.Equal(t, bfl.NoDivergence, diagnosis.Case, "expected NoDivergence on healthy system") +} + +// TestBackwardForwardLET_Case1 verifies Case1 (ForwardLET only: 1 divergent leaf, no extra L2 bridges). +func TestBackwardForwardLET_Case1(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + authKey := testEnv.Keys.SovereignAdmin + + // Enable debug endpoint so we can send certs manually. + enableDebugSendCertEndpoint(ctx, t, authKey) + + // Build bfl tool environment. + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + // Record initial L2 deposit count. + callOpts := &bind.CallOpts{Context: ctx} + initialDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, err) + initialDC := uint32(initialDCBig.Uint64()) + + certSignerKey := loadCertSignerKey(t) + + // Build and send 1 malicious cert with 1 fake bridge exit. + // On a fresh environment there is no settled cert, so buildMaliciousCert + // will start at height=0 from the empty L2 bridge state. + fakeBridgeExits := []*agglayertypes.BridgeExit{makeFakeBridgeExit(0)} + cert := buildMaliciousCert(ctx, t, toolEnv, fakeBridgeExits, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert, authKey) + log.Infof("[Case1] sent malicious cert height=%d", cert.Height) + + waitForCertificateToSettle(ctx, t, toolEnv, cert.Height) + + // Restore normal aggkit mode before diagnosis. + disableDebugSendCertEndpoint(ctx, t) + + // Re-build toolEnv with fresh state. + toolEnv.Close() + cfg2 := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg2) + require.NoError(t, err) + + // Diagnose. + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.Equal(t, bfl.Case1, diagnosis.Case) + require.Len(t, diagnosis.DivergentLeaves, 1) + require.NotEmpty(t, diagnosis.Undercollateralization) + + // Execute recovery. + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 2*time.Minute) + defer recoveryCancel() + err = bfl.ExecuteRecovery(recoveryCtx, toolEnv, diagnosis) + require.NoError(t, err) + + // Verify post-recovery state. + callOpts2 := &bind.CallOpts{Context: ctx} + postDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts2) + require.NoError(t, err) + require.Equal(t, initialDC+1, uint32(postDCBig.Uint64()), + "deposit count should be initial+1 after recovery") + + root, err := toolEnv.L2Bridge.GetRoot(callOpts2) + require.NoError(t, err) + require.Equal(t, diagnosis.L1SettledLER, common.Hash(root), + "L2 LER should match L1 settled LER after recovery") + + inEmergency, err := toolEnv.L2Bridge.IsEmergencyState(callOpts2) + require.NoError(t, err) + require.False(t, inEmergency, "L2 bridge should not be in emergency state after recovery") +} + +// TestBackwardForwardLET_Case2 verifies Case2 (BackwardLET + ForwardLET: 1 divergent leaf + extra L2 bridges). +func TestBackwardForwardLET_Case2(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + authKey := testEnv.Keys.SovereignAdmin + + // Enable debug endpoint first so we can send certs manually. + enableDebugSendCertEndpoint(ctx, t, authKey) + + // Build bfl tool environment. + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + certSignerKey := loadCertSignerKey(t) + + // Build and send 1 malicious cert with 1 fake bridge exit. + fakeBridgeExits := []*agglayertypes.BridgeExit{makeFakeBridgeExit(0)} + cert := buildMaliciousCert(ctx, t, toolEnv, fakeBridgeExits, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert, authKey) + log.Infof("[Case2] sent malicious cert height=%d", cert.Height) + + waitForCertificateToSettle(ctx, t, toolEnv, cert.Height) + + // Create 2 real L2 bridge deposits BEFORE disabling debug mode so that the bridge service + // (already running and synced) can index them quickly within createL2BridgeNoClaim's own + // poll. After the subsequent aggkit restart we wait for re-sync separately. + // With DivergencePoint=0 and l2CurrentDC=2, collectExtraL2Bridges(0,2)=[bridges at DC=0,1]. + createL2BridgeNoClaim(ctx, t) + createL2BridgeNoClaim(ctx, t) + + // Restore normal aggkit mode. + disableDebugSendCertEndpoint(ctx, t) + + // After aggkit restart the bridge service re-syncs from genesis. Wait until it has + // re-indexed all L2 bridges so that Diagnose can see the extra bridges. + waitForBridgeServiceSynced(ctx, t) + + // Re-build toolEnv. + toolEnv.Close() + cfg2 := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg2) + require.NoError(t, err) + + // Diagnose. + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.Equal(t, bfl.Case2, diagnosis.Case) + require.Len(t, diagnosis.DivergentLeaves, 1) + require.NotEmpty(t, diagnosis.ExtraL2Bridges) + + // Execute recovery. + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 2*time.Minute) + defer recoveryCancel() + err = bfl.ExecuteRecovery(recoveryCtx, toolEnv, diagnosis) + require.NoError(t, err) + + // Verify: DC should equal DivergencePoint + divergent leaves + extra real bridges. + // For Case2, L2 LER will NOT match L1 settled LER because extra real L2 bridges were + // appended after the fake leaf; the next aggsender cert will advance L1 to match. + callOpts := &bind.CallOpts{Context: ctx} + expectedDC := diagnosis.DivergencePoint + uint32(len(diagnosis.DivergentLeaves)) + + uint32(len(diagnosis.ExtraL2Bridges)) + postDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, err) + require.Equal(t, expectedDC, uint32(postDCBig.Uint64()), + "deposit count should equal DivergencePoint+divergent+extraL2 after recovery") + + inEmergency, err := toolEnv.L2Bridge.IsEmergencyState(callOpts) + require.NoError(t, err) + require.False(t, inEmergency, "L2 bridge should not be in emergency state after recovery") +} + +// TestBackwardForwardLET_Case3 verifies Case3 (ForwardLET only: 2 divergent leaves, no extra L2 bridges). +func TestBackwardForwardLET_Case3(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + authKey := testEnv.Keys.SovereignAdmin + + // Enable debug endpoint first. + enableDebugSendCertEndpoint(ctx, t, authKey) + + // Build first bfl tool environment. + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + certSignerKey := loadCertSignerKey(t) + + // Send first malicious cert. + fake1 := []*agglayertypes.BridgeExit{makeFakeBridgeExit(0)} + cert1 := buildMaliciousCert(ctx, t, toolEnv, fake1, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert1, authKey) + log.Infof("[Case3] sent malicious cert1 height=%d", cert1.Height) + waitForCertificateToSettle(ctx, t, toolEnv, cert1.Height) + + // Re-query state to get the new settled LER for cert2. + toolEnv.Close() + cfg = prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + + // Send second malicious cert. + fake2 := []*agglayertypes.BridgeExit{makeFakeBridgeExit(1)} + cert2 := buildMaliciousCert(ctx, t, toolEnv, fake2, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert2, authKey) + log.Infof("[Case3] sent malicious cert2 height=%d", cert2.Height) + waitForCertificateToSettle(ctx, t, toolEnv, cert2.Height) + + // Restore normal aggkit mode. + disableDebugSendCertEndpoint(ctx, t) + + // Re-build toolEnv for diagnosis. + toolEnv.Close() + cfg2 := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg2) + require.NoError(t, err) + + // Diagnose. + // When run after Case2 recovery, ForwardLET'd leaves from Case2 persist as extra L2 bridges, + // so the tool may classify this as Case4 instead of Case3. Both are valid. + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.True(t, diagnosis.Case == bfl.Case3 || diagnosis.Case == bfl.Case4, + "expected Case3 or Case4, got %s", diagnosis.Case) + require.GreaterOrEqual(t, len(diagnosis.DivergentLeaves), 2, + "expected at least 2 divergent leaves, got %d", len(diagnosis.DivergentLeaves)) + + // Execute recovery. + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 2*time.Minute) + defer recoveryCancel() + err = bfl.ExecuteRecovery(recoveryCtx, toolEnv, diagnosis) + require.NoError(t, err) + + // Verify using computed deposit count (works for both Case3 and Case4). + callOpts := &bind.CallOpts{Context: ctx} + expectedDC := diagnosis.DivergencePoint + uint32(len(diagnosis.DivergentLeaves)) + + uint32(len(diagnosis.ExtraL2Bridges)) + postDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, err) + require.Equal(t, expectedDC, uint32(postDCBig.Uint64()), + "deposit count should equal DivergencePoint+divergent+extraL2 after recovery") + + // When there are no extra L2 bridges (pure Case3), the LER should match L1 settled LER. + if len(diagnosis.ExtraL2Bridges) == 0 { + root, rootErr := toolEnv.L2Bridge.GetRoot(callOpts) + require.NoError(t, rootErr) + require.Equal(t, diagnosis.L1SettledLER, common.Hash(root), + "L2 LER should match L1 settled LER after Case3 recovery") + } + + inEmergency, err := toolEnv.L2Bridge.IsEmergencyState(callOpts) + require.NoError(t, err) + require.False(t, inEmergency, "L2 bridge should not be in emergency state after recovery") +} + +// TestBackwardForwardLET_Case4 verifies Case4 (BackwardLET + ForwardLET: 2 divergent leaves + extra L2 bridges). +func TestBackwardForwardLET_Case4(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + authKey := testEnv.Keys.SovereignAdmin + + // Enable debug endpoint first. + enableDebugSendCertEndpoint(ctx, t, authKey) + + // Build bfl tool environment. + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + certSignerKey := loadCertSignerKey(t) + + // Send first malicious cert. + fake1 := []*agglayertypes.BridgeExit{makeFakeBridgeExit(0)} + cert1 := buildMaliciousCert(ctx, t, toolEnv, fake1, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert1, authKey) + log.Infof("[Case4] sent malicious cert1 height=%d", cert1.Height) + waitForCertificateToSettle(ctx, t, toolEnv, cert1.Height) + + // Re-query state and send cert2. + toolEnv.Close() + cfg = prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + + fake2 := []*agglayertypes.BridgeExit{makeFakeBridgeExit(1)} + cert2 := buildMaliciousCert(ctx, t, toolEnv, fake2, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert2, authKey) + log.Infof("[Case4] sent malicious cert2 height=%d", cert2.Height) + waitForCertificateToSettle(ctx, t, toolEnv, cert2.Height) + + // Create 2 real L2 bridge deposits BEFORE disabling debug mode so the bridge service + // (already running and synced) can index them quickly within createL2BridgeNoClaim's own + // poll. After the subsequent aggkit restart we wait for re-sync separately. + // With DivergencePoint=0 and l2CurrentDC=2, collectExtraL2Bridges(0,2)=[bridges at DC=0,1]. + createL2BridgeNoClaim(ctx, t) + createL2BridgeNoClaim(ctx, t) + + // Restore normal aggkit mode. + disableDebugSendCertEndpoint(ctx, t) + + // After aggkit restart the bridge service re-syncs from genesis. Wait until it has + // re-indexed all L2 bridges so that Diagnose can see the extra bridges. + waitForBridgeServiceSynced(ctx, t) + + // Re-build toolEnv for diagnosis. + toolEnv.Close() + cfg2 := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg2) + require.NoError(t, err) + + // Diagnose. + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.Equal(t, bfl.Case4, diagnosis.Case) + require.GreaterOrEqual(t, len(diagnosis.DivergentLeaves), 2, + "expected at least 2 divergent leaves, got %d", len(diagnosis.DivergentLeaves)) + require.NotEmpty(t, diagnosis.ExtraL2Bridges) + + // Execute recovery. + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 2*time.Minute) + defer recoveryCancel() + err = bfl.ExecuteRecovery(recoveryCtx, toolEnv, diagnosis) + require.NoError(t, err) + + // Verify: DC should equal DivergencePoint + divergent leaves + extra real bridges. + // For Case4, L2 LER will NOT match L1 settled LER because extra real L2 bridges were + // appended after the fake leaves; the next aggsender cert will advance L1 to match. + callOpts := &bind.CallOpts{Context: ctx} + expectedDC := diagnosis.DivergencePoint + uint32(len(diagnosis.DivergentLeaves)) + + uint32(len(diagnosis.ExtraL2Bridges)) + postDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, err) + require.Equal(t, expectedDC, uint32(postDCBig.Uint64()), + "deposit count should equal DivergencePoint+divergent+extraL2 after recovery") + + inEmergency, err := toolEnv.L2Bridge.IsEmergencyState(callOpts) + require.NoError(t, err) + require.False(t, inEmergency, "L2 bridge should not be in emergency state after recovery") +} + +// TestBackwardForwardLET_AggsenderAPIFallback verifies the full recovery path when the aggsender +// DB is wiped. The test: +// 1. Creates a Case2 diverged state (1 fake bridge exit + 2 real L2 bridges). +// 2. Wipes the aggsender DB by restarting with a fresh StoragePath. +// 3. Runs diagnosis — expects AggsenderAPIFailed=true with cert IDs in MissingCerts. +// 4. Uses the reported cert IDs to call admin_getCertificate on the agglayer admin API. +// 5. Builds a JSON override file from the fetched bridge exits. +// 6. Runs diagnosis again with the override file — expects Case2 classification. +// 7. Executes recovery and verifies the post-recovery L2 state. +func TestBackwardForwardLET_AggsenderAPIFallback(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + authKey := testEnv.Keys.SovereignAdmin + + // Phase 1: Setup — same structure as TestBackwardForwardLET_Case2. + enableDebugSendCertEndpoint(ctx, t, authKey) + + cfg := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err := bfl.SetupEnv(ctx, cfg) + require.NoError(t, err) + defer toolEnv.Close() + + certSignerKey := loadCertSignerKey(t) + + fakeBridgeExits := []*agglayertypes.BridgeExit{makeFakeBridgeExit(0)} + cert := buildMaliciousCert(ctx, t, toolEnv, fakeBridgeExits, certSignerKey) + sendMaliciousCertificate(ctx, t, toolEnv, cert, authKey) + log.Infof("[AggsenderFallback] sent malicious cert height=%d", cert.Height) + waitForCertificateToSettle(ctx, t, toolEnv, cert.Height) + + createL2BridgeNoClaim(ctx, t) + createL2BridgeNoClaim(ctx, t) + + disableDebugSendCertEndpoint(ctx, t) + waitForBridgeServiceSynced(ctx, t) + + // Pre-collect cert IDs for all settled heights BEFORE wiping the aggsender DB. + // When run after prior tests, there are multiple settled heights but only the latest + // is auto-resolved by the diagnosis tool. Pre-collecting lets us build a complete + // override file in Phase 4 even for unresolved heights. + toolEnv.Close() + cfgPre := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfgPre) + require.NoError(t, err) + + preInfo, preInfoErr := toolEnv.AgglayerClient.GetNetworkInfo(ctx, toolEnv.L2NetworkID) + require.NoError(t, preInfoErr, "GetNetworkInfo before DB wipe") + require.NotNil(t, preInfo.SettledHeight, "expected settled certs before DB wipe") + + preCertIDs := make(map[uint64]common.Hash) + for h := uint64(0); h <= *preInfo.SettledHeight; h++ { + hh := h + certData, certErr := toolEnv.AggsenderRPC.GetCertificateHeaderPerHeight(&hh) + if certErr == nil && certData != nil && certData.Header != nil { + preCertIDs[h] = certData.Header.CertificateID + } + } + t.Logf("[AggsenderFallback] pre-collected %d cert IDs for heights 0..%d", len(preCertIDs), *preInfo.SettledHeight) + + // Phase 2: Wipe aggsender DB by restarting with a fresh StoragePath. + // Save the current config so Phase 8 cleanup can restore it. + configPath := testEnv.GetAggkitConfigPath() + preWipeConfig, err := os.ReadFile(configPath) + require.NoError(t, err) + t.Cleanup(func() { + // Phase 8: Restore the pre-wipe aggkit config. + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), bflRestartTimeout) + defer cleanupCancel() + if restoreErr := testEnv.RestartAggkitWithConfig(cleanupCtx, func(cfgPath string) error { + return os.WriteFile(cfgPath, preWipeConfig, 0o600) + }); restoreErr != nil { + t.Logf("WARNING: failed to restore aggkit config after DB wipe: %v", restoreErr) + } + }) + + freshPath := fmt.Sprintf("/tmp/aggsender-empty-%d", time.Now().UnixNano()) + restartCtx, restartCancel := context.WithTimeout(ctx, bflRestartTimeout) + defer restartCancel() + err = testEnv.RestartAggkitWithConfig(restartCtx, func(cfgPath string) error { + content, readErr := os.ReadFile(cfgPath) + if readErr != nil { + return readErr + } + // Inject StoragePath right after the [AggSender] header so it takes precedence + // over any existing StoragePath further down in the section. + patched := strings.Replace( + string(content), "[AggSender]", + "[AggSender]\nStoragePath = \""+freshPath+"\"", 1, + ) + return os.WriteFile(cfgPath, []byte(patched), 0o600) + }) + require.NoError(t, err, "restart aggkit with fresh aggsender storage") + + // Wait for bridge service to re-sync after the restart. + waitForBridgeServiceSynced(ctx, t) + + // Phase 3: First diagnosis — should report AggsenderAPIFailed because the aggsender DB + // is empty and cannot supply bridge exits for any settled height. + toolEnv.Close() + cfg3 := prepareBFLToolConfig(t, testEnv.AggsenderRPCURL) + toolEnv, err = bfl.SetupEnv(ctx, cfg3) + require.NoError(t, err) + + diagnosis, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.True(t, diagnosis.AggsenderAPIFailed, + "expected AggsenderAPIFailed=true after aggsender DB wipe") + require.NotEmpty(t, diagnosis.MissingCerts, + "expected MissingCerts to be non-empty after aggsender DB wipe") + // The malicious cert IS the latest settled cert, so its ID is auto-resolved. + require.True(t, diagnosis.MissingCerts[0].CertIDResolved, + "expected latest settled cert ID to be auto-resolved via agglayer gRPC") + + // Phase 4: Extract bridge exits from the agglayer admin API using cert IDs from tool output. + summaryPath := filepath.Join(testEnv.EnvDir, "summary.json") + summaryData, err := os.ReadFile(summaryPath) + require.NoError(t, err) + var summary summaryForBFLToolConfig + require.NoError(t, json.Unmarshal(summaryData, &summary)) + adminURL := summary.Networks.Agglayer.Services.AdminAPI.External + require.NotEmpty(t, adminURL, "agglayer admin API URL not found in summary.json") + + type overrideFileFormat struct { + NetworkID uint32 `json:"network_id"` + Description string `json:"description"` + Heights map[string][]*agglayertypes.BridgeExit `json:"heights"` + } + of := overrideFileFormat{ + NetworkID: testEnv.L2.NetworkID, + Description: "extracted by E2E test from agglayer admin API", + Heights: make(map[string][]*agglayertypes.BridgeExit), + } + // Some cert IDs may not be auto-resolved (only the latest settled height is guaranteed). + // For unresolved IDs, use the pre-collected cert IDs from before the DB wipe. + for _, mc := range diagnosis.MissingCerts { + certID := mc.CertID + if !mc.CertIDResolved { + preID, ok := preCertIDs[mc.Height] + if !ok { + t.Logf("[AggsenderFallback] skipping unresolved cert at height %d (no pre-collected ID)", mc.Height) + continue + } + certID = preID + } + adminCert := callAgglayerAdminGetCertificate(t, adminURL, certID) + require.NotNil(t, adminCert, "admin_getCertificate returned nil cert for height %d", mc.Height) + of.Heights[strconv.FormatUint(mc.Height, 10)] = adminCert.BridgeExits + } + + // Phase 5: Build JSON override file. + overrideBytes, err := json.Marshal(of) + require.NoError(t, err) + overridePath := filepath.Join(t.TempDir(), "override.json") + require.NoError(t, os.WriteFile(overridePath, overrideBytes, 0o600)) + + // Phase 6: Second diagnosis with override file — should classify as Case2. + toolEnv.Close() + cfg6 := prepareBFLToolConfigWithOverride(t, testEnv.AggsenderRPCURL, overridePath) + toolEnv, err = bfl.SetupEnv(ctx, cfg6) + require.NoError(t, err) + + diagnosis2, err := bfl.Diagnose(ctx, toolEnv) + require.NoError(t, err) + require.False(t, diagnosis2.AggsenderAPIFailed, + "expected AggsenderAPIFailed=false with override file") + // When run after other tests, accumulated state may produce Case4 instead of Case2. + require.True(t, diagnosis2.Case == bfl.Case2 || diagnosis2.Case == bfl.Case4, + "expected Case2 or Case4 diagnosis with override file, got %s", diagnosis2.Case) + require.GreaterOrEqual(t, len(diagnosis2.DivergentLeaves), 1, + "expected at least 1 divergent leaf (the fake bridge exit)") + require.NotEmpty(t, diagnosis2.ExtraL2Bridges, + "expected extra L2 bridges") + + // Phase 7: Recovery. + recoveryCtx, recoveryCancel := context.WithTimeout(ctx, 2*time.Minute) + defer recoveryCancel() + err = bfl.ExecuteRecovery(recoveryCtx, toolEnv, diagnosis2) + require.NoError(t, err) + + // Verify post-recovery L2 state. + callOpts := &bind.CallOpts{Context: ctx} + expectedDC := diagnosis2.DivergencePoint + uint32(len(diagnosis2.DivergentLeaves)) + + uint32(len(diagnosis2.ExtraL2Bridges)) + postDCBig, err := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, err) + require.Equal(t, expectedDC, uint32(postDCBig.Uint64()), + "deposit count should equal DivergencePoint+divergent+extraL2 after recovery") + + inEmergency, err := toolEnv.L2Bridge.IsEmergencyState(callOpts) + require.NoError(t, err) + require.False(t, inEmergency, "L2 bridge should not be in emergency state after recovery") +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +// enableDebugSendCertEndpoint restarts aggkit with the DebugSendCertificate endpoint enabled. +// Saves the original config for later restoration by disableDebugSendCertEndpoint. +// A t.Cleanup is registered as a safety net. +func enableDebugSendCertEndpoint(ctx context.Context, t *testing.T, authKey *ecdsa.PrivateKey) { + t.Helper() + authAddress := crypto.PubkeyToAddress(authKey.PublicKey) + + // Save original config. + configPath := testEnv.GetAggkitConfigPath() + originalContent, err := os.ReadFile(configPath) + require.NoError(t, err, "read aggkit config for backup") + bflOriginalConfig = originalContent + + restartCtx, restartCancel := context.WithTimeout(ctx, bflRestartTimeout) + defer restartCancel() + + err = testEnv.RestartAggkitWithConfig(restartCtx, func(cfgPath string) error { + content, readErr := os.ReadFile(cfgPath) + if readErr != nil { + return readErr + } + // Inject debug settings right after the [AggSender] table header. + patched := strings.Replace( + string(content), + "[AggSender]", + fmt.Sprintf("[AggSender]\nEnableDebugSendCertificate = true\nDebugSendCertificateAuthAddress = %q", + authAddress.Hex()), + 1, + ) + return os.WriteFile(cfgPath, []byte(patched), 0o600) + }) + require.NoError(t, err, "restart aggkit with debug cert endpoint") + + // Safety-net cleanup. + t.Cleanup(func() { + if bflOriginalConfig != nil { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), bflRestartTimeout) + defer cleanupCancel() + restoreErr := testEnv.RestartAggkitWithConfig(cleanupCtx, func(cfgPath string) error { + return os.WriteFile(cfgPath, bflOriginalConfig, 0o600) + }) + if restoreErr != nil { + t.Logf("WARNING: cleanup failed to restore aggkit config: %v", restoreErr) + } else { + bflOriginalConfig = nil + } + } + }) +} + +// disableDebugSendCertEndpoint restores the original aggkit config and restarts aggkit. +func disableDebugSendCertEndpoint(ctx context.Context, t *testing.T) { + t.Helper() + if bflOriginalConfig == nil { + // Already restored (e.g. by cleanup or a prior explicit call). + return + } + + savedContent := bflOriginalConfig + bflOriginalConfig = nil // clear so cleanup is a no-op + + restartCtx, restartCancel := context.WithTimeout(ctx, bflRestartTimeout) + defer restartCancel() + + err := testEnv.RestartAggkitWithConfig(restartCtx, func(cfgPath string) error { + return os.WriteFile(cfgPath, savedContent, 0o600) + }) + require.NoError(t, err, "restart aggkit with original config") +} + +// sendMaliciousCertificate signs and sends a malicious certificate via the aggsender debug endpoint. +func sendMaliciousCertificate( + ctx context.Context, t *testing.T, + toolEnv *bfl.Env, + cert *agglayertypes.Certificate, + authKey *ecdsa.PrivateKey, +) { + t.Helper() + _ = ctx + certHash, err := toolEnv.AggsenderRPC.DebugSendCertificate(cert, authKey) + require.NoError(t, err, "DebugSendCertificate height=%d", cert.Height) + log.Infof("[sendMaliciousCertificate] sent cert height=%d hash=%s", cert.Height, certHash.Hex()) +} + +// waitForCertificateToSettle polls the AggLayer until the certificate at expectedHeight is settled. +func waitForCertificateToSettle( + ctx context.Context, t *testing.T, + toolEnv *bfl.Env, + expectedHeight uint64, +) { + t.Helper() + log.Infof("[waitForCertificateToSettle] waiting for height=%d to settle", expectedHeight) + err := pollWithBackoff(ctx, bflCertSettleTimeout, backoffInitial, backoffMax, + fmt.Sprintf("cert-settle-h%d", expectedHeight), + func() (bool, error) { + hdr, pollErr := toolEnv.AgglayerClient.GetLatestSettledCertificateHeader(ctx, toolEnv.L2NetworkID) + if pollErr != nil { + // Non-fatal: may not have settled certs yet. + return false, nil + } + if hdr == nil { + return false, nil + } + return hdr.Height >= expectedHeight && hdr.Status == agglayertypes.Settled, nil + }, + ) + require.NoError(t, err, "timeout waiting for certificate at height=%d to settle", expectedHeight) +} + +// loadCertSignerKey loads the sequencer keystore (the agglayer proof signer for PP networks). +func loadCertSignerKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + keystorePath := filepath.Join(testEnv.EnvDir, "config", "001", "sequencer.keystore") + contents, err := os.ReadFile(filepath.Clean(keystorePath)) + require.NoError(t, err, "read sequencer keystore") + key, err := gethkeystore.DecryptKey(contents, keystorePassword) + require.NoError(t, err, "decrypt sequencer keystore") + return key.PrivateKey +} + +// buildMaliciousCert builds a Certificate with the given fake bridge exits, rooted at the current +// settled LET state (or height=0 if no settled cert exists yet), and signs it with certSignerKey. +// The certificate is not sent; call sendMaliciousCertificate to submit it. +func buildMaliciousCert( + ctx context.Context, t *testing.T, + toolEnv *bfl.Env, + fakeBridgeExits []*agglayertypes.BridgeExit, + certSignerKey *ecdsa.PrivateKey, +) *agglayertypes.Certificate { + t.Helper() + require.NotEmpty(t, fakeBridgeExits, "need at least one fake bridge exit") + + // Step 1 — Read current settled state from AggLayer. + // If no cert has settled yet (fresh environment), start from height=0 with empty state. + var certHeight uint64 + var prevLER common.Hash + var existingLeafCount uint32 + l1InfoTreeLeafCount := uint32(1) // default for height=0; L1 chain has at least 1 leaf + + info, infoErr := toolEnv.AgglayerClient.GetNetworkInfo(ctx, toolEnv.L2NetworkID) + if infoErr == nil && info.SettledHeight != nil { + // A previous cert has settled: build on top of it. + certHeight = *info.SettledHeight + 1 + prevLER = *info.SettledLER + existingLeafCount = uint32(*info.SettledLETLeafCount) + + // Get the L1InfoTreeLeafCount from the settled cert in the aggsender DB. + // We query the SETTLED height to avoid picking up a stale malicious cert + // stored by a previous (failed) test run. + if settledCert, certErr := toolEnv.AggsenderRPC.GetCertificateHeaderPerHeight(info.SettledHeight); certErr == nil && + settledCert != nil && settledCert.Header != nil && + settledCert.Header.L1InfoTreeLeafCount > 0 { + l1InfoTreeLeafCount = settledCert.Header.L1InfoTreeLeafCount + } + } else { + // No settled cert yet — send the very first cert at height=0. + // Read the actual L2 bridge root (the empty-tree root is non-zero) and + // deposit count so prevLER and existingLeafCount are accurate. + callOpts := &bind.CallOpts{Context: ctx} + root, rootErr := toolEnv.L2Bridge.GetRoot(callOpts) + require.NoError(t, rootErr, "GetRoot for initial prevLER") + prevLER = common.Hash(root) + + dcBig, dcErr := toolEnv.L2Bridge.DepositCount(callOpts) + require.NoError(t, dcErr, "DepositCount for initial leaf count") + existingLeafCount = uint32(dcBig.Uint64()) + + log.Infof("[buildMaliciousCert] no settled cert, height=0, prevLER=%s, dc=%d", prevLER, existingLeafCount) + } + + // Step 2 — Build existing L2 bridge leaf hashes. + // When settled certs exist, use aggsender's stored bridge exits for each settled height. + // This ensures existingHashes matches the agglayer's LET state exactly (including any + // fake exits from previously sent malicious certs). + // For a fresh environment (no settled certs), use bridge service data. + var existingHashes []common.Hash + if infoErr == nil && info.SettledHeight != nil { + existingHashes = make([]common.Hash, 0, existingLeafCount) + for h := uint64(0); h <= *info.SettledHeight; h++ { + hh := h + exits, exitsErr := toolEnv.AggsenderRPC.GetCertificateBridgeExits(&hh) + require.NoError(t, exitsErr, "GetCertificateBridgeExits height=%d", h) + for _, be := range exits { + existingHashes = append(existingHashes, bfl.BridgeExitLeafHash(be)) + } + } + } else { + existingHashes = make([]common.Hash, 0, existingLeafCount) + for dc := range existingLeafCount { + br, bridgeErr := toolEnv.BridgeService.GetBridgeByDepositCount(ctx, toolEnv.L2NetworkID, dc) + require.NoError(t, bridgeErr, "GetBridgeByDepositCount dc=%d", dc) + existingHashes = append(existingHashes, bfl.BridgeResponseLeafHash(br)) + } + } + + // Step 3 — Compute new leaf hashes for fake exits. + newHashes := make([]common.Hash, 0, len(fakeBridgeExits)) + for _, be := range fakeBridgeExits { + newHashes = append(newHashes, bfl.BridgeExitLeafHash(be)) + } + + // Step 4 — Compute the new local exit root. + newLER, err := bfl.ComputeLERForNewLeaves(existingHashes, newHashes) + require.NoError(t, err, "ComputeLERForNewLeaves") + + cert := &agglayertypes.Certificate{ + NetworkID: toolEnv.L2NetworkID, + Height: certHeight, + PrevLocalExitRoot: prevLER, + NewLocalExitRoot: newLER, + BridgeExits: fakeBridgeExits, + L1InfoTreeLeafCount: l1InfoTreeLeafCount, + } + + // Step 5 — Sign the certificate for PP (PessimisticProof) networks. + // AggchainDataMultisig.ExtractAggchainParams() returns ZeroHash, so the + // hash is the same whether computed before or after setting AggchainData. + hashToSign, err := validator.HashCertificateToSign(cert) + require.NoError(t, err, "HashCertificateToSign") + sig, err := crypto.Sign(hashToSign.Bytes(), certSignerKey) + require.NoError(t, err, "sign certificate hash") + + cert.AggchainData = &agglayertypes.AggchainDataMultisig{ + Multisig: &agglayertypes.Multisig{ + Signatures: []agglayertypes.ECDSAMultisigEntry{ + {Index: 0, Signature: sig}, + }, + }, + } + + return cert +} + +// makeFakeBridgeExit builds a fake BridgeExit using Amount=0 so the agglayer's PP +// balance check cannot underflow (zero tokens exported = zero balance needed). +// +// exitIndex differentiates exits within the same test binary run so that Case3/4 +// (which send two malicious certs) produce unique leaf hashes for each cert. +// +// DestinationAddress is derived from bflRunNonce+exitIndex to ensure uniqueness across +// runs (so agglayer does not deduplicate certs from previous runs) and within a run +// (so Case3/4 certs produce distinct leaf hashes). +// +// Metadata is nil: BridgeExit.Hash() uses EmptyBytesHash (= keccak256([])) and the +// forwardLET contract also computes keccak256([]) for empty metadata — they agree. +func makeFakeBridgeExit(exitIndex int) *agglayertypes.BridgeExit { + // Derive a unique DestinationAddress from the run nonce and per-exit index. + addrBytes := crypto.Keccak256(append(append([]byte(nil), bflRunNonce...), byte(exitIndex))) + destAddr := common.BytesToAddress(addrBytes) + return &agglayertypes.BridgeExit{ + LeafType: bridgesynctypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: 0, // mainnet native token + OriginTokenAddress: common.Address{}, // native ETH address + }, + DestinationNetwork: 0, // L1 (mainnet); cannot exit to the same network as origin (L2=1) + DestinationAddress: destAddr, // unique per run+exitIndex + Amount: big.NewInt(0), // zero amount avoids PP balance-underflow rejection + Metadata: nil, // nil: consistent with forwardLET contract's keccak256([]) + } +} + +// createL2BridgeNoClaim performs an L2→L1 BridgeAsset call using the MintableERC20 token +// (L2-native tokens bypass the Local Balance Tree underflow check) and waits for the bridge +// service to index it. Does NOT claim on L1. Used to create "extra L2 bridges" for Case2/4 tests. +func createL2BridgeNoClaim(ctx context.Context, t *testing.T) { + t.Helper() + l2Opts, l2Key, err := testEnv.Keys.L2Keys.Checkout() + require.NoError(t, err, "checkout L2 key") + defer testEnv.Keys.L2Keys.Return(l2Key) + + amount := big.NewInt(1e18) // 1 TEST token + + // Mint tokens to the sender so they have a balance to bridge out. + log.Infof("[createL2BridgeNoClaim] minting TEST tokens to %s", l2Opts.From.Hex()) + mintTx, err := testEnv.L2.Contracts.MintableERC20.Mint(l2Opts, l2Opts.From, amount) + require.NoError(t, err, "mint MintableERC20") + _, err = bind.WaitMined(ctx, testEnv.Clients.L2, mintTx) + require.NoError(t, err, "wait for mint tx") + + // Approve the L2 bridge to pull tokens on behalf of the sender. + log.Infof("[createL2BridgeNoClaim] approving L2 bridge to spend TEST tokens") + approveTx, err := testEnv.L2.Contracts.MintableERC20.Approve( + l2Opts, testEnv.L2.Contracts.L2BridgeAddress, amount, + ) + require.NoError(t, err, "approve L2 bridge for MintableERC20") + _, err = bind.WaitMined(ctx, testEnv.Clients.L2, approveTx) + require.NoError(t, err, "wait for approve tx") + + // Bridge ERC20 tokens L2→L1. No ETH value needed. + log.Infof("[createL2BridgeNoClaim] sending L2 BridgeAsset (ERC20)") + tx, err := testEnv.L2.Contracts.L2Bridge.BridgeAsset( + l2Opts, 0, l2Opts.From, amount, testEnv.L2.Contracts.MintableERC20Address, true, nil, + ) + require.NoError(t, err, "L2 BridgeAsset") + + receipt, err := bind.WaitMined(ctx, testEnv.Clients.L2, tx) + require.NoError(t, err, "wait for L2 BridgeAsset receipt") + require.Equal(t, ethtypes.ReceiptStatusSuccessful, receipt.Status, "L2 BridgeAsset tx failed") + + // Find the BridgeEvent log (ERC20 bridging also emits a Transfer log, so scan all logs). + var depositCount uint32 + var foundBridgeEvent bool + for _, lg := range receipt.Logs { + bridgeEvent, parseErr := testEnv.L2.Contracts.L2Bridge.ParseBridgeEvent(*lg) + if parseErr == nil { + depositCount = bridgeEvent.DepositCount + foundBridgeEvent = true + break + } + } + require.True(t, foundBridgeEvent, "BridgeEvent not found in BridgeAsset receipt logs") + log.Infof("[createL2BridgeNoClaim] bridge tx mined dc=%d block=%d", + depositCount, receipt.BlockNumber.Uint64()) + + // Wait for bridge service to index it. + err = pollWithBackoff(ctx, bflBridgeIndexWait, 2*time.Second, 10*time.Second, + fmt.Sprintf("bridge-service-l2-dc%d", depositCount), + func() (bool, error) { + _, indexErr := testEnv.Clients.BridgeService.GetBridgeByDepositCount( + ctx, testEnv.L2.NetworkID, depositCount, + ) + return indexErr == nil, nil + }, + ) + require.NoError(t, err, "bridge service did not index L2 bridge dc=%d", depositCount) + log.Infof("[createL2BridgeNoClaim] bridge service indexed dc=%d", depositCount) +} + +// waitForBridgeServiceSynced waits until the bridge service has re-indexed all L2 bridges up to +// the current L2 deposit count. This is needed after an aggkit restart, because the bridge +// service re-syncs from genesis and may take several minutes to process historical blocks. +func waitForBridgeServiceSynced(ctx context.Context, t *testing.T) { + t.Helper() + callOpts := &bind.CallOpts{Context: ctx} + dcBig, err := testEnv.L2.Contracts.L2Bridge.DepositCount(callOpts) + require.NoError(t, err, "get L2 deposit count for bridge-service sync check") + if dcBig.Uint64() == 0 { + return + } + lastDC := uint32(dcBig.Uint64()) - 1 + log.Infof("[waitForBridgeServiceSynced] waiting for bridge service to index dc=%d", lastDC) + err = pollWithBackoff(ctx, 1*time.Minute, 2*time.Second, 15*time.Second, + fmt.Sprintf("bridge-service-sync-to-dc%d", lastDC), + func() (bool, error) { + _, indexErr := testEnv.Clients.BridgeService.GetBridgeByDepositCount( + ctx, testEnv.L2.NetworkID, lastDC, + ) + return indexErr == nil, nil + }, + ) + require.NoError(t, err, "bridge service did not sync to dc=%d", lastDC) + log.Infof("[waitForBridgeServiceSynced] bridge service synced to dc=%d", lastDC) +} + +// prepareBFLToolConfig creates a temp config file for the backward/forward LET tool by: +// 1. Patching the host-mounted aggkit config with external (host-accessible) URLs. +// 2. Appending the [BackwardForwardLET] section. +// +// aggsenderRPCURL overrides the default one from summary.json (used for testing API fallback). +func prepareBFLToolConfig(t *testing.T, aggsenderRPCURL string) *bfl.Config { + t.Helper() + return buildBFLToolConfig(t, aggsenderRPCURL, "") +} + +// prepareBFLToolConfigWithOverride is like prepareBFLToolConfig but also sets +// CertificateExitsFile so the tool uses the JSON override file for bridge exit data. +func prepareBFLToolConfigWithOverride(t *testing.T, aggsenderRPCURL, certExitsFile string) *bfl.Config { + t.Helper() + return buildBFLToolConfig(t, aggsenderRPCURL, certExitsFile) +} + +// buildBFLToolConfig is the shared implementation for prepareBFLToolConfig and +// prepareBFLToolConfigWithOverride. certExitsFile may be empty. +func buildBFLToolConfig(t *testing.T, aggsenderRPCURL, certExitsFile string) *bfl.Config { + t.Helper() + + summaryPath := filepath.Join(testEnv.EnvDir, "summary.json") + summaryData, err := os.ReadFile(summaryPath) + require.NoError(t, err) + + var summary summaryForBFLToolConfig + require.NoError(t, json.Unmarshal(summaryData, &summary)) + + l2Network, ok := summary.Networks.L2Networks["001"] + require.True(t, ok, "L2 network 001 not found in summary.json") + + l1URL := summary.Networks.L1.Services.Geth.HTTPRpc.External + l2URL := l2Network.Services.OpGeth.HTTPRpc.External + agglayerGRPCURL := summary.Networks.Agglayer.Services.GrpcRPC.External + bridgeServiceURL := l2Network.Services.Aggkit.BridgeService.External + + sovereignAdminKeyPath := filepath.Join(testEnv.EnvDir, "config", "001", "sovereignadmin.keystore") + + // Read original config. + originalCfgPath := testEnv.GetAggkitConfigPath() + content, err := os.ReadFile(originalCfgPath) + require.NoError(t, err) + + // Patch internal docker container URLs with external host-accessible URLs. + patched := string(content) + patched = strings.ReplaceAll(patched, "http://geth:8545", l1URL) + patched = strings.ReplaceAll(patched, "http://op-geth-001:8545", l2URL) + patched = strings.ReplaceAll(patched, "http://agglayer:4443", agglayerGRPCURL) + + // Optional override file line. + certExitsFileLine := "" + if certExitsFile != "" { + certExitsFileLine = fmt.Sprintf("\nCertificateExitsFile = %q", certExitsFile) + } + + // Append [AgglayerClient] and [BackwardForwardLET] sections. + appendSection := fmt.Sprintf(` + +[AgglayerClient.GRPC] +URL = %q +MinConnectTimeout = "5s" +RequestTimeout = "300s" +UseTLS = false + +[BackwardForwardLET] +BridgeServiceURL = %q +AggsenderRPCURL = %q +L2NetworkID = %d%s + +[BackwardForwardLET.GERRemoverKey] +Method = "local" +Path = %q +Password = %q + +[BackwardForwardLET.EmergencyPauserKey] +Method = "local" +Path = %q +Password = %q + +[BackwardForwardLET.EmergencyUnpauserKey] +Method = "local" +Path = %q +Password = %q +`, + agglayerGRPCURL, + bridgeServiceURL, + aggsenderRPCURL, + testEnv.L2.NetworkID, + certExitsFileLine, + sovereignAdminKeyPath, keystorePassword, + sovereignAdminKeyPath, keystorePassword, + sovereignAdminKeyPath, keystorePassword, + ) + + tmpFile := filepath.Join(t.TempDir(), "aggkit-config-bfl-test.toml") + err = os.WriteFile(tmpFile, append([]byte(patched), []byte(appendSection)...), 0o600) + require.NoError(t, err) + + cliCtx := buildBFLToolCLIContext(t, tmpFile) + cfg, err := bfl.LoadConfig(cliCtx) + require.NoError(t, err) + return cfg +} + +// callAgglayerAdminGetCertificate calls admin_getCertificate on the agglayer admin JSON-RPC +// and returns the Certificate. The cert ID is the agglayer CertificateId resolved from +// diagnosis.MissingCerts. Requires debug-mode = true in the agglayer config. +func callAgglayerAdminGetCertificate( + t *testing.T, + adminURL string, + certID common.Hash, +) *agglayertypes.Certificate { + t.Helper() + response, err := rpc.JSONRPCCall(adminURL, "admin_getCertificate", certID) + require.NoError(t, err, "admin_getCertificate RPC call failed for certID=%s", certID.Hex()) + require.Nil(t, response.Error, "admin_getCertificate returned error: %v", response.Error) + // The result is [Certificate, CertificateHeader|null]. + var pair [2]json.RawMessage + require.NoError(t, json.Unmarshal(response.Result, &pair), + "failed to unmarshal admin_getCertificate result as [Certificate, CertificateHeader|null]") + var cert agglayertypes.Certificate + require.NoError(t, json.Unmarshal(pair[0], &cert), + "failed to unmarshal Certificate from admin_getCertificate pair[0]") + return &cert +} + +// buildBFLToolCLIContext creates a *cli.Context with --cfg pointing to configPath +// so that bfl.LoadConfig can be used from tests. +func buildBFLToolCLIContext(t *testing.T, configPath string) *cli.Context { + t.Helper() + app := cli.NewApp() + app.Flags = []cli.Flag{ + &cli.StringSliceFlag{Name: "cfg", Aliases: []string{"c"}}, + } + set := flag.NewFlagSet("", flag.ContinueOnError) + for _, f := range app.Flags { + require.NoError(t, f.Apply(set)) + } + require.NoError(t, set.Parse([]string{"--cfg", configPath})) + return cli.NewContext(app, set, nil) +} diff --git a/test/e2e/bridge_utils.go b/test/e2e/bridge_utils.go index 1669d549f..c31482583 100644 --- a/test/e2e/bridge_utils.go +++ b/test/e2e/bridge_utils.go @@ -125,7 +125,7 @@ func BridgeL1ToL2(ctx context.Context, env *envs.Env, l1Opts, l2Opts *bind.Trans mainnetExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.MainnetExitRoot)) rollupExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.RollupExitRoot)) originTokenAddress := common.HexToAddress(string(bridge.OriginAddress)) - metadata := common.Hex2Bytes(bridge.Metadata) + metadata := common.FromHex(bridge.Metadata) log.Debugf("sending claim transaction on L2") claimTx, err := env.L2.Contracts.L2Bridge.ClaimAsset( l2Opts, smtProofLocalExitRoot, smtProofRollupExitRoot, @@ -267,7 +267,7 @@ func BridgeL1ToL2WithResult(ctx context.Context, env *envs.Env, l1Opts, l2Opts * mainnetExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.MainnetExitRoot)) rollupExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.RollupExitRoot)) originTokenAddress := common.HexToAddress(string(bridge.OriginAddress)) - metadata := common.Hex2Bytes(bridge.Metadata) + metadata := common.FromHex(bridge.Metadata) log.Debugf("sending claim transaction on L2") claimTx, err := env.L2.Contracts.L2Bridge.ClaimAsset( l2Opts, smtProofLocalExitRoot, smtProofRollupExitRoot, @@ -495,7 +495,7 @@ func BridgeL2ToL1(ctx context.Context, env *envs.Env, l1Opts, l2Opts *bind.Trans mainnetExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.MainnetExitRoot)) rollupExitRoot := common.HexToHash(string(claimProof.L1InfoTreeLeaf.RollupExitRoot)) originTokenAddress := common.HexToAddress(string(bridge.OriginAddress)) - metadata := common.Hex2Bytes(bridge.Metadata) + metadata := common.FromHex(bridge.Metadata) log.Debugf("sending L2->L1 claim transaction on L1") claimTx, err := env.L1.Contracts.Bridge.ClaimAsset( l1Opts, smtProofLocalExitRoot, smtProofRollupExitRoot, bridge.GlobalIndex, diff --git a/test/e2e/envs/loader.go b/test/e2e/envs/loader.go index 5a9568d31..27c9c3be1 100644 --- a/test/e2e/envs/loader.go +++ b/test/e2e/envs/loader.go @@ -19,6 +19,7 @@ import ( "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" "github.com/agglayer/aggkit/bridgeservice/client" "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/test/contracts/mintableerc20" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/common" @@ -47,6 +48,7 @@ type Env struct { Clients ClientsConfig Keys KeysConfig EnvDir string + AggsenderRPCURL string // External URL of the aggsender JSON-RPC endpoint envName ENVName startedCompose bool // Track if we started docker compose (so we know if we should stop it) bridgeServiceURL string // Used by StartAggkit to wait for bridge readiness @@ -91,9 +93,11 @@ type L2Config struct { // L2Contracts contains initialized L2 contract bindings type L2Contracts struct { - L2Bridge *agglayerbridgel2.Agglayerbridgel2 - L2BridgeAddress common.Address - GlobalExitRoot *agglayergerl2.Agglayergerl2 + L2Bridge *agglayerbridgel2.Agglayerbridgel2 + L2BridgeAddress common.Address + GlobalExitRoot *agglayergerl2.Agglayergerl2 + MintableERC20 *mintableerc20.Mintableerc20 + MintableERC20Address common.Address } // ClientsConfig contains RPC clients @@ -137,6 +141,9 @@ type summaryJSON struct { } `json:"http_rpc"` } `json:"op-geth"` Aggkit struct { + RPC struct { + External string `json:"external"` + } `json:"rpc"` BridgeService struct { External string `json:"external"` } `json:"rest_api"` @@ -286,6 +293,34 @@ func LoadEnv(ctx context.Context, envName ENVName) (*Env, error) { return nil, fmt.Errorf("initialize global exit root contract: %w", err) } + // Deploy MintableERC20 on L2 for use in tests that need to bridge L2-native tokens. + // L2-native tokens bypass the Local Balance Tree underflow check in AgglayerBridgeL2. + var deployerKey *ecdsa.PrivateKey + for _, account := range l2Network.Accounts { + if account.PrivateKey != nil && *account.PrivateKey != "" { + deployerKey, err = parsePrivateKey(*account.PrivateKey) + if err != nil { + return nil, fmt.Errorf("parse deployer key for MintableERC20: %w", err) + } + break + } + } + if deployerKey == nil { + return nil, fmt.Errorf("no L2 account with private key found for MintableERC20 deployment") + } + deployerAuth, err := bind.NewKeyedTransactorWithChainID(deployerKey, l2ChainID) + if err != nil { + return nil, fmt.Errorf("create deployer transactor for MintableERC20: %w", err) + } + erc20Addr, erc20Tx, erc20Contract, err := mintableerc20.DeployMintableerc20(deployerAuth, l2Client, "TestToken", "TEST") + if err != nil { + return nil, fmt.Errorf("deploy MintableERC20: %w", err) + } + if _, err := bind.WaitMined(ctx, l2Client, erc20Tx); err != nil { + return nil, fmt.Errorf("wait for MintableERC20 deployment: %w", err) + } + log.Infof("[LoadEnv] MintableERC20 deployed at %s", erc20Addr.Hex()) + // Collect all L1 keys with private_key for the pool (deduplicate by address) seenL1Addr := make(map[common.Address]bool) var l1Keys []*ecdsa.PrivateKey @@ -362,9 +397,11 @@ func LoadEnv(ctx context.Context, envName ENVName) (*Env, error) { ChainID: l2ChainID, NetworkID: l2NetworkID, Contracts: L2Contracts{ - L2Bridge: l2Bridge, - L2BridgeAddress: l2BridgeAddr, - GlobalExitRoot: globalExitRoot, + L2Bridge: l2Bridge, + L2BridgeAddress: l2BridgeAddr, + GlobalExitRoot: globalExitRoot, + MintableERC20: erc20Contract, + MintableERC20Address: erc20Addr, }, Transactor: l2Transactor, }, @@ -380,6 +417,7 @@ func LoadEnv(ctx context.Context, envName ENVName) (*Env, error) { SovereignAdmin: sovereignAdminKey, }, EnvDir: envDir, + AggsenderRPCURL: l2Network.Services.Aggkit.RPC.External, envName: envName, startedCompose: startedCompose, bridgeServiceURL: l2Network.Services.Aggkit.BridgeService.External, @@ -527,6 +565,33 @@ func (e *Env) StartAggkit(ctx context.Context) error { return nil } +// GetAggkitConfigPath returns the path to the aggkit config file on the host. +func (e *Env) GetAggkitConfigPath() string { + return filepath.Join(e.EnvDir, "config", "001", "aggkit-config.toml") +} + +// StopAggkitAndEditConfig stops aggkit and calls editFn with the config file path. +// The caller is responsible for restarting aggkit after editing the config. +func (e *Env) StopAggkitAndEditConfig(ctx context.Context, editFn func(configPath string) error) error { + if err := e.StopAggkit(ctx); err != nil { + return fmt.Errorf("stop aggkit: %w", err) + } + configPath := e.GetAggkitConfigPath() + if err := editFn(configPath); err != nil { + return fmt.Errorf("edit aggkit config %s: %w", configPath, err) + } + return nil +} + +// RestartAggkitWithConfig stops aggkit, calls editFn to modify the config, then starts aggkit. +// Waits for the bridge service to be ready before returning. +func (e *Env) RestartAggkitWithConfig(ctx context.Context, editFn func(configPath string) error) error { + if err := e.StopAggkitAndEditConfig(ctx, editFn); err != nil { + return err + } + return e.StartAggkit(ctx) +} + // ensureDockerComposeRunning ensures docker compose is running for the given environment // Returns true if we started docker compose, false if it was already running func ensureDockerComposeRunning(ctx context.Context, envDir string) (bool, error) { diff --git a/test/e2e/envs/op-pp/config/001/aggkit-config.toml b/test/e2e/envs/op-pp/config/001/aggkit-config.toml index c0f2b5e84..963e7492e 100644 --- a/test/e2e/envs/op-pp/config/001/aggkit-config.toml +++ b/test/e2e/envs/op-pp/config/001/aggkit-config.toml @@ -110,7 +110,7 @@ Environment = "development" # log. Generally we'll switch to debug if we want to troubleshoot # something specifically otherwise we leave it at info # ------------------------------------------------------------------------------ -Level = "info" +Level = "debug" # ------------------------------------------------------------------------------ # Outputs define the output paths for writing logs. The default is to @@ -626,4 +626,3 @@ Capacity = 100 [Validator.AgglayerClient.GRPC] URL = "http://agglayer:4443" UseTLS = false - diff --git a/test/e2e/envs/op-pp/docker-compose.yml b/test/e2e/envs/op-pp/docker-compose.yml index 9b0bb4ba4..cade78ed0 100644 --- a/test/e2e/envs/op-pp/docker-compose.yml +++ b/test/e2e/envs/op-pp/docker-compose.yml @@ -203,6 +203,50 @@ services: environment: - RUST_BACKTRACE=1 + # aggkit-001: + # image: aggkit:local-debug + # container_name: cdk-20260216-212314-aggkit-001 + # hostname: aggkit-001 + # entrypoint: + # - "/usr/local/bin/dlv" + # - "exec" + # - "/usr/local/bin/aggkit" + # - "--headless" + # - "--listen=:40000" + # - "--api-version=2" + # - "--accept-multiclient" + # - "--log" + # - "--" + # command: + # - "run" + # - "--cfg=/etc/aggkit/config.toml" + # - "--components=aggsender,aggoracle,bridge" + # volumes: + # - ./config/001/aggkit-config.toml:/etc/aggkit/config.toml:ro + # - ./config/001/sequencer.keystore:/etc/aggkit/sequencer.keystore:ro + # - ./config/001/aggoracle.keystore:/etc/aggkit/aggoracle.keystore:ro + # - ./config/001/sovereignadmin.keystore:/etc/aggkit/sovereignadmin.keystore:ro + # ports: + # - "11576:5576" # RPC + # - "11577:5577" # REST API + # - "40000:40000" # dlv remote debug + # cap_add: + # - SYS_PTRACE + # security_opt: + # - seccomp:unconfined + # depends_on: + # geth: + # condition: service_healthy + # op-geth-001: + # condition: service_healthy + # op-node-001: + # condition: service_healthy + # agglayer: + # condition: service_healthy + # restart: unless-stopped + # environment: + # - RUST_BACKTRACE=1 + volumes: l2-shared-001: # L1 state is baked in, L2 starts fresh with config-only mounts diff --git a/test/e2e/testmain_test.go b/test/e2e/testmain_test.go index 6ece88e14..f0e093863 100644 --- a/test/e2e/testmain_test.go +++ b/test/e2e/testmain_test.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "math/big" "os" "strings" "testing" @@ -9,7 +10,7 @@ import ( "github.com/agglayer/aggkit/log" "github.com/agglayer/aggkit/test/e2e/envs" - "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/accounts/abi/bind" ) var testEnv *envs.Env @@ -27,7 +28,7 @@ func TestMain(m *testing.M) { return } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) env, err := envs.LoadEnv(ctx, envs.EnvOpPP) if err != nil { @@ -50,21 +51,60 @@ func TestMain(m *testing.M) { if code == 0 { log.Info("Running a L1 -> L2 and L2 -> L1 bridge flow to check network health post-test...") bridgeCheckCtx, bridgeCancel := context.WithTimeout(context.Background(), 8*time.Minute) - l1Opts := env.L1.Transactor + l2Opts := env.L2.Transactor - bridgeL1L2Err := BridgeL1ToL2(bridgeCheckCtx, env, l1Opts, l2Opts) - if bridgeL1L2Err != nil { + + // Mint and approve ERC20 tokens on L2 before bridging (L2-native tokens bypass + // the Local Balance Tree underflow check in the L2 bridge contract). + mintAmount := big.NewInt(1e18) + mintTx, err := env.L2.Contracts.MintableERC20.Mint(l2Opts, l2Opts.From, mintAmount) + if err != nil { + bridgeCancel() + log.Fatalf("[POSTTEST] Failed to mint ERC20 tokens: %v", err) + } + if _, err := bind.WaitMined(bridgeCheckCtx, env.Clients.L2, mintTx); err != nil { + bridgeCancel() + log.Fatalf("[POSTTEST] Failed to wait for ERC20 mint tx: %v", err) + } + + approveTx, err := env.L2.Contracts.MintableERC20.Approve( + l2Opts, env.L2.Contracts.L2BridgeAddress, mintAmount, + ) + if err != nil { bridgeCancel() - log.Fatalf(`[POSTTEST] Bridge flows post-test check failed: L1->L2: %v. - Note that test env will not be cleaned for further debugging`, bridgeL1L2Err) + log.Fatalf("[POSTTEST] Failed to approve ERC20 tokens for L2 bridge: %v", err) } - bridgeL2L1Err := BridgeL2ToL1(bridgeCheckCtx, env, l1Opts, l2Opts, common.Address{}) - if bridgeL2L1Err != nil { + if _, err := bind.WaitMined(bridgeCheckCtx, env.Clients.L2, approveTx); err != nil { bridgeCancel() - log.Fatalf(`[POSTTEST] Bridge flows post-test check failed: L2->L1: %v. - Note that test env will not be cleaned for further debugging`, bridgeL2L1Err) + log.Fatalf("[POSTTEST] Failed to wait for ERC20 approve tx: %v", err) } + + // Run L1->L2 and L2->L1 bridges in parallel. + // Each goroutine gets its own copy of the transactors so that mutations to + // fields like Value (done by BridgeL1ToL2 for the ETH bridge tx) don't race + // with the other goroutine's transactions. + l1l2ErrCh := make(chan error, 1) + l2l1ErrCh := make(chan error, 1) + + l1OptsL1L2, l2OptsL1L2 := *env.L1.Transactor, *env.L2.Transactor + l1OptsL2L1, l2OptsL2L1 := *env.L1.Transactor, *env.L2.Transactor + + go func() { + l1l2ErrCh <- BridgeL1ToL2(bridgeCheckCtx, env, &l1OptsL1L2, &l2OptsL1L2) + }() + go func() { + l2l1ErrCh <- BridgeL2ToL1(bridgeCheckCtx, env, &l1OptsL2L1, &l2OptsL2L1, env.L2.Contracts.MintableERC20Address) + }() + + bridgeL1L2Err := <-l1l2ErrCh + bridgeL2L1Err := <-l2l1ErrCh + bridgeCancel() + + if bridgeL1L2Err != nil || bridgeL2L1Err != nil { + log.Fatalf(`[POSTTEST] Bridge flows post-test check failed: L1->L2: %v, L2->L1: %v. + Note that test env will not be cleaned for further debugging`, bridgeL1L2Err, bridgeL2L1Err) + } log.Infof("[POSTTEST] Bridge flows post-test check succeeded.") } diff --git a/tools/backward_forward_let/RECOVERY_PROCEDURE.md b/tools/backward_forward_let/RECOVERY_PROCEDURE.md new file mode 100644 index 000000000..9cd2549a8 --- /dev/null +++ b/tools/backward_forward_let/RECOVERY_PROCEDURE.md @@ -0,0 +1,246 @@ +# Backward/Forward LET — Manual Recovery Procedure + +This document describes the steps for recovering from a backward/forward LET divergence +when the aggsender database is empty or has been wiped. In this situation the tool cannot +fetch bridge exits from the aggsender RPC and instead needs the data extracted directly +from the agglayer node. + +--- + +## Prerequisites + +- The agglayer node must have `debug-mode = true` in its configuration. + In the op-pp E2E environment this is already set (`debug-mode = true` in + `test/e2e/envs/op-pp/config/agglayer/config.toml`). +- The agglayer admin JSON-RPC API must be reachable (default port 4446). + The URL is exposed as `agglayer.services.admin_api.external` in `summary.json`. +- `curl` and `jq` must be installed on the operator's machine (`jq` is optional but + makes the JSON manipulation much more convenient). + +--- + +## Step 1 — Run the tool to discover missing cert IDs + +```bash +backward-forward-let --cfg aggkit-config.toml +``` + +When the aggsender DB is empty the tool prints an actionable report: + +``` +WARNING: Aggsender RPC returned no bridge exit data for the following certificate heights. +Recovery cannot proceed until this data is provided. + +Missing certificates (2 heights): + Height 3 CertID: 0xabc123...def456 [ID auto-resolved] + Height 2 CertID: UNKNOWN [contact agglayer admin for cert ID] +``` + +- **`[ID auto-resolved]`** — the tool resolved the cert ID from the agglayer gRPC. You can + call `admin_getCertificate` directly in Step 2. +- **`UNKNOWN`** — the cert ID could not be resolved automatically (only the latest settled + height is resolvable via the public gRPC). The agglayer admin must look up + `(network_id, height)` in the `certificate_per_network_cf` column family of the agglayer + state DB and supply the cert ID manually before you can proceed. + +--- + +## Step 2 — Fetch each certificate from the agglayer admin API + +For each cert ID printed by the tool, call `admin_getCertificate`: + +```bash +AGGLAYER_ADMIN="http://localhost:4446" +CERT_ID="0xabc123...def456" + +curl -s -X POST "$AGGLAYER_ADMIN" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"admin_getCertificate\",\"params\":[\"$CERT_ID\"],\"id\":1}" \ + | jq '.' +``` + +The response is a JSON-RPC result where `result` is a two-element array +`[Certificate, CertificateHeader|null]`: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": [ + { + "network_id": 1, + "height": 3, + "bridge_exits": [ ... ], + ... + }, + { ... } + ] +} +``` + +You need `result[0].bridge_exits` from each response. + +--- + +## Step 3 — Build the JSON override file + +### Field name note + +The override file uses the **Go `json` tag names** from `agglayertypes.BridgeExit`: + +| Go field | JSON key | +|----------------------|------------------------| +| `LeafType` | `leaf_type` | +| `TokenInfo` | `token_info` | +| `DestinationNetwork` | `dest_network` | +| `DestinationAddress` | `dest_address` | +| `Amount` | `amount` (decimal string) | +| `Metadata` | `metadata` (base64 or null) | + +The agglayer Rust serde may use different field names (e.g., `destination_network` +instead of `dest_network`). **Do not paste the raw `jq` output directly** unless you +have verified the field names match. The safest approach is to let Go do the translation +by using a small helper script (see below). + +### Option A — Shell script (single cert, no Go tooling) + +Verify that the field names in the admin API response match the table above before using +this option. If they do, you can pipe the `bridge_exits` array straight into the file: + +```bash +AGGLAYER_ADMIN="http://localhost:4446" +CERT_ID="0xabc123...def456" +HEIGHT=3 +NETWORK_ID=1 + +BRIDGE_EXITS=$(curl -s -X POST "$AGGLAYER_ADMIN" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"admin_getCertificate\",\"params\":[\"$CERT_ID\"],\"id\":1}" \ + | jq '.result[0].bridge_exits') + +cat > certificate_exits_override.json < certificate_exits_override.json < 0 { + result.FailedCertHeight = missingErr.missing[0].Height + result.FailedCertID = missingErr.missing[0].CertID + } + return result, nil + } + + result.DivergentLeaves = divergentLeaves + if divFound { + result.DivergencePoint = divPoint + } else { + // All settled leaves diverge; DivergencePoint = 0 (nothing matched). + result.DivergencePoint = 0 + } + + // Step 5 — Classify the case. + result.Case = classifyCase(result.L2CurrentDepositCount, result.DivergencePoint, len(result.DivergentLeaves)) + + // Step 6 — Collect ExtraL2Bridges for Cases 2 and 4. + if result.Case == Case2 || result.Case == Case4 { + extra, err := collectExtraL2Bridges(ctx, env, result.DivergencePoint, result.L2CurrentDepositCount) + if err != nil { + return nil, fmt.Errorf("collect extra L2 bridges: %w", err) + } + result.ExtraL2Bridges = extra + } + + // Step 7 — Compute undercollateralization. + result.Undercollateralization = computeUndercollateralization(result.DivergentLeaves) + + return result, nil +} + +// missingCertsError is returned when one or more heights have no bridge exit data. +type missingCertsError struct { + missing []MissingCertInfo +} + +// getBridgeExitsForHeight fetches bridge exits for a certificate height using a +// two-source fallback chain: +// 1. Aggsender RPC (primary) — works when the aggsender DB is intact. +// 2. JSON override file (secondary) — operator-supplied pre-extracted data. +// +// An error is returned only when both sources fail or the override has no entry +// for the given height. +func getBridgeExitsForHeight(env *Env, height uint64) ([]*agglayertypes.BridgeExit, error) { + exits, err := env.AggsenderRPC.GetCertificateBridgeExits(&height) + if err == nil { + return exits, nil + } + if env.BridgeExitsOverride != nil { + if overrideExits, ok := env.BridgeExitsOverride.GetExits(height); ok { + return overrideExits, nil + } + } + return nil, fmt.Errorf("no bridge exit data for height %d: aggsender: %w", height, err) +} + +// findDivergencePoint walks settled certificate heights from newest to oldest. +// It returns (divergentLeaves, divergencePoint, found, missingCertsError). +// If missingCertsError is non-nil, one or more heights had no bridge exit data and +// the result is partial. The caller must supply override data for all missing heights +// before the diagnosis can be completed. +func findDivergencePoint( + ctx context.Context, + env *Env, + settledHeight uint64, + totalSettledLeaves uint32, + settledCertID common.Hash, +) ([]*agglayertypes.BridgeExit, uint32, bool, *missingCertsError) { + dcEnd := totalSettledLeaves + var divergentLeaves []*agglayertypes.BridgeExit + var missing []MissingCertInfo + + for h := settledHeight; ; h-- { + exits, err := getBridgeExitsForHeight(env, h) + if err != nil { + // Determine cert ID for the error report. + // Only the latest settled height can have its cert ID auto-resolved. + certIDResolved := h == settledHeight && settledCertID != (common.Hash{}) + certID := common.Hash{} + if certIDResolved { + certID = settledCertID + } + missing = append(missing, MissingCertInfo{ + Height: h, + CertID: certID, + CertIDResolved: certIDResolved, + }) + if h == 0 { + break + } + continue + } + + n := uint32(len(exits)) + if n == 0 { + // Empty certificate; skip. + if h == 0 { + break + } + continue + } + + dcStart := dcEnd - n + + // Compare each exit in this certificate against the L2 bridge service. + allMatch := checkCertExitsMatchL2(ctx, env, exits, dcStart) + + if allMatch && len(missing) == 0 { + // No missing entries and this cert fully matches L2; divergence starts after it. + return divergentLeaves, dcEnd, true, nil + } + + if !allMatch { + // Prepend exits (maintain ascending deposit-count order) and advance window. + divergentLeaves = append(exits, divergentLeaves...) + dcEnd = dcStart + } + // If allMatch but missing > 0: the walk is incomplete due to missing heights above. + // Continue to collect any remaining missing heights below this cert. + + if h == 0 { + break + } + } + + if len(missing) > 0 { + return nil, 0, false, &missingCertsError{missing: missing} + } + + // No fully-matching certificate found. + return divergentLeaves, 0, false, nil +} + +// checkCertExitsMatchL2 returns true if all bridge exits in the certificate match +// the L2 bridge service data at their corresponding deposit counts. +func checkCertExitsMatchL2( + ctx context.Context, + env *Env, + exits []*agglayertypes.BridgeExit, + dcStart uint32, +) bool { + for i, exit := range exits { + dc := dcStart + uint32(i) + br, err := env.BridgeService.GetBridgeByDepositCount(ctx, env.L2NetworkID, dc) + if err != nil { + // Not found or error — treat as mismatch. + return false + } + if BridgeExitLeafHash(exit) != BridgeResponseLeafHash(br) { + return false + } + } + return true +} + +// classifyCase returns the RecoveryCase based on the L2 deposit count, +// the divergence point (number of matching leading leaves), and the +// number of divergent L1-settled leaves. +func classifyCase(l2CurrentDC, divergencePoint uint32, numDivergentLeaves int) RecoveryCase { + hasExtraL2 := l2CurrentDC > divergencePoint + multipleL1 := numDivergentLeaves > 1 + + switch { + case !hasExtraL2 && !multipleL1: + return Case1 + case hasExtraL2 && !multipleL1: + return Case2 + case !hasExtraL2 && multipleL1: + return Case3 + default: // hasExtraL2 && multipleL1 + return Case4 + } +} + +// collectExtraL2Bridges gathers real L2 bridges for deposit counts [startDC, endDC). +func collectExtraL2Bridges( + ctx context.Context, + env *Env, + startDC, endDC uint32, +) ([]bridgesync.LeafData, error) { + extra := make([]bridgesync.LeafData, 0, endDC-startDC) + for dc := startDC; dc < endDC; dc++ { + br, err := env.BridgeService.GetBridgeByDepositCount(ctx, env.L2NetworkID, dc) + if err != nil { + if isNotFound(err) { + continue + } + return nil, fmt.Errorf("get L2 bridge at DC=%d: %w", dc, err) + } + extra = append(extra, BridgeResponseToLeafData(br)) + } + return extra, nil +} + +// computeUndercollateralization groups divergent leaves by token and sums their amounts. +func computeUndercollateralization(leaves []*agglayertypes.BridgeExit) []UndercollateralizedToken { + type tokenKey struct { + OriginNetwork uint32 + OriginAddress common.Address + } + totals := make(map[tokenKey]*big.Int) + order := make([]tokenKey, 0) + + for _, leaf := range leaves { + if leaf.TokenInfo == nil { + continue + } + key := tokenKey{ + OriginNetwork: leaf.TokenInfo.OriginNetwork, + OriginAddress: leaf.TokenInfo.OriginTokenAddress, + } + amount := leaf.Amount + if amount == nil { + amount = big.NewInt(0) + } + if _, exists := totals[key]; !exists { + totals[key] = new(big.Int) + order = append(order, key) + } + totals[key].Add(totals[key], amount) + } + + result := make([]UndercollateralizedToken, 0, len(order)) + for _, key := range order { + result = append(result, UndercollateralizedToken{ + TokenOriginNetwork: key.OriginNetwork, + TokenOriginAddress: key.OriginAddress, + Amount: totals[key], + }) + } + return result +} + +// isNotFound returns true if the error is a bridgeservice ErrNotFound sentinel. +func isNotFound(err error) bool { + return errors.Is(err, bridgeservice.ErrNotFound) +} + +// PrintDiagnosis prints a human-readable diagnosis summary to w. +func PrintDiagnosis(w io.Writer, result *DiagnosisResult) { + fmt.Fprintln(w, "=== Backward/Forward LET Diagnosis ===") + fmt.Fprintln(w) + + // L1 vs L2 state table. + fmt.Fprintf(w, "%-30s %-66s %s\n", "State", "LER", "Deposit Count") + fmt.Fprintf(w, "%-30s %-66s %d\n", "L1 Settled (AggLayer)", + result.L1SettledLER.Hex(), result.L1SettledDepositCount) + fmt.Fprintf(w, "%-30s %-66s %d\n", "L2 On-Chain (Bridge)", + result.L2CurrentLER.Hex(), result.L2CurrentDepositCount) + fmt.Fprintf(w, "L1 Settled Height: %d\n", result.L1SettledHeight) + fmt.Fprintf(w, "L1 Settled Certificate ID: %s\n", result.L1SettledCertificateID.Hex()) + fmt.Fprintln(w) + + if result.IsEmergencyState { + fmt.Fprintln(w, "WARNING: L2 bridge is currently in emergency state (paused).") + fmt.Fprintln(w) + } + + if result.Case == NoDivergence { + fmt.Fprintln(w, "Case: NoDivergence — L1 settled state and L2 on-chain state are in sync.") + return + } + + if result.AggsenderAPIFailed { + printMissingCertReport(w, result) + return + } + + fmt.Fprintf(w, "Case: %s\n", caseDescription(result.Case)) + fmt.Fprintf(w, "Divergence Point (matching leaf count): %d\n", result.DivergencePoint) + fmt.Fprintln(w) + + // Divergent leaves table. + if len(result.DivergentLeaves) > 0 { + fmt.Fprintf(w, "Divergent L1-Settled Leaves (%d):\n", len(result.DivergentLeaves)) + fmt.Fprintf(w, " %-8s %-10s %-42s %-10s %-42s %s\n", + "LeafType", "OriginNet", "OriginAddr", "DestNet", "DestAddr", "Amount") + for i, be := range result.DivergentLeaves { + originNet := uint32(0) + originAddr := common.Address{} + if be.TokenInfo != nil { + originNet = be.TokenInfo.OriginNetwork + originAddr = be.TokenInfo.OriginTokenAddress + } + amount := big.NewInt(0) + if be.Amount != nil { + amount = be.Amount + } + fmt.Fprintf(w, " [%d] %-8d %-10d %-42s %-10d %-42s %s\n", + i, be.LeafType.Uint8(), originNet, originAddr.Hex(), + be.DestinationNetwork, be.DestinationAddress.Hex(), + amount.String()) + } + fmt.Fprintln(w) + } + + // Extra L2 bridges table. + if len(result.ExtraL2Bridges) > 0 { + fmt.Fprintf(w, "Extra Real L2 Bridges (%d):\n", len(result.ExtraL2Bridges)) + fmt.Fprintf(w, " %-8s %-10s %-42s %-10s %-42s %s\n", + "LeafType", "OriginNet", "OriginAddr", "DestNet", "DestAddr", "Amount") + for i, ld := range result.ExtraL2Bridges { + amount := big.NewInt(0) + if ld.Amount != nil { + amount = ld.Amount + } + fmt.Fprintf(w, " [%d] %-8d %-10d %-42s %-10d %-42s %s\n", + i, ld.LeafType, ld.OriginNetwork, ld.OriginAddress.Hex(), + ld.DestinationNetwork, ld.DestinationAddress.Hex(), + amount.String()) + } + fmt.Fprintln(w) + } + + // Undercollateralization table. + if len(result.Undercollateralization) > 0 { + fmt.Fprintf(w, "Undercollateralized Tokens (%d):\n", len(result.Undercollateralization)) + fmt.Fprintf(w, " %-10s %-42s %s\n", "OriginNet", "OriginAddr", "Amount") + for _, uc := range result.Undercollateralization { + fmt.Fprintf(w, " %-10d %-42s %s\n", + uc.TokenOriginNetwork, uc.TokenOriginAddress.Hex(), uc.Amount.String()) + } + fmt.Fprintln(w) + } + + // Recovery summary. + fmt.Fprintln(w, "=== Recovery Plan ===") + printRecoveryPlanSummary(w, result) +} + +// printMissingCertReport prints actionable, copy-pasteable instructions when one +// or more certificate heights had no bridge exit data from any source. +// It lists each missing height with its cert ID (or UNKNOWN), explains how to call +// admin_getCertificate on the agglayer, shows the override file template with the +// actual heights, and prints the re-run command. +func printMissingCertReport(w io.Writer, result *DiagnosisResult) { + fmt.Fprintln(w, "WARNING: Aggsender RPC returned no bridge exit data for the following certificate heights.") + fmt.Fprintln(w, "Recovery cannot proceed until this data is provided.") + fmt.Fprintln(w) + + n := len(result.MissingCerts) + heightWord := "heights" + if n == 1 { + heightWord = "height" + } + fmt.Fprintf(w, "Missing certificates (%d %s):\n", n, heightWord) + + hasUnknown := false + for _, mc := range result.MissingCerts { + if mc.CertIDResolved { + fmt.Fprintf(w, " Height %-6d CertID: %s [ID auto-resolved]\n", + mc.Height, mc.CertID.Hex()) + } else { + fmt.Fprintf(w, " Height %-6d CertID: UNKNOWN [contact agglayer admin for cert ID]\n", + mc.Height) + hasUnknown = true + } + } + fmt.Fprintln(w) + + if hasUnknown { + fmt.Fprintln(w, "NOTE: For heights with UNKNOWN cert IDs, ask the agglayer admin to look up") + fmt.Fprintln(w, " (network_id, height) in the agglayer's certificate_per_network_cf column family,") + fmt.Fprintln(w, " or check aggsender submission logs for the certificate ID at that height.") + fmt.Fprintln(w) + } + + fmt.Fprintln(w, "To extract bridge exits for each KNOWN cert ID:") + fmt.Fprintln(w, " POST http:///") + fmt.Fprintln(w, " Content-Type: application/json") + fmt.Fprintln(w) + fmt.Fprintln(w, ` {"jsonrpc":"2.0","method":"admin_getCertificate","params":[""],"id":1}`) + fmt.Fprintln(w) + fmt.Fprintln(w, " The response is [Certificate, CertificateHeader|null].") + fmt.Fprintln(w, ` Extract the "bridge_exits" field from the Certificate object.`) + fmt.Fprintln(w) + + fmt.Fprintln(w, "Build a JSON override file in this format:") + fmt.Fprintln(w, " {") + fmt.Fprintln(w, ` "network_id": ,`) + fmt.Fprintln(w, ` "heights": {`) + for i, mc := range result.MissingCerts { + suffix := "," + if i == n-1 { + suffix = "" + } + fmt.Fprintf(w, " \"%d\": [ ...bridge_exits from admin_getCertificate response... ]%s\n", + mc.Height, suffix) + } + fmt.Fprintln(w, " }") + fmt.Fprintln(w, " }") + fmt.Fprintln(w) + + fmt.Fprintln(w, "Re-run the tool with:") + fmt.Fprintln(w, " backward-forward-let --cfg --cert-exits-file ") +} + +func caseDescription(c RecoveryCase) string { + switch c { + case Case1: + return "Case1 — ForwardLET only: single divergent leaf batch, no extra L2 bridges" + case Case2: + return "Case2 — BackwardLET + ForwardLET: single divergent leaf + extra real L2 bridges" + case Case3: + return "Case3 — ForwardLET only: multiple divergent leaf batches, no extra L2 bridges" + case Case4: + return "Case4 — BackwardLET + ForwardLET: multiple divergent leaves + extra real L2 bridges" + default: + return string(c) + } +} + +func printRecoveryPlanSummary(w io.Writer, result *DiagnosisResult) { + fmt.Fprintln(w, "The following steps will be executed:") + step := 1 + + switch result.Case { + case Case2, Case4: + fmt.Fprintf(w, " %d. BackwardLET: roll back L2 bridge to DivergencePoint DC=%d\n", + step, result.DivergencePoint) + step++ + fmt.Fprintf(w, " %d. ForwardLET #1: inject %d divergent leaf(ves) (agglayer but not L2)\n", + step, len(result.DivergentLeaves)) + step++ + fmt.Fprintf(w, " %d. ForwardLET #2: replay %d real L2 bridge(s) (L2 but not agglayer)\n", + step, len(result.ExtraL2Bridges)) + step++ + case Case1, Case3: + fmt.Fprintf(w, " %d. ForwardLET: inject %d divergent leaf(ves) (agglayer but not L2)\n", + step, len(result.DivergentLeaves)) + step++ + } + + fmt.Fprintf(w, " %d. Verify: confirm L2 LER matches L1 settled LER\n", step) +} diff --git a/tools/backward_forward_let/diagnosis_test.go b/tools/backward_forward_let/diagnosis_test.go new file mode 100644 index 000000000..8bc6fd086 --- /dev/null +++ b/tools/backward_forward_let/diagnosis_test.go @@ -0,0 +1,1024 @@ +package backward_forward_let + +import ( + "bytes" + "context" + "crypto/ecdsa" + "errors" + "fmt" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + aggsendertypes "github.com/agglayer/aggkit/aggsender/types" + bridgeservice "github.com/agglayer/aggkit/bridgeservice/client" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + bridgetypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// stubBridgeService implements bridgeServiceClient for testing. +type stubBridgeService struct { + // bridges maps depositCount → BridgeResponse to return. + bridges map[uint32]*bridgeservicetypes.BridgeResponse + // errAtDC maps depositCount → error to return. + errAtDC map[uint32]error +} + +func (s *stubBridgeService) GetBridgeByDepositCount( + _ context.Context, _ uint32, depositCount uint32, +) (*bridgeservicetypes.BridgeResponse, error) { + if s.errAtDC != nil { + if err, ok := s.errAtDC[depositCount]; ok { + return nil, err + } + } + if br, ok := s.bridges[depositCount]; ok { + return br, nil + } + return nil, bridgeservice.ErrNotFound +} + +// --- stubs for findDivergencePoint unit tests --- + +// stubAggsenderRPC implements aggsenderRPCClient for testing. +type stubAggsenderRPC struct { + // exitsByHeight maps height → exits to return (empty slice = success with no exits). + exitsByHeight map[uint64][]*agglayertypes.BridgeExit + // failHeights are heights where GetCertificateBridgeExits returns an error. + failHeights map[uint64]bool +} + +func (s *stubAggsenderRPC) GetCertificateBridgeExits(height *uint64) ([]*agglayertypes.BridgeExit, error) { + if s.failHeights[*height] { + return nil, fmt.Errorf("stub: no data for height %d", *height) + } + return s.exitsByHeight[*height], nil +} + +func (s *stubAggsenderRPC) GetCertificateHeaderPerHeight(_ *uint64) (*aggsendertypes.Certificate, error) { + return nil, fmt.Errorf("stub: not implemented") +} + +func (s *stubAggsenderRPC) DebugSendCertificate(_ *agglayertypes.Certificate, _ *ecdsa.PrivateKey) (common.Hash, error) { + return common.Hash{}, fmt.Errorf("stub: not implemented") +} + +// TestClassifyCase verifies classifyCase returns the expected RecoveryCase for all 5 cases. +func TestClassifyCase(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + l2CurrentDC uint32 + divergencePoint uint32 // number of matching leading leaves + numDivergent int // number of divergent L1-settled leaves + expectedCase RecoveryCase + }{ + { + name: "Case1: single divergent leaf, no extra L2", + l2CurrentDC: 6, // L2 has DC 0..5 (≤ divergencePoint) + divergencePoint: 6, // 6 matching leaves (DC 0..5) + numDivergent: 1, + expectedCase: Case1, + }, + { + name: "Case2: single divergent leaf + extra L2 bridges", + l2CurrentDC: 8, // L2 has DC 6, 7 (extra real bridges beyond divergencePoint) + divergencePoint: 6, + numDivergent: 1, + expectedCase: Case2, + }, + { + name: "Case3: multiple divergent L1 leaves, no extra L2", + l2CurrentDC: 6, // L2 has DC 0..5 (≤ divergencePoint) + divergencePoint: 6, + numDivergent: 4, // 4 divergent leaves + expectedCase: Case3, + }, + { + name: "Case4: multiple divergent L1 leaves + extra L2 bridges", + l2CurrentDC: 8, // L2 has DC 6, 7 (extra real bridges) + divergencePoint: 6, + numDivergent: 4, + expectedCase: Case4, + }, + { + name: "Case1 edge: exactly 1 divergent leaf, zero matching", + l2CurrentDC: 0, // L2 has no bridges + divergencePoint: 0, // 0 matching leaves + numDivergent: 1, + // hasExtraL2 = 0 > 0 = false; multipleL1 = 1 > 1 = false → Case1 + expectedCase: Case1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := classifyCase(tc.l2CurrentDC, tc.divergencePoint, tc.numDivergent) + require.Equal(t, tc.expectedCase, got) + }) + } +} + +// TestComputeUndercollateralization verifies token amounts are grouped and summed correctly. +func TestComputeUndercollateralization(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + tokenB := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + leaves := []*agglayertypes.BridgeExit{ + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Amount: big.NewInt(100), + }, + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), + Amount: big.NewInt(200), + }, + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: tokenB}, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Amount: big.NewInt(50), + }, + } + + result := computeUndercollateralization(leaves) + + require.Len(t, result, 2) + + // Token A should be first (encountered first). + require.Equal(t, uint32(0), result[0].TokenOriginNetwork) + require.Equal(t, tokenA, result[0].TokenOriginAddress) + require.Equal(t, big.NewInt(300), result[0].Amount) + + // Token B should be second. + require.Equal(t, uint32(1), result[1].TokenOriginNetwork) + require.Equal(t, tokenB, result[1].TokenOriginAddress) + require.Equal(t, big.NewInt(50), result[1].Amount) +} + +// TestComputeUndercollateralization_NilAmount verifies nil amounts are treated as zero. +func TestComputeUndercollateralization_NilAmount(t *testing.T) { + t.Parallel() + + token := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + leaves := []*agglayertypes.BridgeExit{ + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: token}, + Amount: nil, + }, + } + + result := computeUndercollateralization(leaves) + require.Len(t, result, 1) + require.Equal(t, big.NewInt(0), result[0].Amount) +} + +// TestPrintDiagnosis verifies PrintDiagnosis produces expected output for the normal case. +func TestPrintDiagnosis(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + ler := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") + l2ler := common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222") + certID := common.HexToHash("0x3333333333333333333333333333333333333333333333333333333333333333") + + result := &DiagnosisResult{ + Case: Case3, + L1SettledLER: ler, + L1SettledDepositCount: 10, + L1SettledHeight: 5, + L1SettledCertificateID: certID, + L2CurrentLER: l2ler, + L2CurrentDepositCount: 6, + DivergencePoint: 6, + DivergentLeaves: []*agglayertypes.BridgeExit{ + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + Amount: big.NewInt(500), + }, + }, + Undercollateralization: []UndercollateralizedToken{ + { + TokenOriginNetwork: 0, + TokenOriginAddress: tokenA, + Amount: big.NewInt(500), + }, + }, + } + + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + output := buf.String() + + require.Contains(t, output, "Case3") + require.Contains(t, output, ler.Hex()) + require.Contains(t, output, tokenA.Hex()) + require.Contains(t, output, "500") + require.Contains(t, output, "Divergence Point") +} + +// TestPrintDiagnosis_NoDivergence verifies the NoDivergence path. +func TestPrintDiagnosis_NoDivergence(t *testing.T) { + t.Parallel() + + result := &DiagnosisResult{Case: NoDivergence} + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + + require.Contains(t, buf.String(), "NoDivergence") +} + +// TestPrintDiagnosis_AggsenderAPIFailed verifies the actionable missing-cert output +// when all cert IDs are resolved (no UNKNOWN entries). +func TestPrintDiagnosis_AggsenderAPIFailed(t *testing.T) { + t.Parallel() + + certID := common.HexToHash("0xDEAD") + result := &DiagnosisResult{ + Case: Case1, + AggsenderAPIFailed: true, + MissingCerts: []MissingCertInfo{ + {Height: 7, CertID: certID, CertIDResolved: true}, + }, + } + + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + output := buf.String() + + require.Contains(t, output, "Aggsender RPC returned no bridge exit data") + require.Contains(t, output, "Recovery cannot proceed") + require.Contains(t, output, "Missing certificates (1 height):") + require.Contains(t, output, "Height 7") + require.Contains(t, output, certID.Hex()) + require.Contains(t, output, "[ID auto-resolved]") + require.Contains(t, output, "admin_getCertificate") + require.Contains(t, output, `"7":`) + require.Contains(t, output, "--cert-exits-file") + // No UNKNOWN note when all cert IDs are resolved. + require.NotContains(t, output, "UNKNOWN") + require.NotContains(t, output, "certificate_per_network_cf") +} + +// TestPrintDiagnosis_AggsenderAPIFailed_WithUnknownCertID verifies that the extra +// UNKNOWN note is printed when one or more cert IDs could not be resolved. +func TestPrintDiagnosis_AggsenderAPIFailed_WithUnknownCertID(t *testing.T) { + t.Parallel() + + certID := common.HexToHash("0xAAAA") + result := &DiagnosisResult{ + Case: Case1, + AggsenderAPIFailed: true, + MissingCerts: []MissingCertInfo{ + {Height: 5, CertID: certID, CertIDResolved: true}, + {Height: 3, CertIDResolved: false}, + }, + } + + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + output := buf.String() + + require.Contains(t, output, "Missing certificates (2 heights):") + require.Contains(t, output, certID.Hex()) + require.Contains(t, output, "[ID auto-resolved]") + require.Contains(t, output, "UNKNOWN") + require.Contains(t, output, "[contact agglayer admin for cert ID]") + require.Contains(t, output, "certificate_per_network_cf") + // Both heights appear in the JSON template. + require.Contains(t, output, `"5":`) + require.Contains(t, output, `"3":`) +} + +// TestBridgeResponseLeafHash verifies that BridgeResponseLeafHash and BridgeExitLeafHash +// produce identical hashes for equivalent data. +func TestBridgeResponseLeafHash(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + destAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + amount := big.NewInt(12345) + + be := &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: originAddr}, + DestinationNetwork: 2, + DestinationAddress: destAddr, + Amount: amount, + Metadata: nil, + } + + br := &bridgeservicetypes.BridgeResponse{ + LeafType: 0, + OriginNetwork: 1, + OriginAddress: bridgeservicetypes.Address(originAddr.Hex()), + DestinationNetwork: 2, + DestinationAddress: bridgeservicetypes.Address(destAddr.Hex()), + Amount: bridgeservicetypes.BigIntString(fmt.Sprintf("%d", amount.Int64())), + Metadata: "", + } + + hashFromExit := BridgeExitLeafHash(be) + hashFromResponse := BridgeResponseLeafHash(br) + + require.Equal(t, hashFromExit, hashFromResponse, + "BridgeExitLeafHash and BridgeResponseLeafHash must produce identical hashes for equivalent data") +} + +// TestFindDivergencePoint_AllHeightsFail verifies that when aggsender fails for every +// height, all heights are reported in MissingCerts with correct cert ID resolution. +func TestFindDivergencePoint_AllHeightsFail(t *testing.T) { + t.Parallel() + + settledCertID := common.HexToHash("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{0: true, 1: true, 2: true}, + }, + // BridgeService is not reached when aggsender fails. + } + + leaves, divPoint, divFound, missingErr := findDivergencePoint( + context.Background(), env, 2, 3, settledCertID, + ) + + require.NotNil(t, missingErr) + require.Nil(t, leaves) + require.Zero(t, divPoint) + require.False(t, divFound) + + require.Len(t, missingErr.missing, 3) + + // h=2 is the latest settled height → cert ID auto-resolved. + require.Equal(t, uint64(2), missingErr.missing[0].Height) + require.True(t, missingErr.missing[0].CertIDResolved) + require.Equal(t, settledCertID, missingErr.missing[0].CertID) + + // h=1 and h=0 are below the settled height → cert ID not resolvable. + require.Equal(t, uint64(1), missingErr.missing[1].Height) + require.False(t, missingErr.missing[1].CertIDResolved) + require.Equal(t, common.Hash{}, missingErr.missing[1].CertID) + + require.Equal(t, uint64(0), missingErr.missing[2].Height) + require.False(t, missingErr.missing[2].CertIDResolved) + require.Equal(t, common.Hash{}, missingErr.missing[2].CertID) +} + +// TestFindDivergencePoint_OnlySettledHeightFails verifies that when only the latest +// settled height fails, exactly one entry is reported and the lower heights are walked. +func TestFindDivergencePoint_OnlySettledHeightFails(t *testing.T) { + t.Parallel() + + settledCertID := common.HexToHash("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + // Heights 1 and 0 return empty cert (no exits) — skipped without touching BridgeService. + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{2: true}, + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{1: {}, 0: {}}, + }, + } + + _, _, _, missingErr := findDivergencePoint( //nolint:dogsled + context.Background(), env, 2, 0, settledCertID, + ) + + require.NotNil(t, missingErr) + require.Len(t, missingErr.missing, 1) + + // Only h=2 (the settled height) is missing, and its cert ID is resolved. + require.Equal(t, uint64(2), missingErr.missing[0].Height) + require.True(t, missingErr.missing[0].CertIDResolved) + require.Equal(t, settledCertID, missingErr.missing[0].CertID) +} + +// TestFindDivergencePoint_NoFailure verifies that when aggsender succeeds for all +// heights (with empty certs), no missingCertsError is returned. +func TestFindDivergencePoint_NoFailure(t *testing.T) { + t.Parallel() + + // All heights return empty exit lists — no comparison against BridgeService needed. + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {}, + 1: {}, + 2: {}, + }, + }, + } + + leaves, divPoint, divFound, missingErr := findDivergencePoint( + context.Background(), env, 2, 0, common.Hash{}, + ) + + require.Nil(t, missingErr, "no error expected when aggsender succeeds for all heights") + require.Empty(t, leaves) + require.Zero(t, divPoint) + require.False(t, divFound) +} + +// TestFindDivergencePoint_ZeroCertIDSkipped verifies that a zero settledCertID does +// not produce a resolved entry even for the latest settled height. +func TestFindDivergencePoint_ZeroCertIDSkipped(t *testing.T) { + t.Parallel() + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{0: true}, + }, + } + + _, _, _, missingErr := findDivergencePoint( //nolint:dogsled + context.Background(), env, 0, 1, common.Hash{}, // zero settledCertID + ) + + require.NotNil(t, missingErr) + require.Len(t, missingErr.missing, 1) + require.Equal(t, uint64(0), missingErr.missing[0].Height) + require.False(t, missingErr.missing[0].CertIDResolved, + "zero settledCertID must not be reported as resolved") + require.Equal(t, common.Hash{}, missingErr.missing[0].CertID) +} + +// --- getBridgeExitsForHeight unit tests --- + +// makeOverride builds a BridgeExitsOverride with a pre-populated parsed map. +// Used to avoid a temp-file round-trip in unit tests within the same package. +func makeOverride(heights map[uint64][]*agglayertypes.BridgeExit) *BridgeExitsOverride { + return &BridgeExitsOverride{ + NetworkID: 1, + parsed: heights, + } +} + +// TestGetBridgeExitsForHeight_AggsenderSucceeds verifies that when the aggsender +// returns data, the override is never consulted and the aggsender result is returned. +func TestGetBridgeExitsForHeight_AggsenderSucceeds(t *testing.T) { + t.Parallel() + + want := []*agglayertypes.BridgeExit{{DestinationNetwork: 42}} + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{5: want}, + }, + // Override present but must not be reached. + BridgeExitsOverride: makeOverride(map[uint64][]*agglayertypes.BridgeExit{ + 5: {{DestinationNetwork: 99}}, + }), + } + + got, err := getBridgeExitsForHeight(env, 5) + require.NoError(t, err) + require.Equal(t, want, got, "aggsender result must be returned when aggsender succeeds") +} + +// TestGetBridgeExitsForHeight_AggsenderFails_OverrideHit verifies that when the +// aggsender fails and the override has an entry for the height, the override is used. +func TestGetBridgeExitsForHeight_AggsenderFails_OverrideHit(t *testing.T) { + t.Parallel() + + want := []*agglayertypes.BridgeExit{{DestinationNetwork: 7}} + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{3: true}, + }, + BridgeExitsOverride: makeOverride(map[uint64][]*agglayertypes.BridgeExit{3: want}), + } + + got, err := getBridgeExitsForHeight(env, 3) + require.NoError(t, err) + require.Equal(t, want, got, "override result must be returned when aggsender fails") +} + +// TestGetBridgeExitsForHeight_AggsenderFails_OverrideMiss verifies that when the +// aggsender fails and the override is present but has no entry for the height, +// an error is returned. +func TestGetBridgeExitsForHeight_AggsenderFails_OverrideMiss(t *testing.T) { + t.Parallel() + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{8: true}, + }, + // Override exists but has no entry for height 8. + BridgeExitsOverride: makeOverride(map[uint64][]*agglayertypes.BridgeExit{ + 0: {}, + }), + } + + _, err := getBridgeExitsForHeight(env, 8) + require.Error(t, err) + require.Contains(t, err.Error(), "no bridge exit data for height 8") +} + +// TestGetBridgeExitsForHeight_AggsenderFails_NoOverride verifies that when the +// aggsender fails and no override is configured, an error is returned. +func TestGetBridgeExitsForHeight_AggsenderFails_NoOverride(t *testing.T) { + t.Parallel() + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{2: true}, + }, + BridgeExitsOverride: nil, + } + + _, err := getBridgeExitsForHeight(env, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "no bridge exit data for height 2") +} + +// TestIsNotFound verifies that isNotFound correctly identifies the bridgeservice sentinel. +func TestIsNotFound(t *testing.T) { + t.Parallel() + + require.True(t, isNotFound(bridgeservice.ErrNotFound)) + require.True(t, isNotFound(fmt.Errorf("wrapped: %w", bridgeservice.ErrNotFound))) + require.False(t, isNotFound(errors.New("some other error"))) +} + +// TestCaseDescription verifies caseDescription returns the correct string for each case. +func TestCaseDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + c RecoveryCase + want string + }{ + {Case1, "Case1"}, + {Case2, "Case2"}, + {Case3, "Case3"}, + {Case4, "Case4"}, + {NoDivergence, string(NoDivergence)}, // default branch + } + + for _, tc := range tests { + t.Run(string(tc.c), func(t *testing.T) { + t.Parallel() + got := caseDescription(tc.c) + require.Contains(t, got, tc.want) + }) + } +} + +// TestPrintRecoveryPlanSummary_Case1 verifies ForwardLET-only output. +func TestPrintRecoveryPlanSummary_Case1(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + result := &DiagnosisResult{ + Case: Case1, + DivergentLeaves: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + Amount: big.NewInt(100), + }, + }, + } + + var buf bytes.Buffer + printRecoveryPlanSummary(&buf, result) + output := buf.String() + + require.Contains(t, output, "ForwardLET") + require.Contains(t, output, "1 divergent leaf") + require.NotContains(t, output, "BackwardLET") +} + +// TestPrintRecoveryPlanSummary_Case2 verifies BackwardLET+ForwardLET+ForwardLET output. +func TestPrintRecoveryPlanSummary_Case2(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + result := &DiagnosisResult{ + Case: Case2, + DivergencePoint: 3, + DivergentLeaves: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + Amount: big.NewInt(200), + }, + }, + ExtraL2Bridges: []bridgesync.LeafData{ + {LeafType: 0, OriginNetwork: 1, Amount: big.NewInt(50)}, + }, + } + + var buf bytes.Buffer + printRecoveryPlanSummary(&buf, result) + output := buf.String() + + require.Contains(t, output, "BackwardLET") + require.Contains(t, output, "ForwardLET #1") + require.Contains(t, output, "ForwardLET #2") + require.Contains(t, output, "Verify") +} + +// makeBridgeResponse builds a minimal BridgeResponse suitable for leaf hash comparison. +func makeBridgeResponse( + leafType uint8, originNet uint32, originAddr, destAddr string, destNet uint32, amount string, +) *bridgeservicetypes.BridgeResponse { + return &bridgeservicetypes.BridgeResponse{ + LeafType: leafType, + OriginNetwork: originNet, + OriginAddress: bridgeservicetypes.Address(originAddr), + DestinationNetwork: destNet, + DestinationAddress: bridgeservicetypes.Address(destAddr), + Amount: bridgeservicetypes.BigIntString(amount), + } +} + +// makeBridgeExit builds a BridgeExit whose leaf hash matches a given BridgeResponse. +func makeBridgeExitFromResponse(br *bridgeservicetypes.BridgeResponse) *agglayertypes.BridgeExit { + amount := parseAmount(string(br.Amount)) + return &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafType(br.LeafType), + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: br.OriginNetwork, OriginTokenAddress: common.HexToAddress(string(br.OriginAddress))}, + DestinationNetwork: br.DestinationNetwork, + DestinationAddress: common.HexToAddress(string(br.DestinationAddress)), + Amount: amount, + Metadata: decodeMetadata(br.Metadata), + } +} + +// TestCheckCertExitsMatchL2_AllMatch verifies true when all exits match. +func TestCheckCertExitsMatchL2_AllMatch(t *testing.T) { + t.Parallel() + + br0 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "1000") + br1 := makeBridgeResponse(0, 1, "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", 3, "2000") + + exits := []*agglayertypes.BridgeExit{ + makeBridgeExitFromResponse(br0), + makeBridgeExitFromResponse(br1), + } + + env := &Env{ + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 5: br0, + 6: br1, + }, + }, + L2NetworkID: 1, + } + + result := checkCertExitsMatchL2(context.Background(), env, exits, 5) + require.True(t, result) +} + +// TestCheckCertExitsMatchL2_Mismatch verifies false when hashes differ. +func TestCheckCertExitsMatchL2_Mismatch(t *testing.T) { + t.Parallel() + + br0 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "1000") + // Create an exit that does NOT match br0. + differentExit := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 9, OriginTokenAddress: common.HexToAddress("0x9999")}, + DestinationNetwork: 9, + DestinationAddress: common.HexToAddress("0x9999"), + Amount: big.NewInt(9999), + } + + env := &Env{ + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0}, + }, + L2NetworkID: 1, + } + + result := checkCertExitsMatchL2(context.Background(), env, []*agglayertypes.BridgeExit{differentExit}, 0) + require.False(t, result) +} + +// TestCheckCertExitsMatchL2_ServiceError verifies false when bridge service returns error. +func TestCheckCertExitsMatchL2_ServiceError(t *testing.T) { + t.Parallel() + + exit := &agglayertypes.BridgeExit{ + LeafType: 0, + DestinationNetwork: 1, + Amount: big.NewInt(100), + } + + env := &Env{ + BridgeService: &stubBridgeService{ + errAtDC: map[uint32]error{0: errors.New("service down")}, + }, + L2NetworkID: 1, + } + + result := checkCertExitsMatchL2(context.Background(), env, []*agglayertypes.BridgeExit{exit}, 0) + require.False(t, result) +} + +// TestCollectExtraL2Bridges_HappyPath verifies bridges are collected correctly. +func TestCollectExtraL2Bridges_HappyPath(t *testing.T) { + t.Parallel() + + br3 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "500") + br4 := makeBridgeResponse(1, 2, "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", 3, "600") + + env := &Env{ + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 3: br3, + 4: br4, + }, + }, + L2NetworkID: 1, + } + + extra, err := collectExtraL2Bridges(context.Background(), env, 3, 5) + require.NoError(t, err) + require.Len(t, extra, 2) +} + +// TestCollectExtraL2Bridges_NotFound verifies that NotFound entries are skipped. +func TestCollectExtraL2Bridges_NotFound(t *testing.T) { + t.Parallel() + + br3 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "100") + + env := &Env{ + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 3: br3, + // DC 4 is absent → returns ErrNotFound → skipped + }, + }, + L2NetworkID: 1, + } + + extra, err := collectExtraL2Bridges(context.Background(), env, 3, 5) + require.NoError(t, err) + require.Len(t, extra, 1) +} + +// TestCollectExtraL2Bridges_ServiceError verifies a non-NotFound error is propagated. +func TestCollectExtraL2Bridges_ServiceError(t *testing.T) { + t.Parallel() + + env := &Env{ + BridgeService: &stubBridgeService{ + errAtDC: map[uint32]error{2: errors.New("connection refused")}, + }, + L2NetworkID: 1, + } + + _, err := collectExtraL2Bridges(context.Background(), env, 2, 3) + require.Error(t, err) + require.Contains(t, err.Error(), "DC=2") +} + +// TestEnvClose_Nil verifies that Close on a nil *Env returns nil without panic. +func TestEnvClose_Nil(t *testing.T) { + t.Parallel() + + var e *Env + require.NoError(t, e.Close()) +} + +// TestEnvClose_NilL2Client verifies that Close on an Env with no L2Client returns nil. +func TestEnvClose_NilL2Client(t *testing.T) { + t.Parallel() + + e := &Env{} + require.NoError(t, e.Close()) +} + +// TestPrintDiagnosis_EmergencyState verifies the emergency state warning is printed. +func TestPrintDiagnosis_EmergencyState(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + result := &DiagnosisResult{ + Case: Case1, + IsEmergencyState: true, + DivergentLeaves: []*agglayertypes.BridgeExit{ + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x1111"), + Amount: big.NewInt(100), + }, + }, + } + + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + output := buf.String() + + require.Contains(t, output, "emergency state") + require.Contains(t, output, "WARNING") +} + +// TestPrintDiagnosis_WithExtraL2Bridges verifies the ExtraL2Bridges table is printed. +func TestPrintDiagnosis_WithExtraL2Bridges(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + result := &DiagnosisResult{ + Case: Case2, + DivergencePoint: 3, + DivergentLeaves: []*agglayertypes.BridgeExit{ + { + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenA}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x1111"), + Amount: big.NewInt(500), + }, + }, + ExtraL2Bridges: []bridgesync.LeafData{ + { + LeafType: 0, + OriginNetwork: 1, + OriginAddress: tokenA, + DestinationNetwork: 2, + DestinationAddress: common.HexToAddress("0x2222"), + Amount: big.NewInt(200), + }, + }, + Undercollateralization: []UndercollateralizedToken{ + {TokenOriginNetwork: 0, TokenOriginAddress: tokenA, Amount: big.NewInt(500)}, + }, + } + + var buf bytes.Buffer + PrintDiagnosis(&buf, result) + output := buf.String() + + require.Contains(t, output, "Extra Real L2 Bridges") + require.Contains(t, output, "200") + require.Contains(t, output, "Case2") +} + +// TestFindDivergencePoint_NonMatchingExits verifies the path where exits from a cert +// do NOT match the L2 bridge service data. In this case, the exits are prepended to +// divergentLeaves and the walk continues. With no further matching cert, the function +// returns (divergentLeaves, 0, false, nil). +func TestFindDivergencePoint_NonMatchingExits(t *testing.T) { + t.Parallel() + + // Height 0 returns one exit that does NOT match the bridge service response. + mismatchedExit := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 99, OriginTokenAddress: common.HexToAddress("0x9999")}, + DestinationNetwork: 99, + DestinationAddress: common.HexToAddress("0x9999"), + Amount: big.NewInt(9999), + } + br0 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "1000") + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {mismatchedExit}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 0: br0, + }, + }, + L2NetworkID: 1, + } + + // settledHeight=0, 1 total leaf → height 0 has one exit that doesn't match. + leaves, divPoint, divFound, missingErr := findDivergencePoint( + context.Background(), env, 0, 1, common.Hash{}, + ) + + require.Nil(t, missingErr) + require.Len(t, leaves, 1, "mismatched exit should be in divergentLeaves") + require.Equal(t, mismatchedExit, leaves[0]) + require.Equal(t, uint32(0), divPoint) + require.False(t, divFound, "no matching cert found when exits don't match") +} + +// TestFindDivergencePoint_AllCertsMatch verifies the early-return path when all exits +// at a height match the L2 bridge service data and there are no missing heights. +func TestFindDivergencePoint_AllCertsMatch(t *testing.T) { + t.Parallel() + + // Create two matching bridge exit / bridge response pairs for DC 0 and DC 1. + br0 := makeBridgeResponse(0, 1, "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 2, "1000") + br1 := makeBridgeResponse(0, 1, "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", 3, "2000") + exit0 := makeBridgeExitFromResponse(br0) + exit1 := makeBridgeExitFromResponse(br1) + + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + // Height 0 returns 2 exits that match DCs 0 and 1. + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {exit0, exit1}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 0: br0, + 1: br1, + }, + }, + L2NetworkID: 1, + } + + // settledHeight=0, totalSettledLeaves=2 → one cert at height 0 with 2 exits. + leaves, divPoint, divFound, missingErr := findDivergencePoint( + context.Background(), env, 0, 2, common.Hash{}, + ) + + require.Nil(t, missingErr) + require.Empty(t, leaves, "no divergent leaves expected when all certs match") + require.Equal(t, uint32(2), divPoint, "divergence point should be after all settled leaves") + require.True(t, divFound) +} + +// TestComputeUndercollateralization_NilTokenInfo verifies that leaves with nil TokenInfo +// are skipped and do not contribute to the result. +func TestComputeUndercollateralization_NilTokenInfo(t *testing.T) { + t.Parallel() + + token := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + leaves := []*agglayertypes.BridgeExit{ + // This leaf has nil TokenInfo — should be skipped. + { + LeafType: 0, + TokenInfo: nil, + DestinationNetwork: 1, + Amount: big.NewInt(999), + }, + // This leaf has valid TokenInfo. + { + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: token}, + Amount: big.NewInt(42), + }, + } + + result := computeUndercollateralization(leaves) + require.Len(t, result, 1, "only the leaf with non-nil TokenInfo should appear") + require.Equal(t, token, result[0].TokenOriginAddress) + require.Equal(t, big.NewInt(42), result[0].Amount) +} + +// TestFindDivergencePoint_MatchingThenMissingAbove verifies that when a matching cert is found +// but there are missing certs above it, the walk reports missing entries. +func TestFindDivergencePoint_MatchingThenMissingAbove(t *testing.T) { + t.Parallel() + + settledCertID := common.HexToHash("0xCCCC") + + // Height 2 fails, heights 0 and 1 have exits that match (empty). + env := &Env{ + AggsenderRPC: &stubAggsenderRPC{ + failHeights: map[uint64]bool{2: true}, + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {}, + 1: {}, + }, + }, + } + + // settledHeight=2, but height 2 fails → one missing entry. + _, _, _, missingErr := findDivergencePoint( //nolint:dogsled + context.Background(), env, 2, 0, settledCertID, + ) + + require.NotNil(t, missingErr) + require.Len(t, missingErr.missing, 1) + require.Equal(t, uint64(2), missingErr.missing[0].Height) + require.True(t, missingErr.missing[0].CertIDResolved) +} diff --git a/tools/backward_forward_let/helpers.go b/tools/backward_forward_let/helpers.go new file mode 100644 index 000000000..620492fb9 --- /dev/null +++ b/tools/backward_forward_let/helpers.go @@ -0,0 +1,389 @@ +package backward_forward_let + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "strings" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/go_signer/signer" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" +) + +const ( + decimalBase = 10 + merkleZeroHashCount = 33 // 32 tree levels + 1 leaf level +) + +// BridgeExitToLeafData converts an agglayer BridgeExit to a bridgesync.LeafData. +func BridgeExitToLeafData(be *agglayertypes.BridgeExit) bridgesync.LeafData { + amount := be.Amount + if amount == nil { + amount = big.NewInt(0) + } + var originAddr common.Address + var originNetwork uint32 + if be.TokenInfo != nil { + originAddr = be.TokenInfo.OriginTokenAddress + originNetwork = be.TokenInfo.OriginNetwork + } + return bridgesync.LeafData{ + LeafType: be.LeafType.Uint8(), + OriginNetwork: originNetwork, + OriginAddress: originAddr, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: amount, + Metadata: be.Metadata, + } +} + +// BridgeResponseToLeafData converts a bridge service BridgeResponse to a bridgesync.LeafData. +func BridgeResponseToLeafData(br *bridgeservicetypes.BridgeResponse) bridgesync.LeafData { + amount := parseAmount(string(br.Amount)) + return bridgesync.LeafData{ + LeafType: br.LeafType, + OriginNetwork: br.OriginNetwork, + OriginAddress: common.HexToAddress(string(br.OriginAddress)), + DestinationNetwork: br.DestinationNetwork, + DestinationAddress: common.HexToAddress(string(br.DestinationAddress)), + Amount: amount, + Metadata: decodeMetadata(br.Metadata), + } +} + +// BridgeExitLeafHash returns the leaf hash for a BridgeExit using BridgeExit.Hash(). +func BridgeExitLeafHash(be *agglayertypes.BridgeExit) common.Hash { + return be.Hash() +} + +// BridgeResponseLeafHash computes the leaf hash for a BridgeResponse using the same +// algorithm as BridgeExit.Hash(). +// The bridge service stores raw metadata (from the BridgeEvent). The contract's getLeafValue +// takes keccak256(rawMetadata), so we hash it here — matching convertBridgeMetadata in aggsender. +func BridgeResponseLeafHash(br *bridgeservicetypes.BridgeResponse) common.Hash { + amount := parseAmount(string(br.Amount)) + metadata := decodeMetadata(br.Metadata) + + return crypto.Keccak256Hash( + []byte{br.LeafType}, + aggkitcommon.Uint32ToBigEndianBytes(br.OriginNetwork), + common.HexToAddress(string(br.OriginAddress)).Bytes(), + aggkitcommon.Uint32ToBigEndianBytes(br.DestinationNetwork), + common.HexToAddress(string(br.DestinationAddress)).Bytes(), + common.BigToHash(amount).Bytes(), + crypto.Keccak256(metadata), + ) +} + +// parseAmount parses a decimal string (possibly empty) to a *big.Int. Returns 0 on failure. +func parseAmount(s string) *big.Int { + if s == "" { + return big.NewInt(0) + } + n, ok := new(big.Int).SetString(s, decimalBase) + if !ok { + return big.NewInt(0) + } + return n +} + +// decodeMetadata decodes a "0x..."-prefixed hex string to raw bytes. Returns nil for empty/invalid input. +func decodeMetadata(s string) []byte { + s = strings.TrimPrefix(s, "0x") + if s == "" { + return nil + } + b, err := hex.DecodeString(s) + if err != nil { + return nil + } + return b +} + +// makeZeroHashes returns 33 pre-computed zero hashes for a 32-level Merkle tree. +// Index 0 is the empty hash; index h = Keccak256(zeroHashes[h-1], zeroHashes[h-1]) for h >= 1. +func makeZeroHashes() []common.Hash { + zeros := make([]common.Hash, merkleZeroHashCount) + zeros[0] = common.Hash{} + for i := 1; i <= 32; i++ { + zeros[i] = crypto.Keccak256Hash(zeros[i-1].Bytes(), zeros[i-1].Bytes()) + } + return zeros +} + +// computeFrontier simulates an append-only Merkle tree for leaf indices 0..targetIndex-1 +// and returns the resulting frontier (lastLeftCache). The frontier[h] holds the left sibling +// at height h, ready to pair with the next right child. +// +// The returned frontier uses bytes32(0) (literal zero bytes) for positions that have not been +// set by any leaf insertion. This matches the contract's initial storage state and is required +// by _checkValidSubtreeFrontier, which rejects non-zero values in unused positions. +func computeFrontier(leafHashes []common.Hash, targetIndex uint32) ([32]common.Hash, error) { + if uint32(len(leafHashes)) < targetIndex { + return [32]common.Hash{}, fmt.Errorf( + "insufficient leaf hashes: need %d, got %d", targetIndex, len(leafHashes), + ) + } + + zeros := makeZeroHashes() + // frontier is zero-initialized by Go (all common.Hash{} = bytes32(0)), matching the + // contract's initial _branch storage state before any leaves are inserted. + var frontier [32]common.Hash + + for i := uint32(0); i < targetIndex; i++ { + node := leafHashes[i] + for h := range 32 { + if (i>>h)&1 == 0 { + // Left child: cache node at this height, propagate up with zero sibling. + frontier[h] = node + node = crypto.Keccak256Hash(node.Bytes(), zeros[h].Bytes()) + } else { + // Right child: pair with cached left sibling, propagate up. + node = crypto.Keccak256Hash(frontier[h].Bytes(), node.Bytes()) + } + } + } + + // Zero out positions where bit h of targetIndex is 0. These are stale values + // from earlier leaf insertions and must be zero for the contract's + // _checkValidSubtreeFrontier, which rejects non-zero values in inactive positions. + for h := range 32 { + if (targetIndex>>h)&1 == 0 { + frontier[h] = common.Hash{} + } + } + + return frontier, nil +} + +// computeMerkleProof computes a Merkle proof for the leaf at targetIndex in a tree built +// from allLeafHashes. The proof can be verified with tree.CalculateRoot(leaf, proof, targetIndex). +func computeMerkleProof(allLeafHashes []common.Hash, targetIndex uint32) ([32]common.Hash, error) { + if uint32(len(allLeafHashes)) <= targetIndex { + return [32]common.Hash{}, fmt.Errorf( + "targetIndex %d out of range (len=%d)", targetIndex, len(allLeafHashes), + ) + } + + zeros := makeZeroHashes() + var proof [32]common.Hash + + currentLevel := make([]common.Hash, len(allLeafHashes)) + copy(currentLevel, allLeafHashes) + + idx := targetIndex + for h := range 32 { + sibling := idx ^ 1 + if sibling < uint32(len(currentLevel)) { + proof[h] = currentLevel[sibling] + } else { + proof[h] = zeros[h] + } + + // Build next level by pairing consecutive nodes. + nextLen := (len(currentLevel) + 1) / 2 //nolint:mnd // binary tree pairing + nextLevel := make([]common.Hash, nextLen) + for j := range nextLen { + left := currentLevel[2*j] + var right common.Hash + if 2*j+1 < len(currentLevel) { + right = currentLevel[2*j+1] + } else { + right = zeros[h] + } + nextLevel[j] = crypto.Keccak256Hash(left.Bytes(), right.Bytes()) + } + + currentLevel = nextLevel + idx >>= 1 + } + + return proof, nil +} + +// ComputeBackwardLETParams computes the three parameters required for a BackwardLET call: +// - frontier: the append-only tree frontier after inserting leaves 0..targetIndex-1 +// - nextLeaf: the hash of the leaf at targetIndex (the leaf being "rolled back from") +// - proof: a Merkle proof that nextLeaf is at targetIndex in the full tree +func ComputeBackwardLETParams( + allLeafHashes []common.Hash, + targetIndex uint32, +) (frontier [32]common.Hash, nextLeaf common.Hash, proof [32]common.Hash, err error) { + if uint32(len(allLeafHashes)) <= targetIndex { + err = fmt.Errorf("targetIndex %d out of range (len=%d)", targetIndex, len(allLeafHashes)) + return + } + + frontier, err = computeFrontier(allLeafHashes, targetIndex) + if err != nil { + return + } + + nextLeaf = allLeafHashes[targetIndex] + + proof, err = computeMerkleProof(allLeafHashes, targetIndex) + return +} + +// computeRootFromFrontier continues the append-only tree simulation starting from a given +// frontier and existingCount, inserting newLeafHashes. It returns the Merkle root after all +// new leaves have been inserted. +func computeRootFromFrontier( + frontier [32]common.Hash, + existingCount uint32, + newLeafHashes []common.Hash, +) (common.Hash, error) { + if len(newLeafHashes) == 0 { + return common.Hash{}, fmt.Errorf("newLeafHashes must not be empty") + } + + zeros := makeZeroHashes() + + // Work on a local copy of the frontier so callers are not affected. + f := frontier + var root common.Hash + + for j, leafHash := range newLeafHashes { + actualIndex := existingCount + uint32(j) + node := leafHash + for h := range 32 { + if (actualIndex>>h)&1 == 0 { + f[h] = node + node = crypto.Keccak256Hash(node.Bytes(), zeros[h].Bytes()) + } else { + node = crypto.Keccak256Hash(f[h].Bytes(), node.Bytes()) + } + } + root = node + } + + return root, nil +} + +// ComputeLERForNewLeaves computes the LET Merkle root after appending newLeafHashes +// to an existing tree described by existingLeafHashes. +func ComputeLERForNewLeaves(existingLeafHashes []common.Hash, newLeafHashes []common.Hash) (common.Hash, error) { + n := uint32(len(existingLeafHashes)) + frontier, err := computeFrontier(existingLeafHashes, n) + if err != nil { + return common.Hash{}, err + } + return computeRootFromFrontier(frontier, n, newLeafHashes) +} + +// leafDataLeafHash computes the Merkle leaf hash for a bridgesync.LeafData using the same +// algorithm as BridgeExit.Hash(). +// LeafData.Metadata contains raw bytes (from the bridge event). The contract hashes it with +// keccak256 before computing the leaf hash, so we do the same here. +func leafDataLeafHash(ld bridgesync.LeafData) common.Hash { + amount := ld.Amount + if amount == nil { + amount = big.NewInt(0) + } + return crypto.Keccak256Hash( + []byte{ld.LeafType}, + aggkitcommon.Uint32ToBigEndianBytes(ld.OriginNetwork), + ld.OriginAddress.Bytes(), + aggkitcommon.Uint32ToBigEndianBytes(ld.DestinationNetwork), + ld.DestinationAddress.Bytes(), + common.BigToHash(amount).Bytes(), + crypto.Keccak256(ld.Metadata), + ) +} + +// fetchL2LeafHashesUpTo fetches L2 bridge leaf hashes for deposit counts 0..endDC-1 from the +// bridge service and returns them in order. +func fetchL2LeafHashesUpTo(ctx context.Context, env *Env, endDC uint32) ([]common.Hash, error) { + hashes := make([]common.Hash, 0, endDC) + for dc := uint32(0); dc < endDC; dc++ { + br, err := env.BridgeService.GetBridgeByDepositCount(ctx, env.L2NetworkID, dc) + if err != nil { + return nil, fmt.Errorf("get L2 bridge at DC=%d: %w", dc, err) + } + hashes = append(hashes, BridgeResponseLeafHash(br)) + } + return hashes, nil +} + +// buildTransactOpts creates a bind.TransactOpts for the given signer config and L2 chain ID. +func buildTransactOpts( + ctx context.Context, + cfg signertypes.SignerConfig, + l2ChainID *big.Int, + name string, +) (*bind.TransactOpts, error) { + s, err := signer.NewSigner(ctx, l2ChainID.Uint64(), cfg, name, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("load %s signer: %w", name, err) + } + if err := s.Initialize(ctx); err != nil { + return nil, fmt.Errorf("initialize %s signer: %w", name, err) + } + opts := &bind.TransactOpts{ + From: s.PublicAddress(), + Signer: func(_ common.Address, tx *gethTypes.Transaction) (*gethTypes.Transaction, error) { + return s.SignTx(ctx, tx) + }, + } + return opts, nil +} + +// waitForReceipt waits for the transaction to be mined and returns its receipt. +func waitForReceipt( + ctx context.Context, client *ethclient.Client, tx *gethTypes.Transaction, +) (*gethTypes.Receipt, error) { + return bind.WaitMined(ctx, client, tx) +} + +// bridgeExitToContractLeaf converts an agglayer BridgeExit to the contract leaf type. +func bridgeExitToContractLeaf(be *agglayertypes.BridgeExit) agglayerbridgel2.AgglayerBridgeL2LeafData { + var originNetwork uint32 + var originAddr common.Address + if be.TokenInfo != nil { + originNetwork = be.TokenInfo.OriginNetwork + originAddr = be.TokenInfo.OriginTokenAddress + } + amount := be.Amount + if amount == nil { + amount = big.NewInt(0) + } + return agglayerbridgel2.AgglayerBridgeL2LeafData{ + LeafType: be.LeafType.Uint8(), + OriginNetwork: originNetwork, + OriginAddress: originAddr, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: amount, + Metadata: be.Metadata, + } +} + +// leafDataToContractLeaf converts a bridgesync.LeafData to the contract leaf type. +func leafDataToContractLeaf(ld bridgesync.LeafData) agglayerbridgel2.AgglayerBridgeL2LeafData { + amount := ld.Amount + if amount == nil { + amount = big.NewInt(0) + } + return agglayerbridgel2.AgglayerBridgeL2LeafData{ + LeafType: ld.LeafType, + OriginNetwork: ld.OriginNetwork, + OriginAddress: ld.OriginAddress, + DestinationNetwork: ld.DestinationNetwork, + DestinationAddress: ld.DestinationAddress, + Amount: amount, + Metadata: ld.Metadata, + } +} diff --git a/tools/backward_forward_let/helpers_test.go b/tools/backward_forward_let/helpers_test.go new file mode 100644 index 000000000..5967d77d0 --- /dev/null +++ b/tools/backward_forward_let/helpers_test.go @@ -0,0 +1,546 @@ +package backward_forward_let + +import ( + "context" + "errors" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/tree" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +const ( + testOriginAddr = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + testDestAddr = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +// TestMakeZeroHashes verifies the zero hash generation. +func TestMakeZeroHashes(t *testing.T) { + t.Parallel() + + zeros := makeZeroHashes() + require.Len(t, zeros, 33) + + // Index 0 must be the empty hash. + require.Equal(t, common.Hash{}, zeros[0]) + + // Each subsequent entry is keccak256(prev, prev). + for i := 1; i <= 32; i++ { + expected := crypto.Keccak256Hash(zeros[i-1].Bytes(), zeros[i-1].Bytes()) + require.Equal(t, expected, zeros[i], "mismatch at index %d", i) + } +} + +// TestComputeFrontier verifies the frontier after inserting 2 known leaves. +// For targetIndex=2 (binary 10), only position 1 should be set (bit 1 is 1); +// position 0 is inactive (bit 0 of 2 = 0) and must be zero for the contract. +func TestComputeFrontier(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + l1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002") + + frontier, err := computeFrontier([]common.Hash{l0, l1}, 2) + require.NoError(t, err) + + // For targetIndex=2 (binary 10): bit 0 is 0 → frontier[0] zeroed; bit 1 is 1 → frontier[1] active. + require.Equal(t, common.Hash{}, frontier[0], "frontier[0] must be zero (inactive position for count=2)") + require.Equal(t, crypto.Keccak256Hash(l0.Bytes(), l1.Bytes()), frontier[1]) +} + +// TestComputeFrontier_Empty verifies that frontier for 0 leaves is all bytes32(0). +// This matches the contract's initial _branch storage state (before any leaf insertions), +// as required by _checkValidSubtreeFrontier which rejects non-zero unused positions. +func TestComputeFrontier_Empty(t *testing.T) { + t.Parallel() + + frontier, err := computeFrontier([]common.Hash{}, 0) + require.NoError(t, err) + + for h := range 32 { + require.Equal(t, common.Hash{}, frontier[h], "frontier[%d] should be bytes32(0)", h) + } +} + +// TestComputeFrontier_ErrorInsufficientLeaves verifies the error when targetIndex > len(hashes). +func TestComputeFrontier_ErrorInsufficientLeaves(t *testing.T) { + t.Parallel() + + _, err := computeFrontier([]common.Hash{{}}, 5) + require.Error(t, err) + require.Contains(t, err.Error(), "insufficient leaf hashes") +} + +// TestComputeMerkleProof verifies that the proof satisfies tree.CalculateRoot. +func TestComputeMerkleProof(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + l1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002") + l2 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000003") + leaves := []common.Hash{l0, l1, l2} + + // Compute the expected root using computeRootFromFrontier from scratch. + expectedRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, leaves) + require.NoError(t, err) + + for idx := uint32(0); idx < uint32(len(leaves)); idx++ { + proof, err := computeMerkleProof(leaves, idx) + require.NoError(t, err) + + // tree.CalculateRoot expects [32]common.Hash (which is types.Proof). + got := tree.CalculateRoot(leaves[idx], proof, idx) + require.Equal(t, expectedRoot, got, "proof for index %d failed to reproduce root", idx) + } +} + +// TestComputeMerkleProof_TwoLeaves verifies proof for a 2-leaf tree. +func TestComputeMerkleProof_TwoLeaves(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0xAAAA000000000000000000000000000000000000000000000000000000000000") + l1 := common.HexToHash("0xBBBB000000000000000000000000000000000000000000000000000000000000") + leaves := []common.Hash{l0, l1} + + expectedRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, leaves) + require.NoError(t, err) + + for idx := range uint32(2) { + proof, err := computeMerkleProof(leaves, idx) + require.NoError(t, err) + got := tree.CalculateRoot(leaves[idx], proof, idx) + require.Equal(t, expectedRoot, got, "proof for index %d failed", idx) + } +} + +// TestComputeMerkleProof_ErrorOutOfRange verifies the error when targetIndex >= len(leaves). +func TestComputeMerkleProof_ErrorOutOfRange(t *testing.T) { + t.Parallel() + + _, err := computeMerkleProof([]common.Hash{{}}, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "out of range") +} + +// TestComputeBackwardLETParams verifies the end-to-end parameter computation. +// It checks that CalculateRoot(nextLeaf, proof, targetIndex) == root of the full tree. +func TestComputeBackwardLETParams(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000011") + l1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000022") + l2 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000033") + l3 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000044") + allLeaves := []common.Hash{l0, l1, l2, l3} + + // Test rolling back to DivergencePoint=2 (leaves 0 and 1 remain, leaf 2 is nextLeaf). + targetIndex := uint32(2) + frontier, nextLeaf, proof, err := ComputeBackwardLETParams(allLeaves, targetIndex) + require.NoError(t, err) + + // nextLeaf must be the leaf at targetIndex. + require.Equal(t, l2, nextLeaf) + + // CalculateRoot(nextLeaf, proof, targetIndex) must equal the root of allLeaves. + expectedRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, allLeaves) + require.NoError(t, err) + + got := tree.CalculateRoot(nextLeaf, proof, targetIndex) + require.Equal(t, expectedRoot, got, "proof does not reproduce the tree root") + + // Frontier should match computeFrontier for the same targetIndex. + expectedFrontier, err := computeFrontier(allLeaves, targetIndex) + require.NoError(t, err) + require.Equal(t, expectedFrontier, frontier) +} + +// TestComputeRootFromFrontier_Incremental verifies that building the tree in two steps +// (frontier then continuation) yields the same root as a single-pass computation. +func TestComputeRootFromFrontier_Incremental(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + l1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002") + l2 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000003") + allLeaves := []common.Hash{l0, l1, l2} + + // Single-pass root. + singlePassRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, allLeaves) + require.NoError(t, err) + + // Two-step: compute frontier for first 2 leaves, then continue with the 3rd. + frontier, err := computeFrontier(allLeaves, 2) + require.NoError(t, err) + + twoStepRoot, err := computeRootFromFrontier(frontier, 2, []common.Hash{l2}) + require.NoError(t, err) + + require.Equal(t, singlePassRoot, twoStepRoot, + "incremental root must equal single-pass root") +} + +// TestComputeRootFromFrontier_ErrorEmpty verifies the error for an empty newLeafHashes slice. +func TestComputeRootFromFrontier_ErrorEmpty(t *testing.T) { + t.Parallel() + + _, err := computeRootFromFrontier([32]common.Hash{}, 0, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "must not be empty") +} + +// TestBridgeExitToLeafData verifies conversion from BridgeExit to LeafData. +// +//nolint:dupl +func TestBridgeExitToLeafData(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress(testOriginAddr) + destAddr := common.HexToAddress(testDestAddr) + amount := big.NewInt(12345) + + be := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: originAddr}, + DestinationNetwork: 2, + DestinationAddress: destAddr, + Amount: amount, + Metadata: []byte{0xde, 0xad}, + } + + ld := BridgeExitToLeafData(be) + + require.Equal(t, be.LeafType.Uint8(), ld.LeafType) + require.Equal(t, uint32(1), ld.OriginNetwork) + require.Equal(t, originAddr, ld.OriginAddress) + require.Equal(t, uint32(2), ld.DestinationNetwork) + require.Equal(t, destAddr, ld.DestinationAddress) + require.Equal(t, amount, ld.Amount) + require.Equal(t, []byte{0xde, 0xad}, ld.Metadata) +} + +// TestBridgeExitToLeafData_NilTokenInfo verifies nil TokenInfo results in zero origin fields. +func TestBridgeExitToLeafData_NilTokenInfo(t *testing.T) { + t.Parallel() + + be := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: nil, + DestinationNetwork: 3, + DestinationAddress: common.HexToAddress("0x1234"), + Amount: nil, + } + + ld := BridgeExitToLeafData(be) + + require.Equal(t, common.Address{}, ld.OriginAddress) + require.Equal(t, uint32(0), ld.OriginNetwork) + require.Equal(t, big.NewInt(0), ld.Amount) +} + +// TestBridgeResponseToLeafData verifies conversion from BridgeResponse to LeafData. +func TestBridgeResponseToLeafData(t *testing.T) { + t.Parallel() + + originAddr := testOriginAddr + destAddr := testDestAddr + + br := &bridgeservicetypes.BridgeResponse{ + LeafType: 1, + OriginNetwork: 2, + OriginAddress: bridgeservicetypes.Address(originAddr), + DestinationNetwork: 3, + DestinationAddress: bridgeservicetypes.Address(destAddr), + Amount: bridgeservicetypes.BigIntString("999"), + Metadata: "0xdeadbeef", + } + + ld := BridgeResponseToLeafData(br) + + require.Equal(t, uint8(1), ld.LeafType) + require.Equal(t, uint32(2), ld.OriginNetwork) + require.Equal(t, common.HexToAddress(originAddr), ld.OriginAddress) + require.Equal(t, uint32(3), ld.DestinationNetwork) + require.Equal(t, common.HexToAddress(destAddr), ld.DestinationAddress) + require.Equal(t, big.NewInt(999), ld.Amount) + require.Equal(t, []byte{0xde, 0xad, 0xbe, 0xef}, ld.Metadata) +} + +// TestParseAmount verifies all parseAmount paths. +func TestParseAmount(t *testing.T) { + t.Parallel() + + require.Equal(t, big.NewInt(0), parseAmount("")) + require.Equal(t, big.NewInt(42), parseAmount("42")) + require.Equal(t, big.NewInt(0), parseAmount("not-a-number")) +} + +// TestDecodeMetadata verifies all decodeMetadata paths. +func TestDecodeMetadata(t *testing.T) { + t.Parallel() + + require.Nil(t, decodeMetadata("")) + require.Nil(t, decodeMetadata("0x")) + require.Equal(t, []byte{0xde, 0xad}, decodeMetadata("0xdead")) + require.Equal(t, []byte{0xde, 0xad}, decodeMetadata("dead")) + require.Nil(t, decodeMetadata("0xzz")) // invalid hex + require.Nil(t, decodeMetadata("0xzzInvalid")) // invalid hex no 0x prefix handling +} + +// TestComputeLERForNewLeaves verifies LER computation after appending new leaves. +func TestComputeLERForNewLeaves(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + l1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002") + l2 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000003") + + // Build the expected root for all 3 leaves in one pass. + expectedRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, []common.Hash{l0, l1, l2}) + require.NoError(t, err) + + // ComputeLERForNewLeaves should append l2 to existing [l0, l1]. + root, err := ComputeLERForNewLeaves([]common.Hash{l0, l1}, []common.Hash{l2}) + require.NoError(t, err) + require.Equal(t, expectedRoot, root) +} + +// TestComputeLERForNewLeaves_EmptyExisting verifies the case of no existing leaves. +func TestComputeLERForNewLeaves_EmptyExisting(t *testing.T) { + t.Parallel() + + l0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + expectedRoot, err := computeRootFromFrontier([32]common.Hash{}, 0, []common.Hash{l0}) + require.NoError(t, err) + + root, err := ComputeLERForNewLeaves(nil, []common.Hash{l0}) + require.NoError(t, err) + require.Equal(t, expectedRoot, root) +} + +// TestLeafDataLeafHash verifies the leaf hash computation for LeafData. +func TestLeafDataLeafHash(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress(testOriginAddr) + destAddr := common.HexToAddress(testDestAddr) + amount := big.NewInt(5000) + + be := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: originAddr}, + DestinationNetwork: 2, + DestinationAddress: destAddr, + Amount: amount, + Metadata: nil, + } + + ld := BridgeExitToLeafData(be) + + // leafDataLeafHash must match BridgeExitLeafHash for the same data. + require.Equal(t, BridgeExitLeafHash(be), leafDataLeafHash(ld)) +} + +// TestLeafDataLeafHash_NilAmount verifies nil amount is treated as zero. +func TestLeafDataLeafHash_NilAmount(t *testing.T) { + t.Parallel() + + ld := bridgesync.LeafData{ + LeafType: 0, + OriginNetwork: 1, + OriginAddress: common.HexToAddress("0x1111"), + DestinationNetwork: 2, + DestinationAddress: common.HexToAddress("0x2222"), + Amount: nil, + Metadata: nil, + } + // Should not panic; nil amount treated as 0. + h := leafDataLeafHash(ld) + require.NotEqual(t, common.Hash{}, h) +} + +// TestComputeBackwardLETParams_OutOfRange verifies the error when targetIndex >= len(allLeaves). +func TestComputeBackwardLETParams_OutOfRange(t *testing.T) { + t.Parallel() + + leaves := []common.Hash{ + common.HexToHash("0x01"), + common.HexToHash("0x02"), + } + _, _, _, err := ComputeBackwardLETParams(leaves, 2) //nolint:dogsled // index 2, len=2 → out of range + require.Error(t, err) + require.Contains(t, err.Error(), "out of range") +} + +// TestBridgeExitToContractLeaf verifies the conversion from BridgeExit to contract leaf type. +// +//nolint:dupl +func TestBridgeExitToContractLeaf(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress(testOriginAddr) + destAddr := common.HexToAddress(testDestAddr) + amount := big.NewInt(777) + + be := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 5, OriginTokenAddress: originAddr}, + DestinationNetwork: 6, + DestinationAddress: destAddr, + Amount: amount, + Metadata: []byte{0x01, 0x02}, + } + + leaf := bridgeExitToContractLeaf(be) + + require.Equal(t, be.LeafType.Uint8(), leaf.LeafType) + require.Equal(t, uint32(5), leaf.OriginNetwork) + require.Equal(t, originAddr, leaf.OriginAddress) + require.Equal(t, uint32(6), leaf.DestinationNetwork) + require.Equal(t, destAddr, leaf.DestinationAddress) + require.Equal(t, amount, leaf.Amount) + require.Equal(t, []byte{0x01, 0x02}, leaf.Metadata) +} + +// TestBridgeExitToContractLeaf_NilTokenInfo verifies nil TokenInfo results in zero origin fields. +func TestBridgeExitToContractLeaf_NilTokenInfo(t *testing.T) { + t.Parallel() + + be := &agglayertypes.BridgeExit{ + LeafType: 0, + TokenInfo: nil, + DestinationNetwork: 3, + DestinationAddress: common.HexToAddress("0x1234"), + Amount: nil, + } + + leaf := bridgeExitToContractLeaf(be) + + require.Equal(t, common.Address{}, leaf.OriginAddress) + require.Equal(t, uint32(0), leaf.OriginNetwork) + require.Equal(t, big.NewInt(0), leaf.Amount) +} + +// TestLeafDataToContractLeaf verifies the conversion from LeafData to contract leaf type. +func TestLeafDataToContractLeaf(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + amount := big.NewInt(333) + + ld := bridgesync.LeafData{ + LeafType: 1, + OriginNetwork: 7, + OriginAddress: originAddr, + DestinationNetwork: 8, + DestinationAddress: destAddr, + Amount: amount, + Metadata: []byte{0xAB, 0xCD}, + } + + leaf := leafDataToContractLeaf(ld) + + require.Equal(t, uint8(1), leaf.LeafType) + require.Equal(t, uint32(7), leaf.OriginNetwork) + require.Equal(t, originAddr, leaf.OriginAddress) + require.Equal(t, uint32(8), leaf.DestinationNetwork) + require.Equal(t, destAddr, leaf.DestinationAddress) + require.Equal(t, amount, leaf.Amount) + require.Equal(t, []byte{0xAB, 0xCD}, leaf.Metadata) +} + +// TestLeafDataToContractLeaf_NilAmount verifies nil Amount is coerced to 0. +func TestLeafDataToContractLeaf_NilAmount(t *testing.T) { + t.Parallel() + + ld := bridgesync.LeafData{ + LeafType: 0, + OriginNetwork: 0, + DestinationNetwork: 1, + Amount: nil, + } + + leaf := leafDataToContractLeaf(ld) + require.Equal(t, big.NewInt(0), leaf.Amount) +} + +// TestComputeLERForNewLeaves_FrontierError verifies the frontier error path. +// computeFrontier returns an error when existingLeafHashes has fewer entries +// than targetIndex (i.e. existingCount). We craft that by passing mismatched slices. +// Actually computeLERForNewLeaves uses len(existingLeafHashes) as targetIndex, +// so to trigger a frontier error we need computeFrontier to fail, which requires +// len(existing) < targetIndex. Since targetIndex = len(existing), the only way to +// fail is if the frontier itself is inconsistent - but that's impossible here. +// Instead, test the empty-new-leaves error path from computeRootFromFrontier. +func TestComputeLERForNewLeaves_EmptyNewLeaves(t *testing.T) { + t.Parallel() + + // ComputeLERForNewLeaves passes newLeafHashes directly to computeRootFromFrontier, + // which returns an error if it's empty. + _, err := ComputeLERForNewLeaves(nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "must not be empty") +} + +// TestFetchL2LeafHashesUpTo_HappyPath verifies fetching leaf hashes from the bridge service. +func TestFetchL2LeafHashesUpTo_HappyPath(t *testing.T) { + t.Parallel() + + originAddr := testOriginAddr + destAddr := testDestAddr + + br0 := &bridgeservicetypes.BridgeResponse{ + LeafType: 0, + OriginNetwork: 1, + OriginAddress: bridgeservicetypes.Address(originAddr), + DestinationNetwork: 2, + DestinationAddress: bridgeservicetypes.Address(destAddr), + Amount: bridgeservicetypes.BigIntString("1000"), + } + br1 := &bridgeservicetypes.BridgeResponse{ + LeafType: 0, + OriginNetwork: 1, + OriginAddress: bridgeservicetypes.Address(originAddr), + DestinationNetwork: 3, + DestinationAddress: bridgeservicetypes.Address(destAddr), + Amount: bridgeservicetypes.BigIntString("2000"), + } + + env := &Env{ + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 0: br0, + 1: br1, + }, + }, + L2NetworkID: 1, + } + + hashes, err := fetchL2LeafHashesUpTo(context.Background(), env, 2) + require.NoError(t, err) + require.Len(t, hashes, 2) + require.Equal(t, BridgeResponseLeafHash(br0), hashes[0]) + require.Equal(t, BridgeResponseLeafHash(br1), hashes[1]) +} + +// TestFetchL2LeafHashesUpTo_Error verifies an error from the bridge service is propagated. +func TestFetchL2LeafHashesUpTo_Error(t *testing.T) { + t.Parallel() + + env := &Env{ + BridgeService: &stubBridgeService{ + errAtDC: map[uint32]error{0: errors.New("service unavailable")}, + }, + L2NetworkID: 1, + } + + _, err := fetchL2LeafHashesUpTo(context.Background(), env, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "DC=0") +} diff --git a/tools/backward_forward_let/override.go b/tools/backward_forward_let/override.go new file mode 100644 index 000000000..393c3d750 --- /dev/null +++ b/tools/backward_forward_let/override.go @@ -0,0 +1,103 @@ +package backward_forward_let + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" +) + +// BridgeExitsOverride holds pre-extracted certificate bridge exits keyed by height. +// Load via LoadBridgeExitsOverride. Use GetExits to retrieve exits for a specific height. +// +// NOTE: the JSON field names follow the Go agglayertypes.BridgeExit json tags +// (e.g., "dest_network", "dest_address"). The agglayer Rust serde may use different +// names (e.g., "destination_network"); if so, build the file by marshaling the +// Certificate.BridgeExits value obtained via json.Unmarshal from the admin API response, +// not from the raw Rust JSON text. +type BridgeExitsOverride struct { + NetworkID uint32 + Description string + parsed map[uint64][]*agglayertypes.BridgeExit +} + +// GetExits returns the bridge exits for the given certificate height. +// The second return value is false when the height has no entry in the override. +func (o *BridgeExitsOverride) GetExits(height uint64) ([]*agglayertypes.BridgeExit, bool) { + exits, ok := o.parsed[height] + return exits, ok +} + +// overrideFileJSON is the JSON wire format used when reading a BridgeExitsOverride file. +// Heights are string-keyed because JSON does not support integer object keys. +type overrideFileJSON struct { + NetworkID uint32 `json:"network_id"` + Description string `json:"description"` + Heights map[string][]*agglayertypes.BridgeExit `json:"heights"` +} + +// LoadBridgeExitsOverride reads and validates a JSON override file containing +// pre-extracted certificate bridge exits keyed by certificate height. +// +// Expected file format (heights are string-keyed; amount is a decimal string): +// +// { +// "network_id": 1, +// "description": "optional description", +// "heights": { +// "0": [ +// { +// "leaf_type": 0, +// "token_info": { "origin_network": 0, "origin_token_address": "0x..." }, +// "dest_network": 0, +// "dest_address": "0x...", +// "amount": "0", +// "metadata": null +// } +// ], +// "1": [] +// } +// } +// +// Returns an error when: +// - the file cannot be read +// - the JSON is malformed +// - network_id is zero +// - the heights map is absent +// - any height key is not a non-negative integer +func LoadBridgeExitsOverride(filePath string) (*BridgeExitsOverride, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read override file %s: %w", filePath, err) + } + + var raw overrideFileJSON + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse override file %s: %w", filePath, err) + } + + if raw.NetworkID == 0 { + return nil, fmt.Errorf("override file %s: network_id must be non-zero", filePath) + } + + if raw.Heights == nil { + return nil, fmt.Errorf("override file %s: heights map is missing", filePath) + } + + parsed := make(map[uint64][]*agglayertypes.BridgeExit, len(raw.Heights)) + for key, exits := range raw.Heights { + h, parseErr := strconv.ParseUint(key, 10, 64) + if parseErr != nil { + return nil, fmt.Errorf("override file %s: non-numeric height key %q: %w", filePath, key, parseErr) + } + parsed[h] = exits + } + + return &BridgeExitsOverride{ + NetworkID: raw.NetworkID, + Description: raw.Description, + parsed: parsed, + }, nil +} diff --git a/tools/backward_forward_let/override_test.go b/tools/backward_forward_let/override_test.go new file mode 100644 index 000000000..f95c2de77 --- /dev/null +++ b/tools/backward_forward_let/override_test.go @@ -0,0 +1,240 @@ +package backward_forward_let + +import ( + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// writeOverrideFile writes content to a temp file and returns the path. +func writeOverrideFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "override.json") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// TestLoadBridgeExitsOverride_HappyPath verifies that a valid override file is +// loaded correctly, heights are mapped to uint64, and GetExits returns the right data. +func TestLoadBridgeExitsOverride_HappyPath(t *testing.T) { + t.Parallel() + + originAddr := "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + destAddr := "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + + // Use a raw JSON fixture to explicitly verify the on-disk field names + // ("dest_network", "dest_address") and amount as a decimal string. + path := writeOverrideFile(t, `{ + "network_id": 1, + "description": "test fixture", + "heights": { + "3": [ + { + "leaf_type": 0, + "token_info": { + "origin_network": 1, + "origin_token_address": "`+originAddr+`" + }, + "dest_network": 2, + "dest_address": "`+destAddr+`", + "amount": "100", + "metadata": null + } + ], + "7": [] + } + }`) + + result, err := LoadBridgeExitsOverride(path) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, uint32(1), result.NetworkID) + require.Equal(t, "test fixture", result.Description) + + // Height 3: one bridge exit with all fields populated. + exits3, ok := result.GetExits(3) + require.True(t, ok) + require.Len(t, exits3, 1) + + be := exits3[0] + require.NotNil(t, be.TokenInfo) + require.Equal(t, uint32(1), be.TokenInfo.OriginNetwork) + require.Equal(t, common.HexToAddress(originAddr), be.TokenInfo.OriginTokenAddress) + require.Equal(t, uint32(2), be.DestinationNetwork) + require.Equal(t, common.HexToAddress(destAddr), be.DestinationAddress) + require.Equal(t, big.NewInt(100), be.Amount) + require.Nil(t, be.Metadata) + + // Height 7: empty list present. + exits7, ok := result.GetExits(7) + require.True(t, ok, "height 7 must be present") + require.Empty(t, exits7) + + // Unknown height must return false. + _, ok = result.GetExits(99) + require.False(t, ok, "absent height must return false") +} + +// TestLoadBridgeExitsOverride_HeightZero verifies that the height key "0" maps to uint64(0). +func TestLoadBridgeExitsOverride_HeightZero(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{ + "network_id": 2, + "heights": { + "0": [] + } + }`) + + result, err := LoadBridgeExitsOverride(path) + require.NoError(t, err) + + exits, ok := result.GetExits(0) + require.True(t, ok) + require.Empty(t, exits) + + _, ok = result.GetExits(1) + require.False(t, ok) +} + +// TestLoadBridgeExitsOverride_EmptyExitsList verifies that an empty exits list at a +// height returns ([], true) from GetExits (present but empty, not absent). +func TestLoadBridgeExitsOverride_EmptyExitsList(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{ + "network_id": 1, + "heights": { + "5": [] + } + }`) + + result, err := LoadBridgeExitsOverride(path) + require.NoError(t, err) + + exits, ok := result.GetExits(5) + require.True(t, ok, "height 5 must be present") + require.Empty(t, exits, "empty list must be returned as empty, not absent") + + _, ok = result.GetExits(999) + require.False(t, ok, "absent height must return false") +} + +// TestLoadBridgeExitsOverride_RoundTrip verifies that marshaling a BridgeExit slice via +// the internal wire type and loading it back produces identical values. +func TestLoadBridgeExitsOverride_RoundTrip(t *testing.T) { + t.Parallel() + + tokenAddr := common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + + original := []*agglayertypes.BridgeExit{ + { + DestinationNetwork: 3, + DestinationAddress: destAddr, + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: tokenAddr}, + Amount: big.NewInt(999), + Metadata: nil, + }, + } + + raw := overrideFileJSON{ + NetworkID: 5, + Description: "round-trip test", + Heights: map[string][]*agglayertypes.BridgeExit{"2": original}, + } + data, err := json.Marshal(raw) + require.NoError(t, err) + + tmpPath := filepath.Join(t.TempDir(), "override.json") + require.NoError(t, os.WriteFile(tmpPath, data, 0o600)) + + result, err := LoadBridgeExitsOverride(tmpPath) + require.NoError(t, err) + require.Equal(t, uint32(5), result.NetworkID) + require.Equal(t, "round-trip test", result.Description) + + exits, ok := result.GetExits(2) + require.True(t, ok) + require.Len(t, exits, 1) + require.Equal(t, uint32(3), exits[0].DestinationNetwork) + require.Equal(t, destAddr, exits[0].DestinationAddress) + require.Equal(t, big.NewInt(999), exits[0].Amount) + require.Nil(t, exits[0].Metadata) +} + +// TestLoadBridgeExitsOverride_NonNumericKey verifies that a non-numeric height key +// causes an error. +func TestLoadBridgeExitsOverride_NonNumericKey(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{ + "network_id": 1, + "heights": { + "abc": [] + } + }`) + + _, err := LoadBridgeExitsOverride(path) + require.Error(t, err) + require.Contains(t, err.Error(), "non-numeric height key") + require.Contains(t, err.Error(), `"abc"`) +} + +// TestLoadBridgeExitsOverride_MissingNetworkID verifies that a zero (or absent) +// network_id is rejected. +func TestLoadBridgeExitsOverride_MissingNetworkID(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{ + "heights": { + "0": [] + } + }`) + + _, err := LoadBridgeExitsOverride(path) + require.Error(t, err) + require.Contains(t, err.Error(), "network_id must be non-zero") +} + +// TestLoadBridgeExitsOverride_MalformedJSON verifies that malformed JSON is rejected. +func TestLoadBridgeExitsOverride_MalformedJSON(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{not valid json}`) + + _, err := LoadBridgeExitsOverride(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse override file") +} + +// TestLoadBridgeExitsOverride_FileNotFound verifies that a non-existent file path +// produces an error containing "read override file". +func TestLoadBridgeExitsOverride_FileNotFound(t *testing.T) { + t.Parallel() + + _, err := LoadBridgeExitsOverride("/nonexistent/path/override.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read override file") +} + +// TestLoadBridgeExitsOverride_MissingHeightsMap verifies that an absent "heights" key +// (nil map after unmarshal) is rejected. +func TestLoadBridgeExitsOverride_MissingHeightsMap(t *testing.T) { + t.Parallel() + + path := writeOverrideFile(t, `{ + "network_id": 1, + "description": "no heights key" + }`) + + _, err := LoadBridgeExitsOverride(path) + require.Error(t, err) + require.Contains(t, err.Error(), "heights map is missing") +} diff --git a/tools/backward_forward_let/recovery.go b/tools/backward_forward_let/recovery.go new file mode 100644 index 000000000..c47f39a43 --- /dev/null +++ b/tools/backward_forward_let/recovery.go @@ -0,0 +1,377 @@ +package backward_forward_let + +import ( + "context" + "fmt" + "math/big" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// ExecuteRecovery performs the on-chain recovery steps for the given diagnosis. +// It activates emergency state (if not already active), runs BackwardLET and/or ForwardLET +// as required by the case, and deactivates emergency state when done. +func ExecuteRecovery(ctx context.Context, env *Env, diagnosis *DiagnosisResult) (retErr error) { + l2ChainID, err := env.chainIDFn(ctx) + if err != nil { + return fmt.Errorf("get L2 chain ID: %w", err) + } + + adminAuth, err := env.buildAuthFn( + ctx, env.Config.BackwardForwardLET.GERRemoverKey, l2ChainID, "ger-remover", + ) + if err != nil { + return fmt.Errorf("build admin transact opts: %w", err) + } + + pauserAuth, err := env.buildAuthFn( + ctx, env.Config.BackwardForwardLET.EmergencyPauserKey, l2ChainID, "emergency-pauser", + ) + if err != nil { + return fmt.Errorf("build pauser transact opts: %w", err) + } + + unpauserAuth, err := env.buildAuthFn( + ctx, env.Config.BackwardForwardLET.EmergencyUnpauserKey, l2ChainID, "emergency-unpauser", + ) + if err != nil { + return fmt.Errorf("build unpauser transact opts: %w", err) + } + + callOpts := &bind.CallOpts{Context: ctx} + + if !diagnosis.IsEmergencyState { + if err := stepActivateEmergency(ctx, env, pauserAuth, callOpts); err != nil { + return fmt.Errorf("activate emergency state: %w", err) + } + } else { + fmt.Println("[step] Emergency state already active, skipping activation.") + } + + defer func() { + if deactivateErr := stepDeactivateEmergency(ctx, env, unpauserAuth, callOpts); deactivateErr != nil { + if retErr != nil { + retErr = fmt.Errorf("%w; also failed to deactivate emergency state: %w", retErr, deactivateErr) + } else { + retErr = fmt.Errorf("failed to deactivate emergency state (bridge is still paused): %w", deactivateErr) + } + } + }() + + if diagnosis.Case == Case2 || diagnosis.Case == Case4 { + if err := stepBackwardLET(ctx, env, adminAuth, callOpts, diagnosis); err != nil { + return fmt.Errorf("backward LET: %w", err) + } + } + + // First ForwardLET: insert divergent leaves (bridges included on agglayer but not L2). + if err := stepForwardLETDivergentLeaves(ctx, env, adminAuth, callOpts, diagnosis); err != nil { + return fmt.Errorf("forward LET divergent leaves: %w", err) + } + + // Second ForwardLET: insert extra L2 bridges (bridges on L2 but not agglayer). + if len(diagnosis.ExtraL2Bridges) > 0 { + if err := stepForwardLETExtraL2Bridges(ctx, env, adminAuth, callOpts, diagnosis); err != nil { + return fmt.Errorf("forward LET extra L2 bridges: %w", err) + } + } + + return nil +} + +// stepActivateEmergency sends an ActivateEmergencyState transaction and verifies the result. +func stepActivateEmergency( + ctx context.Context, + env *Env, + auth *bind.TransactOpts, + callOpts *bind.CallOpts, +) error { + fmt.Println("[step] Activating emergency state...") + + tx, err := env.L2Bridge.ActivateEmergencyState(auth) + if err != nil { + return fmt.Errorf("send ActivateEmergencyState tx: %w", err) + } + + receipt, err := env.waitReceiptFn(ctx, tx) + if err != nil { + return fmt.Errorf("wait for ActivateEmergencyState receipt: %w", err) + } + if receipt.Status != 1 { + return fmt.Errorf("ActivateEmergencyState tx failed (status=%d)", receipt.Status) + } + + active, err := env.L2Bridge.IsEmergencyState(callOpts) + if err != nil { + return fmt.Errorf("verify emergency state after activation: %w", err) + } + if !active { + return fmt.Errorf("emergency state not active after ActivateEmergencyState") + } + + fmt.Println("[step] Emergency state activated.") + return nil +} + +// stepDeactivateEmergency sends a DeactivateEmergencyState transaction and verifies the result. +func stepDeactivateEmergency( + ctx context.Context, + env *Env, + auth *bind.TransactOpts, + callOpts *bind.CallOpts, +) error { + fmt.Println("[step] Deactivating emergency state...") + + tx, err := env.L2Bridge.DeactivateEmergencyState(auth) + if err != nil { + return fmt.Errorf("send DeactivateEmergencyState tx: %w", err) + } + + receipt, err := env.waitReceiptFn(ctx, tx) + if err != nil { + return fmt.Errorf("wait for DeactivateEmergencyState receipt: %w", err) + } + if receipt.Status != 1 { + return fmt.Errorf("DeactivateEmergencyState tx failed (status=%d)", receipt.Status) + } + + active, err := env.L2Bridge.IsEmergencyState(callOpts) + if err != nil { + return fmt.Errorf("verify emergency state after deactivation: %w", err) + } + if active { + return fmt.Errorf("emergency state still active after DeactivateEmergencyState") + } + + fmt.Println("[step] Emergency state deactivated.") + return nil +} + +// stepBackwardLET rolls the L2 bridge back to diagnosis.DivergencePoint. +func stepBackwardLET( + ctx context.Context, + env *Env, + auth *bind.TransactOpts, + callOpts *bind.CallOpts, + diagnosis *DiagnosisResult, +) error { + fmt.Printf("[step] BackwardLET: rolling back to DC=%d...\n", diagnosis.DivergencePoint) + + allLeafHashes, err := fetchL2LeafHashesUpTo(ctx, env, diagnosis.L2CurrentDepositCount) + if err != nil { + return fmt.Errorf("fetch L2 leaf hashes: %w", err) + } + + frontier, nextLeaf, proof, err := ComputeBackwardLETParams(allLeafHashes, diagnosis.DivergencePoint) + if err != nil { + return fmt.Errorf("compute BackwardLET params: %w", err) + } + + var frontierBytes [32][32]byte + for i, h := range frontier { + frontierBytes[i] = [32]byte(h) + } + var proofBytes [32][32]byte + for i, h := range proof { + proofBytes[i] = [32]byte(h) + } + + tx, err := env.L2Bridge.BackwardLET( + auth, + new(big.Int).SetUint64(uint64(diagnosis.DivergencePoint)), + frontierBytes, + [32]byte(nextLeaf), + proofBytes, + ) + if err != nil { + return fmt.Errorf("send BackwardLET tx: %w", err) + } + + receipt, err := env.waitReceiptFn(ctx, tx) + if err != nil { + return fmt.Errorf("wait for BackwardLET receipt: %w", err) + } + if receipt.Status != 1 { + return fmt.Errorf("BackwardLET tx failed (status=%d)", receipt.Status) + } + + dcBig, err := env.L2Bridge.DepositCount(callOpts) + if err != nil { + return fmt.Errorf("get deposit count after BackwardLET: %w", err) + } + if uint32(dcBig.Uint64()) != diagnosis.DivergencePoint { + return fmt.Errorf("deposit count mismatch after BackwardLET: expected %d, got %d", + diagnosis.DivergencePoint, dcBig.Uint64()) + } + + fmt.Printf("[step] BackwardLET complete. DC=%d\n", diagnosis.DivergencePoint) + return nil +} + +// stepForwardLETDivergentLeaves inserts divergent L1 leaves into the L2 bridge. +// These are bridges included on agglayer but not on L2. +func stepForwardLETDivergentLeaves( + ctx context.Context, + env *Env, + auth *bind.TransactOpts, + callOpts *bind.CallOpts, + diagnosis *DiagnosisResult, +) error { + fmt.Printf("[step] ForwardLET (divergent leaves): inserting %d leaf(ves)...\n", len(diagnosis.DivergentLeaves)) + + newLeaves := make([]agglayerbridgel2.AgglayerBridgeL2LeafData, 0, len(diagnosis.DivergentLeaves)) + for _, be := range diagnosis.DivergentLeaves { + newLeaves = append(newLeaves, bridgeExitToContractLeaf(be)) + } + + // Compute the frontier at DivergencePoint from the L2 bridge service data. + var leafHashesUpToDivergence []common.Hash + var err error + if diagnosis.DivergencePoint > 0 { + leafHashesUpToDivergence, err = fetchL2LeafHashesUpTo(ctx, env, diagnosis.DivergencePoint) + if err != nil { + return fmt.Errorf("fetch L2 leaf hashes up to divergence point: %w", err) + } + } + + frontier, err := computeFrontier(leafHashesUpToDivergence, diagnosis.DivergencePoint) + if err != nil { + return fmt.Errorf("compute frontier at divergence point: %w", err) + } + + divergentLeafHashes := make([]common.Hash, 0, len(diagnosis.DivergentLeaves)) + for _, be := range diagnosis.DivergentLeaves { + divergentLeafHashes = append(divergentLeafHashes, BridgeExitLeafHash(be)) + } + + expectedLER, err := computeRootFromFrontier(frontier, diagnosis.DivergencePoint, divergentLeafHashes) + if err != nil { + return fmt.Errorf("compute expected LER for divergent leaves: %w", err) + } + + tx, err := env.L2Bridge.ForwardLET(auth, newLeaves, [32]byte(expectedLER)) + if err != nil { + return fmt.Errorf("send ForwardLET (divergent leaves) tx: %w", err) + } + + receipt, err := env.waitReceiptFn(ctx, tx) + if err != nil { + return fmt.Errorf("wait for ForwardLET (divergent leaves) receipt: %w", err) + } + if receipt.Status != 1 { + return fmt.Errorf("ForwardLET (divergent leaves) tx failed (status=%d)", receipt.Status) + } + + expectedDC := diagnosis.DivergencePoint + uint32(len(diagnosis.DivergentLeaves)) + + dcBig, err := env.L2Bridge.DepositCount(callOpts) + if err != nil { + return fmt.Errorf("get deposit count after ForwardLET (divergent leaves): %w", err) + } + if uint32(dcBig.Uint64()) != expectedDC { + return fmt.Errorf("deposit count mismatch after ForwardLET (divergent leaves): expected %d, got %d", + expectedDC, dcBig.Uint64()) + } + + root32, err := env.L2Bridge.GetRoot(callOpts) + if err != nil { + return fmt.Errorf("get root after ForwardLET (divergent leaves): %w", err) + } + if common.Hash(root32) != expectedLER { + return fmt.Errorf("LER mismatch after ForwardLET (divergent leaves): expected %s, got %s", + expectedLER.Hex(), common.Hash(root32).Hex()) + } + + fmt.Printf("[step] ForwardLET (divergent leaves) complete. DC=%d, LER=%s\n", expectedDC, expectedLER.Hex()) + return nil +} + +// stepForwardLETExtraL2Bridges inserts extra real L2 bridges into the L2 bridge. +// These are bridges on L2 but not yet on agglayer, appended after the divergent leaves. +// The bridge service doesn't know about divergent leaves (inserted via ForwardLET), so +// the frontier at DivergencePoint+len(DivergentLeaves) is built from L2 service data +// plus the divergent leaf hashes. +func stepForwardLETExtraL2Bridges( + ctx context.Context, + env *Env, + auth *bind.TransactOpts, + callOpts *bind.CallOpts, + diagnosis *DiagnosisResult, +) error { + fmt.Printf("[step] ForwardLET (extra L2 bridges): inserting %d leaf(ves)...\n", len(diagnosis.ExtraL2Bridges)) + + newLeaves := make([]agglayerbridgel2.AgglayerBridgeL2LeafData, 0, len(diagnosis.ExtraL2Bridges)) + for _, ld := range diagnosis.ExtraL2Bridges { + newLeaves = append(newLeaves, leafDataToContractLeaf(ld)) + } + + // Compute the frontier at DivergencePoint + len(DivergentLeaves). + // The bridge service only holds real L2 bridges; divergent leaves were injected via + // ForwardLET and are not visible there, so we build the full leaf hash sequence manually. + afterDivergentCount := diagnosis.DivergencePoint + uint32(len(diagnosis.DivergentLeaves)) + + allHashesBeforeExtra := make([]common.Hash, 0, int(afterDivergentCount)) + if diagnosis.DivergencePoint > 0 { + l2Hashes, err := fetchL2LeafHashesUpTo(ctx, env, diagnosis.DivergencePoint) + if err != nil { + return fmt.Errorf("fetch L2 leaf hashes up to divergence point: %w", err) + } + allHashesBeforeExtra = append(allHashesBeforeExtra, l2Hashes...) + } + for _, be := range diagnosis.DivergentLeaves { + allHashesBeforeExtra = append(allHashesBeforeExtra, BridgeExitLeafHash(be)) + } + + frontier, err := computeFrontier(allHashesBeforeExtra, afterDivergentCount) + if err != nil { + return fmt.Errorf("compute frontier after divergent leaves: %w", err) + } + + extraLeafHashes := make([]common.Hash, 0, len(diagnosis.ExtraL2Bridges)) + for _, ld := range diagnosis.ExtraL2Bridges { + extraLeafHashes = append(extraLeafHashes, leafDataLeafHash(ld)) + } + + expectedLER, err := computeRootFromFrontier(frontier, afterDivergentCount, extraLeafHashes) + if err != nil { + return fmt.Errorf("compute expected LER for extra L2 bridges: %w", err) + } + + tx, err := env.L2Bridge.ForwardLET(auth, newLeaves, [32]byte(expectedLER)) + if err != nil { + return fmt.Errorf("send ForwardLET (extra L2 bridges) tx: %w", err) + } + + receipt, err := env.waitReceiptFn(ctx, tx) + if err != nil { + return fmt.Errorf("wait for ForwardLET (extra L2 bridges) receipt: %w", err) + } + if receipt.Status != 1 { + return fmt.Errorf("ForwardLET (extra L2 bridges) tx failed (status=%d)", receipt.Status) + } + + expectedDC := afterDivergentCount + uint32(len(diagnosis.ExtraL2Bridges)) + + dcBig, err := env.L2Bridge.DepositCount(callOpts) + if err != nil { + return fmt.Errorf("get deposit count after ForwardLET (extra L2 bridges): %w", err) + } + if uint32(dcBig.Uint64()) != expectedDC { + return fmt.Errorf("deposit count mismatch after ForwardLET (extra L2 bridges): expected %d, got %d", + expectedDC, dcBig.Uint64()) + } + + root32, err := env.L2Bridge.GetRoot(callOpts) + if err != nil { + return fmt.Errorf("get root after ForwardLET (extra L2 bridges): %w", err) + } + if common.Hash(root32) != expectedLER { + return fmt.Errorf("LER mismatch after ForwardLET (extra L2 bridges): expected %s, got %s", + expectedLER.Hex(), common.Hash(root32).Hex()) + } + + fmt.Printf("[step] ForwardLET (extra L2 bridges) complete. DC=%d, LER=%s\n", expectedDC, expectedLER.Hex()) + return nil +} diff --git a/tools/backward_forward_let/recovery_test.go b/tools/backward_forward_let/recovery_test.go new file mode 100644 index 000000000..9d1a53e58 --- /dev/null +++ b/tools/backward_forward_let/recovery_test.go @@ -0,0 +1,1157 @@ +package backward_forward_let + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + agglayermocks "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgesync" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// stubL2Bridge is a minimal stub implementing l2BridgeContract for testing. +type stubL2Bridge struct { + depositCount *big.Int + depositCountErr error + root [32]byte + rootErr error + emergency bool + emergencyErr error + activateErr error + deactivateErr error + backwardLETErr error + forwardLETErr error + // tx is returned for all transact methods. + tx *gethTypes.Transaction +} + +func (s *stubL2Bridge) DepositCount(_ *bind.CallOpts) (*big.Int, error) { + if s.depositCountErr != nil { + return nil, s.depositCountErr + } + if s.depositCount == nil { + return big.NewInt(0), nil + } + return s.depositCount, nil +} + +func (s *stubL2Bridge) GetRoot(_ *bind.CallOpts) ([32]byte, error) { + return s.root, s.rootErr +} + +func (s *stubL2Bridge) IsEmergencyState(_ *bind.CallOpts) (bool, error) { + return s.emergency, s.emergencyErr +} + +func (s *stubL2Bridge) ActivateEmergencyState(_ *bind.TransactOpts) (*gethTypes.Transaction, error) { + return s.tx, s.activateErr +} + +func (s *stubL2Bridge) DeactivateEmergencyState(_ *bind.TransactOpts) (*gethTypes.Transaction, error) { + return s.tx, s.deactivateErr +} + +func (s *stubL2Bridge) BackwardLET(_ *bind.TransactOpts, _ *big.Int, _ [32][32]byte, _ [32]byte, _ [32][32]byte) (*gethTypes.Transaction, error) { + return s.tx, s.backwardLETErr +} + +func (s *stubL2Bridge) ForwardLET(_ *bind.TransactOpts, _ []agglayerbridgel2.AgglayerBridgeL2LeafData, _ [32]byte) (*gethTypes.Transaction, error) { + return s.tx, s.forwardLETErr +} + +// successReceipt returns a receipt with Status=1 (success). +func successReceipt() *gethTypes.Receipt { return &gethTypes.Receipt{Status: 1} } + +// failedReceipt returns a receipt with Status=0 (failed tx). +func failedReceipt() *gethTypes.Receipt { return &gethTypes.Receipt{Status: 0} } + +// noopAuth returns a TransactOpts with a no-op signer function. +func noopAuth() *bind.TransactOpts { + return &bind.TransactOpts{ + From: common.HexToAddress("0xdeadbeef"), + Signer: func(_ common.Address, tx *gethTypes.Transaction) (*gethTypes.Transaction, error) { + return tx, nil + }, + } +} + +// buildTestEnv builds a minimal Env with stub L2Bridge, injectable auth builder, +// injectable receipt waiter, and an injectable chainID getter. +func buildTestEnv(t *testing.T, + bridge l2BridgeContract, + chainID *big.Int, + chainIDErr error, + authErr error, + receipt *gethTypes.Receipt, + receiptErr error, +) *Env { + t.Helper() + + env := &Env{ + L2Bridge: bridge, + Config: &Config{}, + chainIDFn: func(_ context.Context) (*big.Int, error) { + return chainID, chainIDErr + }, + buildAuthFn: func(_ context.Context, _ signertypes.SignerConfig, _ *big.Int, _ string) (*bind.TransactOpts, error) { + if authErr != nil { + return nil, authErr + } + return noopAuth(), nil + }, + waitReceiptFn: func(_ context.Context, _ *gethTypes.Transaction) (*gethTypes.Receipt, error) { + return receipt, receiptErr + }, + } + return env +} + +// newAgglayerMockWithSettledHeight creates an agglayer mock that returns a valid NetworkInfo. +func newAgglayerMockWithSettledHeight( + t *testing.T, + networkID uint32, + settledDC uint32, + settledLER [32]byte, +) *agglayermocks.AgglayerClientMock { + t.Helper() + settledHeight := uint64(0) + settledLeafCount := uint64(settledDC) + ler := common.Hash(settledLER) + certID := common.Hash{} + m := agglayermocks.NewAgglayerClientMock(t) + m.EXPECT().GetNetworkInfo(mock.Anything, networkID).Return(agglayertypes.NetworkInfo{ + SettledHeight: &settledHeight, + SettledLETLeafCount: &settledLeafCount, + SettledLER: &ler, + SettledCertificateID: &certID, + }, nil) + return m +} + +// --- Diagnose Step 2 error tests --- + +// TestDiagnose_DepositCountError verifies Diagnose returns an error when DepositCount fails. +func TestDiagnose_DepositCountError(t *testing.T) { + t.Parallel() + + agglayerMock := newAgglayerMockWithSettledHeight(t, 3, 1, [32]byte{0x01}) + bridge := &stubL2Bridge{depositCountErr: errors.New("rpc error")} + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 3, + } + + _, err := Diagnose(context.Background(), env) + require.Error(t, err) + require.Contains(t, err.Error(), "get L2 deposit count") +} + +// TestDiagnose_GetRootError verifies Diagnose returns an error when GetRoot fails. +func TestDiagnose_GetRootError(t *testing.T) { + t.Parallel() + + agglayerMock := newAgglayerMockWithSettledHeight(t, 4, 2, [32]byte{0x02}) + bridge := &stubL2Bridge{rootErr: errors.New("root unavailable")} + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 4, + } + + _, err := Diagnose(context.Background(), env) + require.Error(t, err) + require.Contains(t, err.Error(), "get L2 bridge root") +} + +// TestDiagnose_IsEmergencyStateError verifies Diagnose returns an error when IsEmergencyState fails. +func TestDiagnose_IsEmergencyStateError(t *testing.T) { + t.Parallel() + + agglayerMock := newAgglayerMockWithSettledHeight(t, 5, 3, [32]byte{0x03}) + bridge := &stubL2Bridge{emergencyErr: errors.New("bridge paused query failed")} + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 5, + } + + _, err := Diagnose(context.Background(), env) + require.Error(t, err) + require.Contains(t, err.Error(), "check L2 emergency state") +} + +// TestDiagnose_NoDivergence_LERAndDCMatch verifies that when L2 LER and DC match L1, +// Diagnose returns NoDivergence. +func TestDiagnose_NoDivergence_LERAndDCMatch(t *testing.T) { + t.Parallel() + + settledLER := [32]byte{0xAB} + settledDC := uint32(7) + + agglayerMock := newAgglayerMockWithSettledHeight(t, 6, settledDC, settledLER) + + // L2 bridge returns matching values. + bridge := &stubL2Bridge{ + depositCount: big.NewInt(int64(settledDC)), + root: settledLER, + emergency: false, + } + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 6, + } + + result, err := Diagnose(context.Background(), env) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, NoDivergence, result.Case) +} + +// TestDiagnose_Case1_SingleDivergentLeaf verifies that when there's one divergent leaf +// and no extra L2 bridges, Diagnose returns Case1 and populates DivergentLeaves. +func TestDiagnose_Case1_SingleDivergentLeaf(t *testing.T) { + t.Parallel() + + // L1 settled: height=0, DC=1 (one leaf settled). + settledLER := [32]byte{0xBB} + agglayerMock := newAgglayerMockWithSettledHeight(t, 7, 1, settledLER) + + // L2 has DC=0 (no leaves); L1 settled has 1 divergent leaf. + // hasExtraL2 = l2CurrentDC(0) > divergencePoint(0) = false → Case1. + bridge := &stubL2Bridge{ + depositCount: big.NewInt(0), + root: [32]byte{0xCC}, + emergency: false, + } + + // AggsenderRPC: height 0 returns one mismatched exit. + mismatchedExit := &agglayertypes.BridgeExit{ + DestinationNetwork: 99, + Amount: big.NewInt(9999), + } + + // BridgeService at DC=0: returns a different bridge (so they won't match). + diffBR := &bridgeservicetypes.BridgeResponse{ + LeafType: 0, + OriginNetwork: 1, + OriginAddress: bridgeservicetypes.Address("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + DestinationNetwork: 2, + DestinationAddress: bridgeservicetypes.Address("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Amount: bridgeservicetypes.BigIntString("1000"), + } + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 7, + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {mismatchedExit}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: diffBR}, + }, + } + + result, err := Diagnose(context.Background(), env) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, Case1, result.Case) + require.Len(t, result.DivergentLeaves, 1) + require.Equal(t, mismatchedExit, result.DivergentLeaves[0]) + require.Equal(t, uint32(0), result.DivergencePoint) + require.False(t, result.IsEmergencyState) +} + +// TestDiagnose_Case2_WithExtraL2Bridges verifies Case2 when L2 has extra bridges. +func TestDiagnose_Case2_WithExtraL2Bridges(t *testing.T) { + t.Parallel() + + settledLER := [32]byte{0xDD} + agglayerMock := newAgglayerMockWithSettledHeight(t, 8, 1, settledLER) + + bridge := &stubL2Bridge{ + depositCount: big.NewInt(2), // one extra beyond the divergent L1 leaf + root: [32]byte{0xEE}, + emergency: false, + } + + mismatchedExit := &agglayertypes.BridgeExit{ + DestinationNetwork: 77, + Amount: big.NewInt(7777), + } + + diffBR := &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 0, + DestinationNetwork: 1, + Amount: bridgeservicetypes.BigIntString("1"), + } + extraBR := &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 0, + DestinationNetwork: 2, + Amount: bridgeservicetypes.BigIntString("999"), + } + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 8, + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {mismatchedExit}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 0: diffBR, + 1: extraBR, + }, + }, + } + + result, err := Diagnose(context.Background(), env) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, Case2, result.Case) + require.Len(t, result.DivergentLeaves, 1) + // collectExtraL2Bridges(startDC=0, endDC=2) fetches DC 0 and DC 1 → 2 extra bridges. + require.Len(t, result.ExtraL2Bridges, 2) + require.NotNil(t, result.Undercollateralization) +} + +// TestDiagnose_EmergencyStateTrue verifies that IsEmergencyState=true is captured. +func TestDiagnose_EmergencyStateTrue(t *testing.T) { + t.Parallel() + + settledLER := [32]byte{0xFE} + agglayerMock := newAgglayerMockWithSettledHeight(t, 9, 1, settledLER) + + mismatchedExit := &agglayertypes.BridgeExit{DestinationNetwork: 5} + diffBR := &bridgeservicetypes.BridgeResponse{DestinationNetwork: 6} + + bridge := &stubL2Bridge{ + depositCount: big.NewInt(1), + root: [32]byte{0xFF}, + emergency: true, + } + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 9, + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {mismatchedExit}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: diffBR}, + }, + } + + result, err := Diagnose(context.Background(), env) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.IsEmergencyState) +} + +// TestDiagnose_CollectExtraL2Bridges_Error verifies that an error from collectExtraL2Bridges +// causes Diagnose to return an error. +func TestDiagnose_CollectExtraL2Bridges_Error(t *testing.T) { + t.Parallel() + + settledLER := [32]byte{0xAA} + agglayerMock := newAgglayerMockWithSettledHeight(t, 10, 1, settledLER) + + bridge := &stubL2Bridge{ + depositCount: big.NewInt(2), + root: [32]byte{0xBB}, + } + + mismatchedExit := &agglayertypes.BridgeExit{DestinationNetwork: 3} + diffBR := &bridgeservicetypes.BridgeResponse{DestinationNetwork: 4} + + env := &Env{ + AgglayerClient: agglayerMock, + L2Bridge: bridge, + L2NetworkID: 10, + AggsenderRPC: &stubAggsenderRPC{ + exitsByHeight: map[uint64][]*agglayertypes.BridgeExit{ + 0: {mismatchedExit}, + }, + }, + BridgeService: &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{ + 0: diffBR, + }, + errAtDC: map[uint32]error{1: errors.New("DB failure")}, + }, + } + + _, err := Diagnose(context.Background(), env) + require.Error(t, err) + require.Contains(t, err.Error(), "collect extra L2 bridges") +} + +// --- ExecuteRecovery unit tests --- + +// TestExecuteRecovery_ChainIDError verifies that an error from chainIDFn is propagated. +func TestExecuteRecovery_ChainIDError(t *testing.T) { + t.Parallel() + + env := buildTestEnv(t, &stubL2Bridge{}, nil, errors.New("chain ID unavailable"), nil, nil, nil) + diagnosis := &DiagnosisResult{Case: Case1, DivergentLeaves: []*agglayertypes.BridgeExit{{}}} + + err := ExecuteRecovery(context.Background(), env, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "get L2 chain ID") +} + +// TestExecuteRecovery_BuildAuthError verifies that an error from buildAuthFn is propagated. +func TestExecuteRecovery_BuildAuthError(t *testing.T) { + t.Parallel() + + env := buildTestEnv(t, &stubL2Bridge{}, big.NewInt(1), nil, errors.New("key not found"), nil, nil) + diagnosis := &DiagnosisResult{Case: Case1, DivergentLeaves: []*agglayertypes.BridgeExit{{}}} + + err := ExecuteRecovery(context.Background(), env, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "build admin transact opts") +} + +// TestExecuteRecovery_ActivateEmergencyError verifies activate emergency state error propagation. +func TestExecuteRecovery_ActivateEmergencyError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + activateErr: errors.New("tx reverted"), + } + + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + diagnosis := &DiagnosisResult{ + Case: Case1, + IsEmergencyState: false, + DivergentLeaves: []*agglayertypes.BridgeExit{{}}, + } + + err := ExecuteRecovery(context.Background(), env, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "activate emergency state") +} + +// TestExecuteRecovery_Case2_BackwardLETError verifies that a BackwardLET error is propagated. +func TestExecuteRecovery_Case2_BackwardLETError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergency: true, + backwardLETErr: errors.New("backward LET failed"), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{}, + } + + diagnosis := &DiagnosisResult{ + Case: Case2, + IsEmergencyState: true, + DivergencePoint: 0, + L2CurrentDepositCount: 1, + DivergentLeaves: []*agglayertypes.BridgeExit{{}}, + } + + err := ExecuteRecovery(context.Background(), env, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "backward LET") +} + +// --- stepActivateEmergency unit tests --- + +// TestStepActivateEmergency_ActivateTxError verifies the tx send error path. +func TestStepActivateEmergency_ActivateTxError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{activateErr: errors.New("nonce too low")} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "send ActivateEmergencyState tx") +} + +// TestStepActivateEmergency_ReceiptError verifies receipt wait error path. +func TestStepActivateEmergency_ReceiptError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, nil, errors.New("timeout")) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "wait for ActivateEmergencyState receipt") +} + +// TestStepActivateEmergency_TxStatusFailed verifies the status=0 path. +func TestStepActivateEmergency_TxStatusFailed(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, failedReceipt(), nil) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "ActivateEmergencyState tx failed") +} + +// TestStepActivateEmergency_IsEmergencyCheckFails verifies the IsEmergencyState error after activation. +func TestStepActivateEmergency_IsEmergencyCheckFails(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergencyErr: errors.New("contract call failed"), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "verify emergency state after activation") +} + +// TestStepActivateEmergency_NotActiveAfterTx verifies the "not active" path. +func TestStepActivateEmergency_NotActiveAfterTx(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergency: false, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "emergency state not active after ActivateEmergencyState") +} + +// TestStepActivateEmergency_Success verifies the happy path. +func TestStepActivateEmergency_Success(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergency: true, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + auth := noopAuth() + + err := stepActivateEmergency(context.Background(), env, auth, &bind.CallOpts{}) + require.NoError(t, err) +} + +// --- stepDeactivateEmergency unit tests --- + +// TestStepDeactivateEmergency_TxError verifies the tx send error. +func TestStepDeactivateEmergency_TxError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{deactivateErr: errors.New("gas too low")} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "send DeactivateEmergencyState tx") +} + +// TestStepDeactivateEmergency_ReceiptError verifies the receipt wait error. +func TestStepDeactivateEmergency_ReceiptError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, nil, errors.New("ctx cancelled")) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "wait for DeactivateEmergencyState receipt") +} + +// TestStepDeactivateEmergency_TxFailed verifies the status=0 path. +func TestStepDeactivateEmergency_TxFailed(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, failedReceipt(), nil) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "DeactivateEmergencyState tx failed") +} + +// TestStepDeactivateEmergency_IsEmergencyCheckFails verifies IsEmergencyState error after deactivate. +func TestStepDeactivateEmergency_IsEmergencyCheckFails(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergencyErr: errors.New("rpc down"), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "verify emergency state after deactivation") +} + +// TestStepDeactivateEmergency_StillActiveAfterTx verifies the "still active" path. +func TestStepDeactivateEmergency_StillActiveAfterTx(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergency: true, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.Error(t, err) + require.Contains(t, err.Error(), "emergency state still active after DeactivateEmergencyState") +} + +// TestStepDeactivateEmergency_Success verifies the happy path. +func TestStepDeactivateEmergency_Success(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + emergency: false, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + err := stepDeactivateEmergency(context.Background(), env, noopAuth(), &bind.CallOpts{}) + require.NoError(t, err) +} + +// --- stepBackwardLET unit tests --- + +// TestStepBackwardLET_FetchL2LeafHashesError verifies the fetch error path. +func TestStepBackwardLET_FetchL2LeafHashesError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + errAtDC: map[uint32]error{0: errors.New("bridge service down")}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "fetch L2 leaf hashes") +} + +// TestStepBackwardLET_BackwardLETTxError verifies the BackwardLET tx send error. +func TestStepBackwardLET_BackwardLETTxError(t *testing.T) { + t.Parallel() + + br0 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("1")} + br1 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("2")} + + bridge := &stubL2Bridge{backwardLETErr: errors.New("contract reverted")} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0, 1: br1}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "send BackwardLET tx") +} + +// TestStepBackwardLET_ReceiptError verifies the receipt wait error path for BackwardLET. +func TestStepBackwardLET_ReceiptError(t *testing.T) { + t.Parallel() + + br0 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("1")} + br1 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("2")} + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, nil, errors.New("node unreachable")) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0, 1: br1}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "wait for BackwardLET receipt") +} + +// TestStepBackwardLET_TxFailed verifies the tx status=0 path. +func TestStepBackwardLET_TxFailed(t *testing.T) { + t.Parallel() + + br0 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("1")} + br1 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("2")} + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, failedReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0, 1: br1}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "BackwardLET tx failed") +} + +// TestStepBackwardLET_DepositCountMismatch verifies the DC verification mismatch path. +func TestStepBackwardLET_DepositCountMismatch(t *testing.T) { + t.Parallel() + + br0 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("1")} + br1 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("2")} + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(99), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0, 1: br1}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "deposit count mismatch after BackwardLET") +} + +// TestStepBackwardLET_Success verifies the happy path. +func TestStepBackwardLET_Success(t *testing.T) { + t.Parallel() + + br0 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("1")} + br1 := &bridgeservicetypes.BridgeResponse{Amount: bridgeservicetypes.BigIntString("2")} + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(1), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: br0, 1: br1}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + L2CurrentDepositCount: 2, + } + + err := stepBackwardLET(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.NoError(t, err) +} + +// --- stepForwardLETDivergentLeaves unit tests --- + +// TestStepForwardLETDivergentLeaves_ForwardLETTxError verifies the tx error path. +func TestStepForwardLETDivergentLeaves_ForwardLETTxError(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(1)} + bridge := &stubL2Bridge{forwardLETErr: errors.New("contract error")} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "send ForwardLET (divergent leaves) tx") +} + +// TestStepForwardLETDivergentLeaves_ReceiptError verifies the receipt error path. +func TestStepForwardLETDivergentLeaves_ReceiptError(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, nil, errors.New("receipt error")) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "wait for ForwardLET (divergent leaves) receipt") +} + +// TestStepForwardLETDivergentLeaves_TxFailed verifies the tx status=0 path. +func TestStepForwardLETDivergentLeaves_TxFailed(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, failedReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "ForwardLET (divergent leaves) tx failed") +} + +// TestStepForwardLETDivergentLeaves_DepositCountMismatch verifies DC mismatch after ForwardLET. +func TestStepForwardLETDivergentLeaves_DepositCountMismatch(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(99), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "deposit count mismatch after ForwardLET (divergent leaves)") +} + +// TestStepForwardLETDivergentLeaves_GetRootError verifies GetRoot error after ForwardLET. +func TestStepForwardLETDivergentLeaves_GetRootError(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(1), + rootErr: errors.New("root fetch failed"), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "get root after ForwardLET (divergent leaves)") +} + +// TestStepForwardLETDivergentLeaves_LERMismatch verifies the LER mismatch path. +func TestStepForwardLETDivergentLeaves_LERMismatch(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(1), + root: [32]byte{0xFF}, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err := stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "LER mismatch after ForwardLET (divergent leaves)") +} + +// TestStepForwardLETDivergentLeaves_Success verifies the happy path (DivergencePoint=0, one leaf). +func TestStepForwardLETDivergentLeaves_Success(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 1, Amount: big.NewInt(5)} + leafHash := BridgeExitLeafHash(leaf) + expectedLER, err := computeRootFromFrontier([32]common.Hash{}, 0, []common.Hash{leafHash}) + require.NoError(t, err) + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(1), + root: [32]byte(expectedLER), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err = stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.NoError(t, err) +} + +// TestStepForwardLETDivergentLeaves_WithDivergencePoint verifies non-zero DivergencePoint path. +func TestStepForwardLETDivergentLeaves_WithDivergencePoint(t *testing.T) { + t.Parallel() + + existingBR := &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 0, + DestinationNetwork: 1, + Amount: bridgeservicetypes.BigIntString("100"), + } + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + + existingHash := BridgeResponseLeafHash(existingBR) + leafHash := BridgeExitLeafHash(leaf) + expectedLER, err := ComputeLERForNewLeaves([]common.Hash{existingHash}, []common.Hash{leafHash}) + require.NoError(t, err) + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(2), + root: [32]byte(expectedLER), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: existingBR}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + } + + err = stepForwardLETDivergentLeaves(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.NoError(t, err) +} + +// --- stepForwardLETExtraL2Bridges unit tests --- + +// TestStepForwardLETExtraL2Bridges_ForwardLETTxError verifies the tx error path. +func TestStepForwardLETExtraL2Bridges_ForwardLETTxError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{forwardLETErr: errors.New("reverted")} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{{DestinationNetwork: 3, Amount: big.NewInt(300)}}, + } + + err := stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "send ForwardLET (extra L2 bridges) tx") +} + +// TestStepForwardLETExtraL2Bridges_ReceiptError verifies the receipt error path. +func TestStepForwardLETExtraL2Bridges_ReceiptError(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, nil, errors.New("receipt unavailable")) + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{{DestinationNetwork: 3, Amount: big.NewInt(300)}}, + } + + err := stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "wait for ForwardLET (extra L2 bridges) receipt") +} + +// TestStepForwardLETExtraL2Bridges_TxFailed verifies the status=0 path. +func TestStepForwardLETExtraL2Bridges_TxFailed(t *testing.T) { + t.Parallel() + + bridge := &stubL2Bridge{tx: &gethTypes.Transaction{}} + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, failedReceipt(), nil) + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{{DestinationNetwork: 3, Amount: big.NewInt(300)}}, + } + + err := stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "ForwardLET (extra L2 bridges) tx failed") +} + +// TestStepForwardLETExtraL2Bridges_DepositCountMismatch verifies DC mismatch. +func TestStepForwardLETExtraL2Bridges_DepositCountMismatch(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(99), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{{DestinationNetwork: 3, Amount: big.NewInt(300)}}, + } + + err := stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "deposit count mismatch after ForwardLET (extra L2 bridges)") +} + +// TestStepForwardLETExtraL2Bridges_LERMismatch verifies LER mismatch after extra bridges ForwardLET. +func TestStepForwardLETExtraL2Bridges_LERMismatch(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + extraLeaf := bridgesync.LeafData{DestinationNetwork: 3, Amount: big.NewInt(300)} + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(2), + root: [32]byte{0xFF}, + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{extraLeaf}, + } + + err := stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.Error(t, err) + require.Contains(t, err.Error(), "LER mismatch after ForwardLET (extra L2 bridges)") +} + +// TestStepForwardLETExtraL2Bridges_Success verifies the happy path. +func TestStepForwardLETExtraL2Bridges_Success(t *testing.T) { + t.Parallel() + + leaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(200)} + extraLeaf := bridgesync.LeafData{DestinationNetwork: 3, Amount: big.NewInt(300)} + + leafHash := BridgeExitLeafHash(leaf) + extraHash := leafDataLeafHash(extraLeaf) + expectedLER, err := computeRootFromFrontier([32]common.Hash{}, 0, []common.Hash{leafHash, extraHash}) + require.NoError(t, err) + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(2), + root: [32]byte(expectedLER), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + + diagnosis := &DiagnosisResult{ + DivergencePoint: 0, + DivergentLeaves: []*agglayertypes.BridgeExit{leaf}, + ExtraL2Bridges: []bridgesync.LeafData{extraLeaf}, + } + + err = stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.NoError(t, err) +} + +// TestStepForwardLETExtraL2Bridges_WithDivergencePoint verifies non-zero DivergencePoint path. +func TestStepForwardLETExtraL2Bridges_WithDivergencePoint(t *testing.T) { + t.Parallel() + + // DC=0 is the existing L2 leaf (fetched via BridgeService). + existingBR := &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 0, + DestinationNetwork: 1, + Amount: bridgeservicetypes.BigIntString("50"), + } + + // Divergent leaf at DC=1. + divergentLeaf := &agglayertypes.BridgeExit{DestinationNetwork: 2, Amount: big.NewInt(100)} + + // Extra L2 leaf inserted at DC=2. + extraLeaf := bridgesync.LeafData{DestinationNetwork: 3, Amount: big.NewInt(200)} + + // Compute expected LER: existing(DC=0) + divergent(DC=1) + extra(DC=2). + existingHash := BridgeResponseLeafHash(existingBR) + divHash := BridgeExitLeafHash(divergentLeaf) + extraHash := leafDataLeafHash(extraLeaf) + expectedLER, err := computeRootFromFrontier([32]common.Hash{}, 0, []common.Hash{existingHash, divHash, extraHash}) + require.NoError(t, err) + + bridge := &stubL2Bridge{ + tx: &gethTypes.Transaction{}, + depositCount: big.NewInt(3), + root: [32]byte(expectedLER), + } + env := buildTestEnv(t, bridge, big.NewInt(1), nil, nil, successReceipt(), nil) + env.BridgeService = &stubBridgeService{ + bridges: map[uint32]*bridgeservicetypes.BridgeResponse{0: existingBR}, + } + + diagnosis := &DiagnosisResult{ + DivergencePoint: 1, + DivergentLeaves: []*agglayertypes.BridgeExit{divergentLeaf}, + ExtraL2Bridges: []bridgesync.LeafData{extraLeaf}, + } + + err = stepForwardLETExtraL2Bridges(context.Background(), env, noopAuth(), &bind.CallOpts{}, diagnosis) + require.NoError(t, err) +} diff --git a/tools/backward_forward_let/run.go b/tools/backward_forward_let/run.go new file mode 100644 index 000000000..e14e57b5d --- /dev/null +++ b/tools/backward_forward_let/run.go @@ -0,0 +1,208 @@ +package backward_forward_let + +import ( + "context" + "fmt" + "math/big" + "os" + "strings" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/agglayer/aggkit/agglayer" + "github.com/agglayer/aggkit/aggsender/rpcclient" + bridgeservice "github.com/agglayer/aggkit/bridgeservice/client" + "github.com/agglayer/aggkit/log" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" +) + +const ( + dialTimeout = 10 * time.Second + recoveryTimeout = 10 * time.Minute +) + +// l2BridgeContract is the subset of agglayerbridgel2.Agglayerbridgel2 used by the tool. +// Defined as an interface to allow mocking in tests. +type l2BridgeContract interface { + // Read-only methods used by Diagnose. + DepositCount(opts *bind.CallOpts) (*big.Int, error) + GetRoot(opts *bind.CallOpts) ([32]byte, error) + IsEmergencyState(opts *bind.CallOpts) (bool, error) + // Write methods used by ExecuteRecovery. + ActivateEmergencyState(opts *bind.TransactOpts) (*gethTypes.Transaction, error) + DeactivateEmergencyState(opts *bind.TransactOpts) (*gethTypes.Transaction, error) + BackwardLET(opts *bind.TransactOpts, newDepositCount *big.Int, newFrontier [32][32]byte, nextLeaf [32]byte, proof [32][32]byte) (*gethTypes.Transaction, error) //nolint:lll + ForwardLET(opts *bind.TransactOpts, newLeaves []agglayerbridgel2.AgglayerBridgeL2LeafData, expectedLER [32]byte) (*gethTypes.Transaction, error) //nolint:lll +} + +// Env holds all connections and contract bindings needed by the backward/forward LET tool. +type Env struct { + // L2Client is the L2 Ethereum RPC client. + L2Client *ethclient.Client + + // BridgeService is the aggkit bridge service REST client. + BridgeService bridgeServiceClient + + // AgglayerClient is the gRPC client for the AggLayer node. + AgglayerClient agglayer.AgglayerClientInterface + + // AggsenderRPC is the JSON-RPC client for the running aggsender process. + AggsenderRPC aggsenderRPCClient + + // BridgeExitsOverride is loaded from CertificateExitsFile if configured. + // nil when no override file is specified. + BridgeExitsOverride *BridgeExitsOverride + + // L2Bridge is the bound L2 bridge contract. + L2Bridge l2BridgeContract + + // L2NetworkID is the network ID of the L2 chain. + L2NetworkID uint32 + + // Config holds the loaded configuration. + Config *Config + + // chainIDFn returns the L2 chain ID. Defaults to L2Client.ChainID. Override in tests. + chainIDFn func(ctx context.Context) (*big.Int, error) + + // buildAuthFn builds a bind.TransactOpts for the given signer config. Override in tests. + buildAuthFn func(ctx context.Context, cfg signertypes.SignerConfig, l2ChainID *big.Int, name string) (*bind.TransactOpts, error) //nolint:lll + + // waitReceiptFn waits for a transaction to be mined and returns its receipt. + // Defaults to waitForReceipt wrapping bind.WaitMined. Override in tests. + waitReceiptFn func(ctx context.Context, tx *gethTypes.Transaction) (*gethTypes.Receipt, error) +} + +// Close closes the L2 RPC connection. +func (e *Env) Close() error { + if e == nil { + return nil + } + if e.L2Client != nil { + e.L2Client.Close() + } + return nil +} + +// SetupEnv dials L2, initialises contract bindings, bridge service, agglayer, and aggsender clients. +func SetupEnv(ctx context.Context, cfg *Config) (*Env, error) { + if cfg.BackwardForwardLET.BridgeServiceURL == "" { + return nil, fmt.Errorf("BackwardForwardLET.BridgeServiceURL is required") + } + + bridgeSvc := bridgeservice.New(bridgeservice.Config{BaseURL: cfg.BackwardForwardLET.BridgeServiceURL}) + if _, err := bridgeSvc.HealthCheck(ctx); err != nil { + return nil, fmt.Errorf("bridge service health check at %s: %w", + cfg.BackwardForwardLET.BridgeServiceURL, err) + } + + l2Client, err := ethclient.DialContext(ctx, cfg.Common.L2RPC.URL) + if err != nil { + return nil, fmt.Errorf("connect to L2: %w", err) + } + + agglayerClient, err := agglayer.NewAgglayerClient(cfg.AgglayerClient, + log.GetDefaultLogger()) + if err != nil { + l2Client.Close() + return nil, fmt.Errorf("create agglayer client: %w", err) + } + + aggsenderRPC := rpcclient.NewClient(cfg.BackwardForwardLET.AggsenderRPCURL) + + l2Bridge, err := agglayerbridgel2.NewAgglayerbridgel2(cfg.BridgeL2Sync.BridgeAddr, l2Client) + if err != nil { + l2Client.Close() + return nil, fmt.Errorf("initialize L2 bridge binding: %w", err) + } + + var bridgeExitsOverride *BridgeExitsOverride + if cfg.BackwardForwardLET.CertificateExitsFile != "" { + bridgeExitsOverride, err = LoadBridgeExitsOverride(cfg.BackwardForwardLET.CertificateExitsFile) + if err != nil { + l2Client.Close() + return nil, fmt.Errorf("load certificate exits override: %w", err) + } + } + + env := &Env{ + L2Client: l2Client, + BridgeService: bridgeSvc, + AgglayerClient: agglayerClient, + AggsenderRPC: aggsenderRPC, + BridgeExitsOverride: bridgeExitsOverride, + L2Bridge: l2Bridge, + L2NetworkID: cfg.BackwardForwardLET.L2NetworkID, + Config: cfg, + } + env.chainIDFn = l2Client.ChainID + env.buildAuthFn = buildTransactOpts + env.waitReceiptFn = func(ctx context.Context, tx *gethTypes.Transaction) (*gethTypes.Receipt, error) { + return waitForReceipt(ctx, l2Client, tx) + } + return env, nil +} + +// Run is the main entry point for the backward/forward LET CLI. +func Run(c *cli.Context) error { + cfg, err := LoadConfig(c) + if err != nil { + return err + } + + // Flag takes precedence over config file. + if f := c.String("cert-exits-file"); f != "" { + cfg.BackwardForwardLET.CertificateExitsFile = f + } + + dialCtx, dialCancel := context.WithTimeout(c.Context, dialTimeout) + env, err := SetupEnv(dialCtx, cfg) + dialCancel() + if err != nil { + return err + } + defer env.Close() + + diagnosis, err := Diagnose(c.Context, env) + if err != nil { + return fmt.Errorf("diagnosis failed: %w", err) + } + + PrintDiagnosis(os.Stdout, diagnosis) + + if diagnosis.Case == NoDivergence { + fmt.Println("Nothing to do: L1 settled state and L2 on-chain state are in sync.") + return nil + } + + if diagnosis.AggsenderAPIFailed { + fmt.Printf("\nAggsender RPC was unreachable. Cannot proceed with recovery.\n") + fmt.Printf("Contact your AggLayer admin with the failed certificate details above.\n") + return nil + } + + if !c.Bool("yes") { + fmt.Print("\nProceed with recovery? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + recoveryCtx, recoveryCancel := context.WithTimeout(c.Context, recoveryTimeout) + defer recoveryCancel() + + if err := ExecuteRecovery(recoveryCtx, env, diagnosis); err != nil { + return fmt.Errorf("recovery failed: %w", err) + } + + fmt.Println("\nRecovery completed successfully.") + return nil +} diff --git a/tools/backward_forward_let/types.go b/tools/backward_forward_let/types.go new file mode 100644 index 000000000..4266416aa --- /dev/null +++ b/tools/backward_forward_let/types.go @@ -0,0 +1,96 @@ +package backward_forward_let + +import ( + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/bridgesync" + "github.com/ethereum/go-ethereum/common" +) + +// RecoveryCase classifies the divergence between the L1 settled LET and the L2 bridge state. +type RecoveryCase string + +const ( + // NoDivergence indicates L1 settled state and L2 on-chain state are in sync. + NoDivergence RecoveryCase = "NoDivergence" + // Case1 is ForwardLET only — a single divergent leaf batch, no extra L2 bridges. + Case1 RecoveryCase = "Case1" + // Case2 is BackwardLET + ForwardLET — single divergent leaf + extra real L2 bridges. + Case2 RecoveryCase = "Case2" + // Case3 is ForwardLET only — multiple divergent leaf batches, no extra L2 bridges. + Case3 RecoveryCase = "Case3" + // Case4 is BackwardLET + ForwardLET — multiple divergent leaves + extra real L2 bridges. + Case4 RecoveryCase = "Case4" +) + +// UndercollateralizedToken tracks the net under-collateralization amount per token. +type UndercollateralizedToken struct { + TokenOriginNetwork uint32 + TokenOriginAddress common.Address + Amount *big.Int +} + +// DiagnosisResult holds the complete output of the diagnosis phase. +type DiagnosisResult struct { + Case RecoveryCase + + // L1 settled state (from AggLayer NetworkInfo). + L1SettledLER common.Hash + L1SettledDepositCount uint32 // = SettledLETLeafCount from NetworkInfo + L1SettledHeight uint64 + L1SettledCertificateID common.Hash + + // L2 on-chain bridge state. + L2CurrentLER common.Hash + L2CurrentDepositCount uint32 + + // DivergencePoint is the number of leading leaves that match between + // L1 settled and L2 bridge. It is also the target deposit count for BackwardLET. + DivergencePoint uint32 + + // ExtraL2Bridges contains real L2 bridges (bridgesync.LeafData) after DivergencePoint. + // Populated for Cases 2 and 4. + ExtraL2Bridges []bridgesync.LeafData + + // DivergentLeaves are the bridge exits settled on L1 that are absent or different on L2. + DivergentLeaves []*agglayertypes.BridgeExit + + // Undercollateralization summarises token under-collateralization from DivergentLeaves. + Undercollateralization []UndercollateralizedToken + + // IsEmergencyState reports whether the L2 bridge is already paused. + IsEmergencyState bool + + // AggsenderAPIFailed is set when the aggsender RPC was unreachable during the divergence walk. + AggsenderAPIFailed bool + + // MissingCerts lists the certificate heights for which no bridge exit data + // was available. Populated when AggsenderAPIFailed is true. + // The operator should fetch each cert from the agglayer admin API using + // the provided CertID, then supply a JSON override file. + MissingCerts []MissingCertInfo + + // Deprecated: use MissingCerts instead. FailedCertHeight is the first height + // for which no bridge exit data was available. + FailedCertHeight uint64 + + // Deprecated: use MissingCerts instead. FailedCertID is the cert ID for + // FailedCertHeight, if it could be resolved. + FailedCertID common.Hash +} + +// MissingCertInfo describes a certificate height for which bridge exits +// could not be obtained from any available source. +type MissingCertInfo struct { + // Height is the certificate height that is missing. + Height uint64 + + // CertID is the agglayer CertificateId for this height, if it could be + // resolved via the public gRPC. Zero-value when not resolvable. + CertID common.Hash + + // CertIDResolved is true when CertID was successfully resolved. + // When false, the operator must contact the agglayer admin. + CertIDResolved bool +}