diff --git a/autoclaim/proof/leaf_proof_refresher_test.go b/autoclaim/proof/leaf_proof_refresher_test.go index 8b7d95095..e34adf17d 100644 --- a/autoclaim/proof/leaf_proof_refresher_test.go +++ b/autoclaim/proof/leaf_proof_refresher_test.go @@ -36,6 +36,18 @@ func (f *fakeURLResolver) GetURL(networkID uint32) (bridgeservicefinder.NetworkU return bridgeservicefinder.NetworkURLs{BridgeURL: f.urls[networkID]}, nil } +func (f *fakeURLResolver) NetworkIDs() []uint32 { + ids := make([]uint32, 0, len(f.urls)) + for id := range f.urls { + ids = append(ids, id) + } + return ids +} + +func (f *fakeURLResolver) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, nil +} + // fakeClaimProofClient implements claimProofClient for tests, keyed by base URL. type fakeClaimProofClient struct { baseURL string diff --git a/autoclaim/runtime/runtime.go b/autoclaim/runtime/runtime.go index fec51b161..ff35162d2 100644 --- a/autoclaim/runtime/runtime.go +++ b/autoclaim/runtime/runtime.go @@ -536,6 +536,14 @@ func (noopBridgeServiceFinder) GetURL(networkID uint32) (bridgeservicefinder.Net networkID) } +func (noopBridgeServiceFinder) NetworkIDs() []uint32 { return nil } + +func (noopBridgeServiceFinder) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + return common.Address{}, fmt.Errorf( + "autoclaim bridge service finder is not configured (AutoClaim.L2ToLxBridgeDetector.Enabled=false): network %d", + networkID) +} + // startRuntimeComponents launches the goroutines for tx managers, claimers, and the bridge detector. func startRuntimeComponents( ctx context.Context, diff --git a/autoclaim/runtime/runtime_test.go b/autoclaim/runtime/runtime_test.go index ea303a096..7dfc88349 100644 --- a/autoclaim/runtime/runtime_test.go +++ b/autoclaim/runtime/runtime_test.go @@ -290,6 +290,12 @@ func (fakeBridgeServiceFinder) GetURL(uint32) (bridgeservicefinder.NetworkURLs, return bridgeservicefinder.NetworkURLs{BridgeURL: "http://fake-source"}, nil } +func (fakeBridgeServiceFinder) NetworkIDs() []uint32 { return nil } + +func (fakeBridgeServiceFinder) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, nil +} + func withL2ToLxEnabled(cfg autoclaimcfg.Config) autoclaimcfg.Config { cfg.L2ToLxBridgeDetector = autoclaimcfg.L2ToLxBridgeDetector{ Enabled: true, diff --git a/bridgeservicefinder/bridge_address_test.go b/bridgeservicefinder/bridge_address_test.go new file mode 100644 index 000000000..407dce0fa --- /dev/null +++ b/bridgeservicefinder/bridge_address_test.go @@ -0,0 +1,123 @@ +package bridgeservicefinder + +import ( + "errors" + "testing" + + "github.com/agglayer/aggkit/bridgeservicefinder/mocks" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newTestFinderForBridgeAddress builds a *finder (not the Finder interface) with rm as its +// RollupManagerQuerier, so tests can call BridgeAddress directly without going through Start +// (BridgeAddress is resolved lazily, on first use, independently of the cache built by Start). +func newTestFinderForBridgeAddress(t *testing.T, cfg Config, rm RollupManagerQuerier) *finder { + t.Helper() + + f, err := New(cfg, Options{RollupManager: rm, LogFilterer: mocks.NewLogFilterer(t), Logger: testLogger()}) + require.NoError(t, err) + concrete, ok := f.(*finder) + require.True(t, ok) + return concrete +} + +// TestBridgeAddress_DefaultsToRollupManagerBridgeAddress verifies a network absent from +// Config.BridgeAddress resolves to the rollup manager's own on-chain BridgeAddress(). +func TestBridgeAddress_DefaultsToRollupManagerBridgeAddress(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + got, err := f.BridgeAddress(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, wantAddr, got) +} + +// TestBridgeAddress_CachesTheOnChainDefaultAcrossNetworksAndCalls verifies the on-chain +// BridgeAddress() call happens at most once, regardless of how many networks or calls ask for +// the default — it is an immutable constructor parameter, safe to cache forever. +func TestBridgeAddress_CachesTheOnChainDefaultAcrossNetworksAndCalls(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() // .Once(): a second call fails the test + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + for _, networkID := range []uint32{1, 2, 1} { + got, err := f.BridgeAddress(t.Context(), networkID) + require.NoError(t, err) + require.Equal(t, wantAddr, got) + } +} + +// TestBridgeAddress_OverridePrecedesTheOnChainDefault verifies a networkID present in +// Config.BridgeAddress is served verbatim, without ever consulting the rollup manager. +func TestBridgeAddress_OverridePrecedesTheOnChainDefault(t *testing.T) { + overrideAddr := common.HexToAddress("0xdeaf") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{BridgeAddress: map[uint32]common.Address{63: overrideAddr}}, rm) + + got, err := f.BridgeAddress(t.Context(), 63) + require.NoError(t, err) + require.Equal(t, overrideAddr, got) +} + +// TestBridgeAddress_Network0OverrideIsDefaultForOtherNetworks verifies a Config.BridgeAddress[0] +// override doubles as the default for a network with no override of its own, taking precedence +// over the on-chain rollup manager BridgeAddress() (which must never be consulted in this case). +func TestBridgeAddress_Network0OverrideIsDefaultForOtherNetworks(t *testing.T) { + network0Addr := common.HexToAddress("0xcafe") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{BridgeAddress: map[uint32]common.Address{0: network0Addr}}, rm) + + for _, networkID := range []uint32{0, 5, 82} { + got, err := f.BridgeAddress(t.Context(), networkID) + require.NoError(t, err) + require.Equal(t, network0Addr, got) + } +} + +// TestBridgeAddress_PerNetworkOverridePrecedesNetwork0Default verifies a network's own override +// wins over Config.BridgeAddress[0], even when both are configured. +func TestBridgeAddress_PerNetworkOverridePrecedesNetwork0Default(t *testing.T) { + network0Addr := common.HexToAddress("0xcafe") + network63Addr := common.HexToAddress("0xdeaf") + rm := mocks.NewRollupManagerQuerier(t) // no BridgeAddress expectation: must never be called + + f := newTestFinderForBridgeAddress(t, Config{ + BridgeAddress: map[uint32]common.Address{0: network0Addr, 63: network63Addr}, + }, rm) + + got, err := f.BridgeAddress(t.Context(), 63) + require.NoError(t, err) + require.Equal(t, network63Addr, got) + + got, err = f.BridgeAddress(t.Context(), 84) + require.NoError(t, err) + require.Equal(t, network0Addr, got) +} + +// TestBridgeAddress_OnChainFailureIsNotCached verifies a failed on-chain read is not cached: the +// next call retries instead of repeating the same error forever. +func TestBridgeAddress_OnChainFailureIsNotCached(t *testing.T) { + wantAddr := common.HexToAddress("0xb1123e") + wantErr := errors.New("rpc unavailable") + rm := mocks.NewRollupManagerQuerier(t) + rm.EXPECT().BridgeAddress(mock.Anything).Return(common.Address{}, wantErr).Once() + rm.EXPECT().BridgeAddress(mock.Anything).Return(wantAddr, nil).Once() + + f := newTestFinderForBridgeAddress(t, Config{}, rm) + + _, err := f.BridgeAddress(t.Context(), 1) + require.ErrorIs(t, err, wantErr) + + got, err := f.BridgeAddress(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, wantAddr, got) +} diff --git a/bridgeservicefinder/bridgeservicefinder.go b/bridgeservicefinder/bridgeservicefinder.go index 68bbbaa5c..6db8e05b4 100644 --- a/bridgeservicefinder/bridgeservicefinder.go +++ b/bridgeservicefinder/bridgeservicefinder.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sync" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" aggkitcommon "github.com/agglayer/aggkit/common" @@ -74,6 +75,13 @@ type finder struct { // skipped entirely during enumeration (see buildInitialCache) and live discovery (see listener's // discoverRollup). ignoreNetworkIDs map[uint32]struct{} + + // bridgeAddrMu guards defaultBridgeAddr + bridgeAddrMu sync.Mutex + // defaultBridgeAddr caches the rollup manager's own BridgeAddress() once resolved (see + // BridgeAddress): nil until the first call needs it. It is an immutable constructor parameter of + // the rollup manager, so once resolved it is cached forever — never re-read or invalidated. + defaultBridgeAddr *common.Address } // buildIgnoreSet turns Config.IgnoreNetworkIDs into a set for O(1) membership checks. @@ -187,7 +195,11 @@ func (f *finder) Start(ctx context.Context) error { if err := f.buildInitialCache(ctx); err != nil { return err } - + listNetworksStr := "" + for networkID := range f.cache.entries { + listNetworksStr += fmt.Sprintf("%d, ", networkID) + } + f.logger.Info("Resolved network entries: " + listNetworksStr) unhealthy := f.probeAll(ctx) if unhealthy > 0 && f.cfg.RequireAllHealthyOnStart { return fmt.Errorf("%w: %d unreachable", ErrServicesUnhealthyOnStart, unhealthy) @@ -375,3 +387,46 @@ func (f *finder) GetURL(networkID uint32) (NetworkURLs, error) { return NetworkURLs{BridgeURL: entry.url, JSONRPCURL: entry.jsonRPCURL}, nil } + +// NetworkIDs returns the networkIDs of every network currently resolved (i.e. every network +// GetURL would presently succeed for). +func (f *finder) NetworkIDs() []uint32 { + return f.cache.networkIDs() +} + +// BridgeAddress returns the bridge contract address for networkID, in priority order: +// 1. Config.BridgeAddress[networkID], if set. +// 2. Config.BridgeAddress[0], if set — network 0's override doubles as the default for every +// other network that has none of its own, since it is typically the shared L1 bridge address. +// 3. The rollup manager's own on-chain BridgeAddress(), resolved once and cached forever (see +// defaultBridgeAddress) — only reached when neither override above is configured. +func (f *finder) BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) { + if addr, ok := f.cfg.BridgeAddress[networkID]; ok { + return addr, nil + } + if addr, ok := f.cfg.BridgeAddress[0]; ok { + return addr, nil + } + return f.defaultBridgeAddress(ctx) +} + +// defaultBridgeAddress returns the rollup manager's own BridgeAddress(), resolving it on chain the +// first time it is needed and caching it forever after: it is an immutable constructor parameter +// of the rollup manager, so it can never change once deployed. A transient failure (e.g. a +// transport error) is not cached, so the next call retries the on-chain read. +func (f *finder) defaultBridgeAddress(ctx context.Context) (common.Address, error) { + f.bridgeAddrMu.Lock() + defer f.bridgeAddrMu.Unlock() + + if f.defaultBridgeAddr != nil { + return *f.defaultBridgeAddr, nil + } + + addr, err := f.rollupManager.BridgeAddress(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, fmt.Errorf("reading rollup manager's bridge address: %w", err) + } + + f.defaultBridgeAddr = &addr + return addr, nil +} diff --git a/bridgeservicefinder/cache.go b/bridgeservicefinder/cache.go index 7e08247b9..4d91e5101 100644 --- a/bridgeservicefinder/cache.go +++ b/bridgeservicefinder/cache.go @@ -49,3 +49,17 @@ func (c *cache) set(networkID uint32, entry cacheEntry) { c.entries[networkID] = entry } + +// networkIDs returns the networkIDs of every network currently cached. It takes a read lock so +// it is safe to call concurrently with set. +func (c *cache) networkIDs() []uint32 { + c.mu.RLock() + defer c.mu.RUnlock() + + ids := make([]uint32, 0, len(c.entries)) + for id := range c.entries { + ids = append(ids, id) + } + + return ids +} diff --git a/bridgeservicefinder/cache_test.go b/bridgeservicefinder/cache_test.go new file mode 100644 index 000000000..71ac886e3 --- /dev/null +++ b/bridgeservicefinder/cache_test.go @@ -0,0 +1,43 @@ +package bridgeservicefinder + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCache_NetworkIDs verifies networkIDs returns exactly the networkIDs currently cached, +// with no duplicates and regardless of insertion order. +func TestCache_NetworkIDs(t *testing.T) { + c := newCache() + require.Empty(t, c.networkIDs()) + + c.set(1, cacheEntry{url: "http://network-1"}) + c.set(0, cacheEntry{url: "http://network-0"}) + c.set(42, cacheEntry{url: "http://network-42"}) + + require.ElementsMatch(t, []uint32{0, 1, 42}, c.networkIDs()) + + // Overwriting an existing entry does not duplicate it + c.set(1, cacheEntry{url: "http://network-1-updated"}) + require.ElementsMatch(t, []uint32{0, 1, 42}, c.networkIDs()) +} + +// TestFinder_NetworkIDs verifies finder.NetworkIDs delegates to the cache, i.e. it reports +// exactly the networks GetURL would presently succeed for. +func TestFinder_NetworkIDs(t *testing.T) { + f := &finder{cache: newCache()} + require.Empty(t, f.NetworkIDs()) + + f.cache.set(1, cacheEntry{url: "http://network-1"}) + f.cache.set(7, cacheEntry{url: "http://network-7"}) + + ids := f.NetworkIDs() + require.ElementsMatch(t, []uint32{1, 7}, ids) + + for _, id := range ids { + urls, err := f.GetURL(id) + require.NoError(t, err) + require.NotEmpty(t, urls.BridgeURL) + } +} diff --git a/bridgeservicefinder/config.go b/bridgeservicefinder/config.go index b0597e5dd..12bf805bf 100644 --- a/bridgeservicefinder/config.go +++ b/bridgeservicefinder/config.go @@ -58,6 +58,13 @@ type Config struct { // absent from this map get their JSON-RPC endpoint from the rollup's trustedSequencerURL. RPCURLs map[uint32]string `mapstructure:"RPCURLs"` + // BridgeAddress is the static override map from networkID to bridge contract address, consulted + // by Finder.BridgeAddress in priority order: BridgeAddress[networkID], then BridgeAddress[0] + // (which doubles as the default for every network without its own entry — typically the shared + // L1 bridge address), then finally the rollup manager's own on-chain BridgeAddress() if neither + // is set. Only networks whose bridge contract differs from that default need their own entry. + BridgeAddress map[uint32]common.Address `mapstructure:"BridgeAddress"` + // BlockFinality is the finality level used to bound the upper block of each event scan, so the // finder does not react to logs that may still be reorged away. See aggkittypes.BlockNumberFinality. BlockFinality aggkittypes.BlockNumberFinality `jsonschema:"enum=PendingBlock,enum=LatestBlock,enum=SafeBlock,enum=FinalizedBlock,enum=EarliestBlock" mapstructure:"BlockFinality"` //nolint:lll diff --git a/bridgeservicefinder/interfaces.go b/bridgeservicefinder/interfaces.go index cf94c58e2..3e4414099 100644 --- a/bridgeservicefinder/interfaces.go +++ b/bridgeservicefinder/interfaces.go @@ -68,6 +68,21 @@ type Finder interface { // ErrURLNotFound if nothing is cached. networkID follows the mapping documented in doc.go // (networkID == rollupID; network 0 is L1 and is only served if provided via Config.BridgeURLs). GetURL(networkID uint32) (NetworkURLs, error) + // NetworkIDs returns the networkIDs of every network currently resolved — i.e. every network + // GetURL would presently succeed for. Used by callers that need to enumerate every configured + // bridge service rather than query one network at a time (e.g. the bridge tracker's activity + // scanner). Order is unspecified. + NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID, in priority order: + // Config.BridgeAddress[networkID] if set; else Config.BridgeAddress[0] if set (network 0's + // override doubles as the default for every network without its own, since it is typically the + // shared L1 bridge address); else the rollup manager's own on-chain BridgeAddress() — resolved + // once and cached forever, since it is an immutable constructor parameter of the rollup manager. + // A network whose bridge contract differs from that default needs its own + // Config.BridgeAddress override. Returns an error only when no override applies and the + // on-chain default could not be resolved (e.g. a transport failure) — such a failure is not + // cached, so the next call retries. + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) } // RollupManagerQuerier enumerates the rollups attached to a rollup manager and reads their data. @@ -83,6 +98,9 @@ type RollupManagerQuerier interface { // aggchain-type rollups. ChainID is the rollup's L2 chain id. RollupIDToRollupData(opts *bind.CallOpts, rollupID uint32) ( agglayermanager.AgglayerManagerRollupDataReturn, error) + // BridgeAddress returns the bridge contract address the rollup manager was constructed with: an + // immutable constructor parameter, so the same value for the lifetime of the contract. + BridgeAddress(opts *bind.CallOpts) (common.Address, error) } // RollupContractReader reads the two on-chain sources (metadata and trusted-sequencer URL) from a diff --git a/bridgeservicefinder/mocks/mock_rollup_manager_querier.go b/bridgeservicefinder/mocks/mock_rollup_manager_querier.go index 9fd9238e3..d1d3cad3b 100644 --- a/bridgeservicefinder/mocks/mock_rollup_manager_querier.go +++ b/bridgeservicefinder/mocks/mock_rollup_manager_querier.go @@ -6,6 +6,8 @@ import ( agglayermanager "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayermanager" bind "github.com/ethereum/go-ethereum/accounts/abi/bind" + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" ) @@ -22,6 +24,64 @@ func (_m *RollupManagerQuerier) EXPECT() *RollupManagerQuerier_Expecter { return &RollupManagerQuerier_Expecter{mock: &_m.Mock} } +// BridgeAddress provides a mock function with given fields: opts +func (_m *RollupManagerQuerier) BridgeAddress(opts *bind.CallOpts) (common.Address, error) { + ret := _m.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for BridgeAddress") + } + + var r0 common.Address + var r1 error + if rf, ok := ret.Get(0).(func(*bind.CallOpts) (common.Address, error)); ok { + return rf(opts) + } + if rf, ok := ret.Get(0).(func(*bind.CallOpts) common.Address); ok { + r0 = rf(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Address) + } + } + + if rf, ok := ret.Get(1).(func(*bind.CallOpts) error); ok { + r1 = rf(opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RollupManagerQuerier_BridgeAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BridgeAddress' +type RollupManagerQuerier_BridgeAddress_Call struct { + *mock.Call +} + +// BridgeAddress is a helper method to define mock.On call +// - opts *bind.CallOpts +func (_e *RollupManagerQuerier_Expecter) BridgeAddress(opts interface{}) *RollupManagerQuerier_BridgeAddress_Call { + return &RollupManagerQuerier_BridgeAddress_Call{Call: _e.mock.On("BridgeAddress", opts)} +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) Run(run func(opts *bind.CallOpts)) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*bind.CallOpts)) + }) + return _c +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) Return(_a0 common.Address, _a1 error) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RollupManagerQuerier_BridgeAddress_Call) RunAndReturn(run func(*bind.CallOpts) (common.Address, error)) *RollupManagerQuerier_BridgeAddress_Call { + _c.Call.Return(run) + return _c +} + // RollupCount provides a mock function with given fields: opts func (_m *RollupManagerQuerier) RollupCount(opts *bind.CallOpts) (uint32, error) { ret := _m.Called(opts) diff --git a/bridgetracker/activity.go b/bridgetracker/activity.go new file mode 100644 index 000000000..e6a713de8 --- /dev/null +++ b/bridgetracker/activity.go @@ -0,0 +1,279 @@ +package bridgetracker + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum/common" +) + +// compile-time check: ActivityCache fulfils ActivityQuerier +var _ ActivityQuerier = (*ActivityCache)(nil) + +// activityAddrCache is the per-from_address state ActivityCache keeps: every bridge found for +// it so far, and when it was last requested (see ActivityCache.addrCache, which stamps this on +// every GetActivity call and is what the idle sweep evicts by — the same lastAccess/PruneIdle +// idea memoryRegistry uses for tracking, see registry.go's bridgeEntry) +type activityAddrCache struct { + entries map[string]*domain.ActivityEntry // key: bridge.GlobalIndex.String() + // lastAccess is when this address was last requested; addresses idle past idleTimeout are + // forgotten (see ActivityCache.addrCache) + lastAccess time.Time +} + +// ActivityCache implements domain.ActivityQuerier: for a given from_address it scans every +// configured bridge service (via ActivityBridgeScanner) for bridges it has not already cached, +// and keeps a running per-address cache of the resulting bridges, so: +// - a bridge already known is never re-scanned from the bridge service again (see +// ActivityBridgeScanner.BridgesFrom's known parameter); +// - once a bridge is confirmed claimed, isClaimed() is never asked again for it, even if its +// claim record has not been fetched yet; +// - once a bridge's claim record has been fetched, it is never asked for again; +// - an address nobody has asked about in idleTimeout is forgotten entirely, freeing everything +// cached for it (mirrors SupervisedStore.PruneIdle's idea, without a dedicated ticker: see +// addrCache). +// +// Safe for concurrent use. +type ActivityCache struct { + scanner ActivityBridgeScanner + claims ActivityClaimChecker + supervised SupervisedStore + logger aggkitcommon.Logger + + idleTimeout time.Duration + // now is the clock lastAccess is stamped with and the idle sweep compares against, + // injectable for tests (mirrors memoryRegistry.now, registry.go) + now func() time.Time + + mu sync.Mutex + byAddr map[common.Address]*activityAddrCache +} + +// NewActivityCache returns an ActivityCache resolving bridges through scanner, claim state +// through claims, and (when asked) tracker registration through supervised. idleTimeout is how +// long an address survives with no GetActivity call for it before being forgotten; <= 0 falls +// back to DefaultIdleTimeout +func NewActivityCache( + scanner ActivityBridgeScanner, claims ActivityClaimChecker, supervised SupervisedStore, + logger aggkitcommon.Logger, idleTimeout time.Duration, +) *ActivityCache { + if idleTimeout <= 0 { + idleTimeout = DefaultIdleTimeout.Duration + } + return &ActivityCache{ + scanner: scanner, + claims: claims, + supervised: supervised, + logger: logger, + idleTimeout: idleTimeout, + now: time.Now, + byAddr: make(map[common.Address]*activityAddrCache), + } +} + +// GetActivity implements domain.ActivityQuerier: it rechecks every bridge already cached for +// fromAddress that is not yet settled (see settled — their raw bridge data is already cached, so +// this needs no bridge-service call), then scans for bridges not seen before (see +// ActivityBridgeScanner.BridgesFrom), and returns everything cached for fromAddress that matches +// filter. The returned []domain.ActivityWarning is whatever the scan reported for networks it +// could not reach this call (see ActivityBridgeScanner.BridgesFrom) — it never fails the call by +// itself, since the result is still valid for every other network. +func (a *ActivityCache) GetActivity( + ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, +) ([]*domain.ActivityEntry, []domain.ActivityWarning, error) { + addrCache := a.addrCache(fromAddress) + + a.mu.Lock() + known := make(map[string]struct{}, len(addrCache.entries)) + cached := make([]*domain.ActivityEntry, 0, len(addrCache.entries)) + for key, entry := range addrCache.entries { + known[key] = struct{}{} + cached = append(cached, entry) + } + a.mu.Unlock() + + for _, entry := range cached { + scanned := &domain.ScannedBridge{Bridge: entry.Bridge, NetworkID: entry.BridgeNetworkID} + a.upsert(ctx, addrCache, scanned, includeTracking, filter) + } + + newItems, warnings, err := a.scanner.BridgesFrom(ctx, fromAddress, known) + if err != nil { + return nil, nil, fmt.Errorf("scanning bridges from %s: %w", fromAddress, err) + } + for _, item := range newItems { + a.upsert(ctx, addrCache, item, includeTracking, filter) + } + + a.mu.Lock() + defer a.mu.Unlock() + out := make([]*domain.ActivityEntry, 0, len(addrCache.entries)) + for _, entry := range addrCache.entries { + if matchesFilter(entry, filter) { + out = append(out, entry) + } + } + return out, warnings, nil +} + +// upsert (re)computes item's entry via refresh and stores it, unless it is already cached and +// settled — in which case it is left untouched. Safe to call with an item the caller cannot be +// sure is genuinely new (e.g. a defensive re-check, or a pagination-boundary duplicate): settled +// entries are never redundantly refreshed regardless of where item came from +func (a *ActivityCache) upsert( + ctx context.Context, addrCache *activityAddrCache, item *domain.ScannedBridge, + includeTracking bool, filter types.ActivityFilter, +) { + key := item.Bridge.GlobalIndex.String() + + a.mu.Lock() + existing := addrCache.entries[key] + a.mu.Unlock() + + if existing != nil && settled(existing) { + return + } + + entry := a.refresh(ctx, item, existing, includeTracking, filter) + + a.mu.Lock() + addrCache.entries[key] = entry + a.mu.Unlock() +} + +// matchesFilter reports whether entry belongs in a GetActivity result under filter: +// ActivityFilterAll always matches; the other filters match exactly one ClaimStatus each — see +// ActivityFilter's doc for what each one means +func matchesFilter(entry *domain.ActivityEntry, filter types.ActivityFilter) bool { + switch filter { + case types.ActivityFilterClaimed: + return entry.ClaimStatus == types.ClaimStatusClaimed + case types.ActivityFilterPending: + return entry.ClaimStatus == types.ClaimStatusUnclaimed + case types.ActivityFilterError: + return entry.ClaimStatus == types.ClaimStatusError + case types.ActivityFilterAll: + return true + default: + return true + } +} + +// skipsClaimInfo reports whether filter excludes a claimed bridge from its result, making the +// destination bridge service's claim record unnecessary to fetch right now (see refresh) +func skipsClaimInfo(filter types.ActivityFilter) bool { + return filter == types.ActivityFilterPending || filter == types.ActivityFilterError +} + +// addrCache returns (creating if necessary) the per-address cache for fromAddress, stamping its +// lastAccess with now. Before that, it sweeps every address whose lastAccess is older than +// idleTimeout out of byAddr — the idle-eviction sweep. There is no dedicated ticker/goroutine for +// this (unlike SupervisedStore.PruneIdle, which the tracking engine drives on its own poll +// ticker, see engine.go's tick): ActivityCache has no background loop of its own to piggyback +// on, and sweeping on every real request is cheap for the expected number of distinct addresses. +func (a *ActivityCache) addrCache(fromAddress common.Address) *activityAddrCache { + a.mu.Lock() + defer a.mu.Unlock() + + now := a.now() + cutoff := now.Add(-a.idleTimeout) + for addr, cache := range a.byAddr { + // fromAddress itself is deliberately not exempted: if it was already idle-expired as of + // its last access, its stale state is forgotten and it starts fresh below, exactly as if + // this were its first request + if cache.lastAccess.Before(cutoff) { + delete(a.byAddr, addr) + } + } + + addrCache, ok := a.byAddr[fromAddress] + if !ok { + addrCache = &activityAddrCache{entries: make(map[string]*domain.ActivityEntry)} + a.byAddr[fromAddress] = addrCache + } + addrCache.lastAccess = now + return addrCache +} + +// settled reports whether entry is done being rechecked: claimed, with the claim record +// already fetched. Anything else (unclaimed, the isClaimed() check itself having failed, or +// claimed but the destination bridge service has not indexed the claim yet) is re-verified on +// every GetActivity call +func settled(entry *domain.ActivityEntry) bool { + return entry.ClaimStatus == types.ClaimStatusClaimed && entry.Claim != nil +} + +// refresh (re)computes the claim/tracking state of a single bridge item, stamping +// ActivityEntry.CreatedAt (carried forward from existing, or now if this is the first time) and +// UpdatedAt (always now — every call to refresh counts as an update, whether or not anything +// about the entry actually changed). existing is the previously cached entry for this same +// bridge, or nil if it has never been seen before: +// - if existing is already confirmed claimed, isClaimed() is not asked again — that result +// never reverts — and refresh goes straight to the claim-record step; +// - otherwise the on-chain isClaimed() call runs as usual (unclaimed and error states must +// keep being re-verified, since only a confirmed claim is permanent). +// +// Once claimed, the destination bridge service's claim record is fetched — skipped when filter +// excludes claimed bridges anyway (see skipsClaimInfo); the entry then simply stays unsettled +// and is fetched normally the next time a filter that needs it is used (see settled) — or, only +// if includeTracking, the tracker's current snapshot is attached for the still-unclaimed tx. A +// failure at any step is logged and left for the next call to retry; it never fails the whole +// GetActivity call, since one bad network should not hide every other bridge found +func (a *ActivityCache) refresh( + ctx context.Context, item *domain.ScannedBridge, existing *domain.ActivityEntry, + includeTracking bool, filter types.ActivityFilter, +) *domain.ActivityEntry { + entry := &domain.ActivityEntry{Bridge: item.Bridge, BridgeNetworkID: item.NetworkID} + if existing != nil { + entry.CreatedAt = existing.CreatedAt + } else { + entry.CreatedAt = a.now() + } + entry.UpdatedAt = a.now() + + if existing != nil && existing.ClaimStatus == types.ClaimStatusClaimed { + entry.ClaimStatus = types.ClaimStatusClaimed + } else { + claimed, err := a.claims.IsClaimed(ctx, item) + if err != nil { + a.logger.Warnf("activity: checking claim state of bridge tx=%s (network=%d, deposit=%d): %v", + item.Bridge.TxHash, item.NetworkID, item.Bridge.DepositCount, err) + entry.ClaimStatus = types.ClaimStatusError + entry.Errors = map[string]string{"claim": err.Error()} + return entry + } + if claimed { + entry.ClaimStatus = types.ClaimStatusClaimed + } else { + entry.ClaimStatus = types.ClaimStatusUnclaimed + } + } + + if entry.ClaimStatus == types.ClaimStatusClaimed { + if skipsClaimInfo(filter) { + return entry + } + claim, err := a.claims.ClaimInfo(ctx, item) + if err != nil { + a.logger.Warnf("activity: fetching claim record of bridge tx=%s: %v", item.Bridge.TxHash, err) + } + entry.Claim = claim + return entry + } + + if includeTracking { + id := domain.TrackingID{NetworkID: item.NetworkID, TxHash: common.HexToHash(string(item.Bridge.TxHash))} + tracking, err := a.supervised.Get(id, true) + if err != nil { + a.logger.Warnf("activity: registering bridge tx=%s with the tracker: %v", item.Bridge.TxHash, err) + } else { + entry.Tracking = tracking + } + } + return entry +} diff --git a/bridgetracker/activity_test.go b/bridgetracker/activity_test.go new file mode 100644 index 000000000..fd45ac345 --- /dev/null +++ b/bridgetracker/activity_test.go @@ -0,0 +1,467 @@ +package bridgetracker + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +var testFromAddress = common.HexToAddress("0x1111111111111111111111111111111111111111") + +// testScannedNetworkID is the network these tests' bridges were scanned from, matching +// testBridge's hardcoded OriginNetwork so most tests need not care about the distinction (see +// TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork for a test that does) +const testScannedNetworkID = uint32(1) + +func testBridge(globalIndex int64) *bridgeservicetypes.BridgeResponse { + return &bridgeservicetypes.BridgeResponse{ + OriginNetwork: 1, + DestinationNetwork: 2, + DepositCount: uint32(globalIndex), + GlobalIndex: big.NewInt(globalIndex), + TxHash: bridgeservicetypes.Hash("0xtx"), + } +} + +// testScannedBridge wraps testBridge with testScannedNetworkID, the network these tests treat +// as "the bridge service that returned it" +func testScannedBridge(globalIndex int64) *domain.ScannedBridge { + return scannedBridge(testBridge(globalIndex), testScannedNetworkID) +} + +func scannedBridge(bridge *bridgeservicetypes.BridgeResponse, networkID uint32) *domain.ScannedBridge { + return &domain.ScannedBridge{Bridge: bridge, NetworkID: networkID} +} + +// fakeActivityScanner is a hand-rolled ActivityBridgeScanner for tests: it returns whichever of +// bridges is not in known, mirroring ActivitySource.BridgesFrom's real contract. calls records +// how many times it was invoked, lastKnown the known argument it was last called with. +type fakeActivityScanner struct { + bridges []*domain.ScannedBridge + err error + warnings []domain.ActivityWarning + calls int + lastKnown map[string]struct{} +} + +func (f *fakeActivityScanner) BridgesFrom( + _ context.Context, _ common.Address, known map[string]struct{}, +) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { + f.calls++ + f.lastKnown = known + if f.err != nil { + return nil, nil, f.err + } + out := make([]*domain.ScannedBridge, 0, len(f.bridges)) + for _, b := range f.bridges { + if _, ok := known[b.Bridge.GlobalIndex.String()]; ok { + continue + } + out = append(out, b) + } + return out, f.warnings, nil +} + +// fakeActivityClaims is a hand-rolled ActivityClaimChecker for tests: isClaimed/claimInfo are +// consulted in FIFO order per call, one entry per expected IsClaimed/ClaimInfo invocation, so a +// test can assert exactly how many times each was called (and fail loudly if called more). +// isClaimedErrs, if non-nil, is consulted alongside isClaimed: a non-nil entry makes that call +// fail instead of returning the paired isClaimed value. lastIsClaimedNetworkID records the +// NetworkID of the last ScannedBridge IsClaimed was called with. +type fakeActivityClaims struct { + isClaimed []bool + isClaimedErrs []error + isClaimedCalls int + lastIsClaimedNetworkID uint32 + claimInfo []*bridgeservicetypes.ClaimResponse + claimInfoCalls int +} + +func (f *fakeActivityClaims) IsClaimed(_ context.Context, bridge *domain.ScannedBridge) (bool, error) { + f.lastIsClaimedNetworkID = bridge.NetworkID + i := f.isClaimedCalls + f.isClaimedCalls++ + if i < len(f.isClaimedErrs) && f.isClaimedErrs[i] != nil { + return false, f.isClaimedErrs[i] + } + return f.isClaimed[i], nil +} + +func (f *fakeActivityClaims) ClaimInfo( + context.Context, *domain.ScannedBridge, +) (*bridgeservicetypes.ClaimResponse, error) { + claim := f.claimInfo[f.claimInfoCalls] + f.claimInfoCalls++ + return claim, nil +} + +// newTestActivityCache builds an ActivityCache with a one-hour idle timeout, long enough that +// no test below evicts anything by accident; tests exercising eviction build their own directly. +func newTestActivityCache(scanner ActivityBridgeScanner, claims ActivityClaimChecker) *ActivityCache { + supervised := NewMemoryRegistry(10) + return NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test"), time.Hour) +} + +// TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall verifies an unclaimed bridge's claim +// state is re-verified on every GetActivity call, and that includeTracking=false never +// registers it with the tracker. +func TestActivityCache_UnclaimedBridgeIsRecheckedEveryCall(t *testing.T) { + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + + for range 2 { + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + require.Nil(t, entries[0].Tracking) + } + require.Equal(t, 2, claims.isClaimedCalls) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_IncludeTrackingRegistersUnclaimedBridge verifies includeTracking=true +// registers a still-unclaimed bridge with the supervised store (register-only) and reports its +// snapshot, keyed by the scanned network — not Bridge.OriginNetwork. +func TestActivityCache_IncludeTrackingRegistersUnclaimedBridge(t *testing.T) { + bridge := testBridge(1) + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}} + claims := &fakeActivityClaims{isClaimed: []bool{false}} + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.NotNil(t, entries[0].Tracking) + + wantID := domain.TrackingID{NetworkID: testScannedNetworkID, TxHash: common.HexToHash(string(bridge.TxHash))} + require.Equal(t, wantID, entries[0].Tracking.ID()) +} + +// TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked verifies a bridge that is claimed +// with its claim record already fetched is never rechecked on a later call. +func TestActivityCache_ClaimedAndIndexedBridgeIsNeverRechecked(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + // only one IsClaimed/ClaimInfo entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) + + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls) + require.Equal(t, 1, claims.claimInfoCalls) + require.Equal(t, 2, scanner.calls) // BridgesFrom is still called every time to find new bridges +} + +// TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried verifies a bridge reported as claimed +// on-chain, but whose claim record the destination bridge service has not indexed yet (ClaimInfo +// returns nil), has its claim record retried on the next call — without asking isClaimed() again, +// since a confirmed claim never reverts (see ActivityCache.refresh). +func TestActivityCache_ClaimedButNotYetIndexedBridgeIsRetried(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + // a single isClaimed entry: a second consultation would panic on out-of-range, proving it is + // never asked again once confirmed claimed + claims := &fakeActivityClaims{ + isClaimed: []bool{true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{nil, claim}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls, "isClaimed must not be asked again once confirmed claimed") + require.Equal(t, 2, claims.claimInfoCalls) +} + +// TestActivityCache_ScannerErrorFailsTheCall verifies a scanner failure fails GetActivity +// entirely. +func TestActivityCache_ScannerErrorFailsTheCall(t *testing.T) { + wantErr := errors.New("bridge service unreachable") + scanner := &fakeActivityScanner{err: wantErr} + cache := newTestActivityCache(scanner, &fakeActivityClaims{}) + + _, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.ErrorIs(t, err, wantErr) +} + +// TestActivityCache_IsClaimedFailureReportsErrorStatus verifies a failed isClaimed() check +// (e.g. no bridge contract address configured for the destination network) is reported as +// ClaimStatusError — never silently as ClaimStatusUnclaimed — and is retried on the next call +// (unlike a confirmed claim, an error is not permanent). +func TestActivityCache_IsClaimedFailureReportsErrorStatus(t *testing.T) { + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + claims := &fakeActivityClaims{ + isClaimed: []bool{false, false}, + isClaimedErrs: []error{errors.New("no bridge contract address configured for network 2"), nil}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) + require.Nil(t, entries[0].Claim) + require.Equal(t, "no bridge contract address configured for network 2", entries[0].Errors["claim"]) + + // the error state is not settled: it is retried on the next call + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Equal(t, 2, claims.isClaimedCalls) + require.Nil(t, entries[0].Errors, "a successful recheck must not carry over the previous failure") +} + +// TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo verifies +// ActivityFilterPending returns only confirmed-unclaimed bridges — excluding both claimed ones +// and ones whose isClaimed() check errored — and never fetches a claimed bridge's claim record +// (only IsClaimed is consulted, never ClaimInfo). +func TestActivityCache_FilterPendingExcludesClaimedAndErroredAndSkipsClaimInfo(t *testing.T) { + pendingBridge := testScannedBridge(2) + scanner := &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{testScannedBridge(1), pendingBridge, testScannedBridge(3)}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called + isClaimedErrs: []error{nil, nil, errors.New("boom")}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, pendingBridge.Bridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusUnclaimed, entries[0].ClaimStatus) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo verifies ActivityFilterError +// returns only bridges whose isClaimed() check failed, excludes claimed and pending ones, and +// never fetches a claimed bridge's claim record for this filter either. +func TestActivityCache_FilterErrorReturnsOnlyErroredAndSkipsClaimInfo(t *testing.T) { + erroredBridge := testScannedBridge(3) + wantErr := errors.New("boom") + scanner := &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{testScannedBridge(1), testScannedBridge(2), erroredBridge}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, // no claimInfo entries: ClaimInfo must not be called + isClaimedErrs: []error{nil, nil, wantErr}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterError) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, erroredBridge.Bridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusError, entries[0].ClaimStatus) + require.Equal(t, wantErr.Error(), entries[0].Errors["claim"]) + require.Equal(t, 0, claims.claimInfoCalls) +} + +// TestActivityCache_FilterClaimedExcludesPending verifies ActivityFilterClaimed returns only +// confirmed-claimed bridges, excluding both unclaimed ones and ones whose check errored. +func TestActivityCache_FilterClaimedExcludesPending(t *testing.T) { + claimedBridge := testScannedBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + + scanner := &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{claimedBridge, testScannedBridge(2), testScannedBridge(3)}, + } + claims := &fakeActivityClaims{ + isClaimed: []bool{true, false, false}, + isClaimedErrs: []error{nil, nil, errors.New("boom")}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim}, + } + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterClaimed) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, claimedBridge.Bridge, entries[0].Bridge) + require.Equal(t, types.ClaimStatusClaimed, entries[0].ClaimStatus) + require.Equal(t, claim, entries[0].Claim) +} + +// TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed verifies a bridge left +// unsettled by ActivityFilterPending (claimed, but its claim record deliberately not fetched) +// gets its claim record fetched normally the next time ActivityFilterAll is used — without +// isClaimed() being asked again, since it was already confirmed claimed. +func TestActivityCache_PendingBridgeSkippedThenFetchedOnceFilterAllIsUsed(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + // a single isClaimed entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + + // filterBridges=pending: claimed, but its claim record is deliberately not fetched, and the + // bridge itself is excluded from this result + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterPending) + require.NoError(t, err) + require.Empty(t, entries) + require.Equal(t, 0, claims.claimInfoCalls) + + // filterBridges=all: the still-unsettled entry is rechecked — its claim record is fetched, + // but isClaimed() is not asked again + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls) + require.Equal(t, 1, claims.claimInfoCalls) +} + +// TestActivityCache_ScannerReceivesGrowingKnownSet verifies the scanner is called with an empty +// known set the first time (nothing cached yet), and with the previously found bridge's key once +// it has been cached. +func TestActivityCache_ScannerReceivesGrowingKnownSet(t *testing.T) { + bridge := testScannedBridge(1) + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{bridge}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + + _, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Empty(t, scanner.lastKnown, "nothing cached yet on the first call") + + _, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Contains(t, scanner.lastKnown, bridge.Bridge.GlobalIndex.String()) +} + +// TestActivityCache_IdleAddressIsForgotten verifies an address untouched for longer than +// idleTimeout is forgotten entirely: proven indirectly by observing isClaimed() being asked +// again for a bridge that had already settled — which would not happen if its cached state had +// survived. +func TestActivityCache_IdleAddressIsForgotten(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + claims := &fakeActivityClaims{ + isClaimed: []bool{true, true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim, claim}, + } + + supervised := NewMemoryRegistry(10) + cache := NewActivityCache(scanner, claims, supervised, log.WithFields("module", "activity_test"), time.Minute) + now := time.Now() + cache.now = func() time.Time { return now } + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 1, claims.isClaimedCalls, "settled after the first call") + + now = now.Add(2 * time.Minute) // past idleTimeout + + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.Equal(t, claim, entries[0].Claim) + require.Equal(t, 2, claims.isClaimedCalls, "the address was forgotten, so isClaimed is asked again from scratch") +} + +// TestActivityCache_TimestampsTrackCreationAndLastUpdate verifies CreatedAt is stamped once and +// never changes, while UpdatedAt advances on every recheck of a still-unsettled entry. +func TestActivityCache_TimestampsTrackCreationAndLastUpdate(t *testing.T) { + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + claims := &fakeActivityClaims{isClaimed: []bool{false, false}} + + cache := newTestActivityCache(scanner, claims) + t1 := time.Now() + cache.now = func() time.Time { return t1 } + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].CreatedAt.Equal(t1)) + require.True(t, entries[0].UpdatedAt.Equal(t1)) + + t2 := t1.Add(time.Minute) + cache.now = func() time.Time { return t2 } + + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].CreatedAt.Equal(t1), "creation time must not change") + require.True(t, entries[0].UpdatedAt.Equal(t2), "update time must advance on every recheck") +} + +// TestActivityCache_TimestampsFreezeOnceSettled verifies UpdatedAt stops advancing once a bridge +// settles (claimed with its claim record fetched), since a settled entry is never refreshed again. +func TestActivityCache_TimestampsFreezeOnceSettled(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{testScannedBridge(1)}} + // a single isClaimed/claimInfo entry: a second consultation would panic on out-of-range + claims := &fakeActivityClaims{isClaimed: []bool{true}, claimInfo: []*bridgeservicetypes.ClaimResponse{claim}} + + cache := newTestActivityCache(scanner, claims) + t1 := time.Now() + cache.now = func() time.Time { return t1 } + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].UpdatedAt.Equal(t1)) + + cache.now = func() time.Time { return t1.Add(time.Minute) } + + entries, _, err = cache.GetActivity(t.Context(), testFromAddress, false, types.ActivityFilterAll) + require.NoError(t, err) + require.True(t, entries[0].UpdatedAt.Equal(t1), "a settled entry is never refreshed again") +} + +// TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork verifies +// ActivityEntry.BridgeNetworkID reflects the network the bridge was scanned from (what the +// caller actually asked bridge_service for), not Bridge.OriginNetwork — which is the origin of +// the bridged ASSET and can differ when re-bridging an asset from a third network (see +// domain.ScannedBridge). It also verifies isClaimed()'s on-chain sourceBridgeNetwork argument and +// the tracker's TrackingID both use the scanned network, never Bridge.OriginNetwork. +func TestActivityCache_BridgeNetworkIDUsesScannedNetworkNotBridgeOriginNetwork(t *testing.T) { + // OriginNetwork (99) is the asset-origin decoy, deliberately different from the network this + // bridge was actually scanned from (5) + const scannedNetworkID = uint32(7) + bridge := testBridge(1) + bridge.OriginNetwork = 99 + scanner := &fakeActivityScanner{bridges: []*domain.ScannedBridge{scannedBridge(bridge, scannedNetworkID)}} + claims := &fakeActivityClaims{isClaimed: []bool{false}} + + cache := newTestActivityCache(scanner, claims) + + entries, _, err := cache.GetActivity(t.Context(), testFromAddress, true, types.ActivityFilterAll) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, scannedNetworkID, entries[0].BridgeNetworkID) + require.Equal(t, scannedNetworkID, claims.lastIsClaimedNetworkID) + require.Equal(t, scannedNetworkID, entries[0].Tracking.ID().NetworkID) +} diff --git a/bridgetracker/api/activity_command.go b/bridgetracker/api/activity_command.go new file mode 100644 index 000000000..c89287fb8 --- /dev/null +++ b/bridgetracker/api/activity_command.go @@ -0,0 +1,176 @@ +package api + +import ( + "net/http" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +// compile-time check: activityCommand fulfils the command interface +var _ command = (*activityCommand)(nil) + +// activityCommand answers GET /activity/from/{from_address}: it scans every configured bridge +// service for bridges sent by from_address and reports their claim (and, optionally, tracking) +// state +type activityCommand struct { + querier domain.ActivityQuerier +} + +// ActivityItem is one bridge found for the requested from_address. Bridge and Claim are the +// bridge service's own response shapes (see bridgeservice/types), reported exactly as-is +// rather than remapped into a bespoke model; BridgeNetworkID/ClaimNetworkID sit alongside them +// (not inside) since the caller needs to know which bridge service produced each one +type ActivityItem struct { + // Bridge is the raw bridge event, exactly as returned by the origin network's bridge + // service, unmodified + Bridge *bridgeservicetypes.BridgeResponse `json:"bridge"` + // BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily + // Bridge.OriginNetwork, which is the origin network of the bridged asset and can differ for + // a re-bridged asset (see domain.ScannedBridge) + BridgeNetworkID uint32 `json:"bridge_network_id"` + // Claimed is the tri-state result of the destination bridge contract's isClaimed() call + // the last time it was checked: "false" (confirmed unclaimed), "true" (claimed), or + // "error" if the check itself failed (e.g. no bridge contract address configured for the + // destination network) — callers must not read "error" as "false" + Claimed string `json:"claimed"` + // ClaimNetworkID is the network whose bridge service reported Claim (the bridge's + // destination network); only present alongside Claim + ClaimNetworkID uint32 `json:"claim_network_id,omitempty"` + // Claim is the raw claim record, exactly as returned by the destination network's bridge + // service, unmodified, once Claimed is true and the indexer has recorded it + Claim *bridgeservicetypes.ClaimResponse `json:"claim,omitempty"` + // CreationTimestamp is when this bridge was first cached by the activity endpoint (unix + // seconds); it never changes after that + CreationTimestamp uint64 `json:"creation_timestamp"` + // LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix + // seconds), whether or not anything about it actually changed. Stops advancing once the + // bridge is claimed with its claim record fetched — nothing left to recheck + LastUpdatedTimestamp uint64 `json:"last_updated_timestamp"` + // Tracking is the bridge tracker's current status for this bridge; only present when the + // request set includeTracking=true and the bridge is still unclaimed + Tracking *TrackingData `json:"tracking,omitempty"` + // Errors holds the message of whatever check failed the last time this item was refreshed, + // keyed by which check it was — currently only "claim", present when Claimed is "error" + Errors map[string]string `json:"errors,omitempty"` +} + +// ActivityWarningItem reports one network's bridge service that could not be scanned while +// building this response — Bridges is still whatever every other network reported, just +// possibly incomplete for the networks listed here +type ActivityWarningItem struct { + // NetworkID is the network whose bridge service could not be scanned + NetworkID uint32 `json:"network_id"` + // Message is the error encountered while scanning NetworkID + Message string `json:"message"` +} + +// ActivityResponse is the body of GET /activity/from/{from_address} +type ActivityResponse struct { + // FromAddress is the address requested + FromAddress common.Address `json:"from_address"` + // Bridges holds every bridge found for FromAddress across every configured bridge service + Bridges []ActivityItem `json:"bridges"` + // Warnings lists every network whose bridge service could not be scanned this call; absent + // when every configured network was scanned successfully. Bridges may be incomplete for the + // networks listed here, but is still valid for every other network + Warnings []ActivityWarningItem `json:"warnings,omitempty"` +} + +// Execute implements command: it scans every configured bridge service for bridges sent by the +// from_address path parameter, and reports each one's claim state. Passing +// ?includeTracking=true additionally registers every still-unclaimed bridge found with the +// bridge tracker (same effect as calling GetTxStatus for it) and includes its current tracking +// snapshot. ?filterBridges=claimed|pending|error restricts the result to only bridges with that +// claim state (default "all"); a claimed bridge excluded by "pending"/"error" never has its +// claim record fetched, so switching back to "all"/"claimed" later fetches it then. A network +// whose bridge service could not be scanned never fails the request: it is skipped and reported +// in the "warnings" field instead, so Bridges is still whatever every other network reported. +// 200 OK unless: invalid from_address/filterBridges (ErrorData/400), or the scan itself failed +// (ErrorData/500) +// +// @Summary Get bridge activity by sender address +// @Description Scans every bridge service the tracker knows about for bridges sent by +// @Description from_address and reports each one's claim state, exactly as the bridge service +// @Description reported it. Results are cached: a bridge already known to be claimed, with its +// @Description claim record already fetched, is not rechecked on a later call. Passing +// @Description includeTracking=true additionally registers every still-unclaimed bridge with +// @Description the bridge tracker and includes its current tracking snapshot. filterBridges +// @Description restricts the result to bridges with only that claim state (claimed / still +// @Description pending / errored while checking). A network whose bridge service could not be +// @Description scanned is skipped and reported in the "warnings" field instead of failing the +// @Description whole request. +// @Tags bridge-tracker +// @Produce json +// @Param from_address path string true "Address that sent the bridges to look up" +// @Param includeTracking query bool false "Register still-unclaimed bridges with the tracker" +// @Param filterBridges query string false "Which bridges to return" Enums(all, claimed, pending, error) default(all) +// @Success 200 {object} ActivityResponse +// @Failure 400 {object} types.ErrorData "Invalid from_address or filterBridges" +// @Failure 500 {object} types.ErrorData "Scanning the configured bridge services failed" +// @Router /activity/from/{from_address} [get] +func (cmd *activityCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) { + addrStr := c.Param(fromAddressParam) + if !common.IsHexAddress(addrStr) { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: "invalid from_address parameter"} + } + fromAddress := common.HexToAddress(addrStr) + includeTracking := c.Query(includeTrackingQueryParam) == "true" + + filter, err := types.ParseActivityFilter(c.Query(filterBridgesQueryParam)) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: err.Error()} + } + + entries, warnings, err := cmd.querier.GetActivity(c.Request.Context(), fromAddress, includeTracking, filter) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + + return http.StatusOK, ActivityResponse{ + FromAddress: fromAddress, + Bridges: newActivityItems(entries), + Warnings: newActivityWarningItems(warnings), + }, nil +} + +// newActivityItems builds the wire ActivityItems from the resolved activity entries +func newActivityItems(entries []*domain.ActivityEntry) []ActivityItem { + items := make([]ActivityItem, 0, len(entries)) + for _, e := range entries { + item := ActivityItem{ + Bridge: e.Bridge, + BridgeNetworkID: e.BridgeNetworkID, + Claimed: e.ClaimStatus.String(), + Errors: e.Errors, + CreationTimestamp: uint64(e.CreatedAt.Unix()), + LastUpdatedTimestamp: uint64(e.UpdatedAt.Unix()), + } + if e.Claim != nil { + item.Claim = e.Claim + item.ClaimNetworkID = e.Bridge.DestinationNetwork + } + if e.Tracking != nil { + tracking := trackingDataFrom(e.Tracking) + item.Tracking = &tracking + } + items = append(items, item) + } + return items +} + +// newActivityWarningItems builds the wire ActivityWarningItems from the scan's warnings; nil in +// (every network scanned fine) yields nil out, so Warnings is omitted from the response entirely +func newActivityWarningItems(warnings []domain.ActivityWarning) []ActivityWarningItem { + if len(warnings) == 0 { + return nil + } + items := make([]ActivityWarningItem, 0, len(warnings)) + for _, w := range warnings { + items = append(items, ActivityWarningItem{NetworkID: w.NetworkID, Message: w.Message}) + } + return items +} diff --git a/bridgetracker/api/api.go b/bridgetracker/api/api.go index 9828ead4d..0b1d5bee1 100644 --- a/bridgetracker/api/api.go +++ b/bridgetracker/api/api.go @@ -33,8 +33,18 @@ const ( // TrackerV1Prefix is the url prefix for the bridge tracker service TrackerV1Prefix = "/tracker/v1" - txHashParam = "tx_hash" - networkIDParam = "network_id" + txHashParam = "tx_hash" + networkIDParam = "network_id" + fromAddressParam = "from_address" + + // includeTrackingQueryParam, when set to "true", makes the activity endpoint additionally + // register every still-unclaimed bridge it finds with the bridge tracker (see + // activityCommand.Execute) + includeTrackingQueryParam = "includeTracking" + + // filterBridgesQueryParam selects which bridges the activity endpoint returns: "all" + // (default), "claimed" or "pending" (see types.ActivityFilter) + filterBridgesQueryParam = "filterBridges" decimalBase = 10 uint32BitSize = 32 @@ -48,21 +58,33 @@ type API struct { getTxStatusCmd *getTxStatusCommand healthCmd *healthCommand wsHandler *wsHandler + // activityCmd serves GET /activity/from/{from_address}; nil (when NewAPI is given a nil + // activity) leaves the route unregistered entirely — see RegisterRoutes + activityCmd *activityCommand + // bridgeAddressCmd serves GET /bridge-address and GET /bridge-address/{network_id}; nil + // (when NewAPI is given a nil bridgeAddressResolver) leaves both routes unregistered + // entirely — see RegisterRoutes + bridgeAddressCmd *bridgeAddressCommand } // NewAPI returns the tracker HTTP service serving the given supervised registry. // registerResolveTimeout is how long GetTxStatus waits, the first time a tx is registered, for // the tracking engine's immediate resolution attempt to produce an update before answering (see // getTxStatusCommand); <= 0 disables the wait. cors governs which origins may open the -// WebSocket endpoint (see wsHandler) +// WebSocket endpoint (see wsHandler). activity may be nil, in which case the +// GET /activity/from/{from_address} endpoint is not registered at all (see RegisterRoutes). +// bridgeAddressResolver may be nil, in which case neither GET /bridge-address nor +// GET /bridge-address/{network_id} is registered at all (see RegisterRoutes) func NewAPI( logger aggkitcommon.Logger, configSHA1 string, supervised domain.SupervisedRegistry, + activity domain.ActivityQuerier, + bridgeAddressResolver domain.BridgeAddressResolver, registerResolveTimeout time.Duration, cors aggkitcommon.CORSConfig, ) *API { - return &API{ + api := &API{ getTxStatusCmd: &getTxStatusCommand{supervised: supervised, resolveTimeout: registerResolveTimeout}, healthCmd: &healthCommand{ // instanceID is a UUID generated at startup, exposed by the health endpoint to @@ -72,6 +94,13 @@ func NewAPI( }, wsHandler: newWSHandler(logger, supervised, cors), } + if activity != nil { + api.activityCmd = &activityCommand{querier: activity} + } + if bridgeAddressResolver != nil { + api.bridgeAddressCmd = &bridgeAddressCommand{resolver: bridgeAddressResolver} + } + return api } // RegisterRoutes registers all bridge tracker routes on router. Route-level documentation @@ -85,6 +114,16 @@ func (a *API) RegisterRoutes(router gin.IRouter) { trackerGroup.GET("/network/:"+networkIDParam+"/tx/:"+txHashParam, func(c *gin.Context) { runCommand(c, a.getTxStatusCmd) }) trackerGroup.GET("/network/:"+networkIDParam+"/tx/:"+txHashParam+"/ws", a.wsHandler.TxStatusWSHandler) + if a.activityCmd != nil { + trackerGroup.GET("/activity/from/:"+fromAddressParam, + func(c *gin.Context) { runCommand(c, a.activityCmd) }) + } + if a.bridgeAddressCmd != nil { + trackerGroup.GET("/bridge-address", + func(c *gin.Context) { runCommand(c, a.bridgeAddressCmd) }) + trackerGroup.GET("/bridge-address/:"+networkIDParam, + func(c *gin.Context) { runCommand(c, a.bridgeAddressCmd) }) + } // Swagger docs endpoint trackerGroup.GET("/swagger/*any", ginswagger.WrapHandler(swaggerfiles.Handler)) diff --git a/bridgetracker/api/bridge_address_command.go b/bridgetracker/api/bridge_address_command.go new file mode 100644 index 000000000..a3ec717cb --- /dev/null +++ b/bridgetracker/api/bridge_address_command.go @@ -0,0 +1,88 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/agglayer/aggkit/bridgetracker/domain" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +// compile-time check: bridgeAddressCommand fulfils the command interface +var _ command = (*bridgeAddressCommand)(nil) + +// bridgeAddressCommand answers GET /bridge-address and GET /bridge-address/{network_id}: with +// no network_id it reports the bridge contract address of every network the resolver currently +// knows about, and with one it reports only that network's +type bridgeAddressCommand struct { + resolver domain.BridgeAddressResolver +} + +// BridgeAddressItem is the bridge contract address of one network +type BridgeAddressItem struct { + // NetworkID is the network BridgeAddress belongs to + NetworkID uint32 `json:"network_id"` + // BridgeAddress is the bridge contract address on NetworkID + BridgeAddress common.Address `json:"bridge_address"` +} + +// BridgeAddressResponse is the body of GET /bridge-address +type BridgeAddressResponse struct { + // Bridges holds the bridge contract address of every network currently known + Bridges []BridgeAddressItem `json:"bridges"` +} + +// Execute implements command: with no network_id path parameter it resolves the bridge +// contract address of every network the resolver currently knows about (BridgeAddressResponse); +// with one it resolves only that network's (BridgeAddressItem). 200 OK unless: invalid +// network_id (ErrorData/400), or resolving the address failed (ErrorData/500) +// +// @Summary Get the bridge contract address of one network, or every network +// @Description With no network_id, reports the bridge contract address of every network the +// @Description tracker currently knows about (via the bridge service finder). With network_id, +// @Description reports only that network's. +// @Tags bridge-tracker +// @Produce json +// @Param network_id path int false "Network to look up; omit to get every network" +// @Success 200 {object} BridgeAddressResponse "Body when network_id is omitted" +// @Success 200 {object} BridgeAddressItem "Body when network_id is set" +// @Failure 400 {object} types.ErrorData "Invalid network_id" +// @Failure 500 {object} types.ErrorData "Resolving the bridge contract address failed" +// @Router /bridge-address [get] +// @Router /bridge-address/{network_id} [get] +func (cmd *bridgeAddressCommand) Execute(c *gin.Context) (int, any, *types.ErrorData) { + networkIDStr := c.Param(networkIDParam) + if networkIDStr == "" { + return cmd.executeAll(c) + } + + networkID, err := strconv.ParseUint(networkIDStr, decimalBase, uint32BitSize) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusBadRequest, Message: "invalid network_id parameter"} + } + + addr, err := cmd.resolver.BridgeAddress(c.Request.Context(), uint32(networkID)) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + + return http.StatusOK, BridgeAddressItem{NetworkID: uint32(networkID), BridgeAddress: addr}, nil +} + +// executeAll resolves the bridge contract address of every network cmd.resolver currently +// knows about (see domain.BridgeAddressResolver.NetworkIDs) +func (cmd *bridgeAddressCommand) executeAll(c *gin.Context) (int, any, *types.ErrorData) { + networkIDs := cmd.resolver.NetworkIDs() + items := make([]BridgeAddressItem, 0, len(networkIDs)) + for _, networkID := range networkIDs { + addr, err := cmd.resolver.BridgeAddress(c.Request.Context(), networkID) + if err != nil { + return 0, nil, &types.ErrorData{Code: http.StatusInternalServerError, Message: err.Error()} + } + items = append(items, BridgeAddressItem{NetworkID: networkID, BridgeAddress: addr}) + } + + return http.StatusOK, BridgeAddressResponse{Bridges: items}, nil +} diff --git a/bridgetracker/api/bridge_step_path.go b/bridgetracker/api/bridge_step_path.go index c857d3164..1aed031d2 100644 --- a/bridgetracker/api/bridge_step_path.go +++ b/bridgetracker/api/bridge_step_path.go @@ -23,7 +23,7 @@ type BridgeStepPath struct { // (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), // *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData // (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - // *types.ClaimResult (StepWaitingClaim). nil until + // *types.ClaimResult (StepClaimed). nil until // the step produces one, and for steps that never do. Most steps only set this once Done, // but StepCertificatePending (Status still InProgress) may already carry the certificate's // current, not yet settled, status — see domain.ErrCertificateNotSettled diff --git a/bridgetracker/api/bridge_step_path_test.go b/bridgetracker/api/bridge_step_path_test.go index db25d1498..69f7966fc 100644 --- a/bridgetracker/api/bridge_step_path_test.go +++ b/bridgetracker/api/bridge_step_path_test.go @@ -135,20 +135,47 @@ func TestBridgeStepPathResultMarshalJSON(t *testing.T) { expected: `{"certificate_id":"0x0000000000000000000000000000000000000000000000000000000000000001","status":4,"status_string":"Settled"}`, }, { - name: "claim result", - result: &types.ClaimResult{ClaimTx: common.HexToHash("0x0c"), BlockNumber: 300}, - expected: `{"claim_tx":"0x000000000000000000000000000000000000000000000000000000000000000c","block_number":300}`, + name: "certificate data result, settled with its L1 block known", + result: &types.CertificateData{ + CertificateID: common.HexToHash("0x01"), + Status: agglayertypes.Settled, + BlockNumber: func() *uint64 { n := uint64(400); return &n }(), + BlockTimestamp: func() *uint64 { ts := uint64(1700000400); return &ts }(), + }, + expected: `{ + "certificate_id":"0x0000000000000000000000000000000000000000000000000000000000000001", + "status":4, + "status_string":"Settled", + "block_number":400, + "block_timestamp":1700000400 + }`, + }, + { + name: "claim result", + result: &types.ClaimResult{ + ClaimTx: common.HexToHash("0x0c"), BlockNumber: 300, BlockTimestamp: 1700000000, + }, + expected: `{"claim_tx":"0x000000000000000000000000000000000000000000000000000000000000000c",` + + `"block_number":300,"block_timestamp":1700000000}`, }, { name: "L1 settled GER result", result: &types.L1SettledGERResult{ - TxHash: common.HexToHash("0x0d"), BlockNumber: 400, GER: common.HexToHash("0x0e"), + TxHash: common.HexToHash("0x0d"), SettlementBlockNumber: 400, + SettlementBlockTimestamp: 1700000000, SettlementLogIndex: 1, + GER: common.HexToHash("0x0e"), GERBlockNumber: 400, + GERBlockTimestamp: 1700000000, GERLogIndex: 2, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, expected: `{ "tx_hash":"0x000000000000000000000000000000000000000000000000000000000000000d", - "block_number":400, + "settlement_block_number":400, + "settlement_block_timestamp":1700000000, + "settlement_log_index":1, "ger":"0x000000000000000000000000000000000000000000000000000000000000000e", + "ger_block_number":400, + "ger_block_timestamp":1700000000, + "ger_log_index":2, "has_verify_batches_trusted_aggregator":true, "has_update_l1_info_tree":true, "has_update_l1_info_tree_v2":false diff --git a/bridgetracker/api/docs/docs.go b/bridgetracker/api/docs/docs.go index 66c3c5faf..aeaa20fed 100644 --- a/bridgetracker/api/docs/docs.go +++ b/bridgetracker/api/docs/docs.go @@ -22,6 +22,138 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address or filterBridges", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -127,6 +259,129 @@ const docTemplate = `{ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" + } + } + }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -213,7 +468,7 @@ const docTemplate = `{ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -302,14 +557,6 @@ const docTemplate = `{ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -334,14 +581,6 @@ const docTemplate = `{ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", @@ -352,6 +591,202 @@ const docTemplate = `{ } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/bridgetracker/api/docs/swagger.json b/bridgetracker/api/docs/swagger.json index 9d9ec95c0..015001b08 100644 --- a/bridgetracker/api/docs/swagger.json +++ b/bridgetracker/api/docs/swagger.json @@ -15,6 +15,138 @@ }, "basePath": "/tracker/v1", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address or filterBridges", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -120,6 +252,129 @@ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" + } + } + }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -206,7 +461,7 @@ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -295,14 +550,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -327,14 +574,6 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", @@ -345,6 +584,202 @@ } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/bridgetracker/api/docs/swagger.yaml b/bridgetracker/api/docs/swagger.yaml index 90fb991ec..01649eb95 100644 --- a/bridgetracker/api/docs/swagger.yaml +++ b/bridgetracker/api/docs/swagger.yaml @@ -1,5 +1,113 @@ basePath: /tracker/v1 definitions: + api.ActivityItem: + properties: + bridge: + allOf: + - $ref: '#/definitions/types.BridgeResponse' + description: |- + Bridge is the raw bridge event, exactly as returned by the origin network's bridge + service, unmodified + bridge_network_id: + description: |- + BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily + Bridge.OriginNetwork, which is the origin network of the bridged asset and can differ for + a re-bridged asset (see domain.ScannedBridge) + type: integer + claim: + allOf: + - $ref: '#/definitions/types.ClaimResponse' + description: |- + Claim is the raw claim record, exactly as returned by the destination network's bridge + service, unmodified, once Claimed is true and the indexer has recorded it + claim_network_id: + description: |- + ClaimNetworkID is the network whose bridge service reported Claim (the bridge's + destination network); only present alongside Claim + type: integer + claimed: + description: |- + Claimed is the tri-state result of the destination bridge contract's isClaimed() call + the last time it was checked: "false" (confirmed unclaimed), "true" (claimed), or + "error" if the check itself failed (e.g. no bridge contract address configured for the + destination network) — callers must not read "error" as "false" + type: string + creation_timestamp: + description: |- + CreationTimestamp is when this bridge was first cached by the activity endpoint (unix + seconds); it never changes after that + type: integer + errors: + additionalProperties: + type: string + description: |- + Errors holds the message of whatever check failed the last time this item was refreshed, + keyed by which check it was — currently only "claim", present when Claimed is "error" + type: object + last_updated_timestamp: + description: |- + LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix + seconds), whether or not anything about it actually changed. Stops advancing once the + bridge is claimed with its claim record fetched — nothing left to recheck + type: integer + tracking: + allOf: + - $ref: '#/definitions/api.TrackingData' + description: |- + Tracking is the bridge tracker's current status for this bridge; only present when the + request set includeTracking=true and the bridge is still unclaimed + type: object + api.ActivityResponse: + properties: + bridges: + description: Bridges holds every bridge found for FromAddress across every + configured bridge service + items: + $ref: '#/definitions/api.ActivityItem' + type: array + from_address: + description: FromAddress is the address requested + items: + type: integer + type: array + warnings: + description: |- + Warnings lists every network whose bridge service could not be scanned this call; absent + when every configured network was scanned successfully. Bridges may be incomplete for the + networks listed here, but is still valid for every other network + items: + $ref: '#/definitions/api.ActivityWarningItem' + type: array + type: object + api.ActivityWarningItem: + properties: + message: + description: Message is the error encountered while scanning NetworkID + type: string + network_id: + description: NetworkID is the network whose bridge service could not be scanned + type: integer + type: object + api.BridgeAddressItem: + properties: + bridge_address: + description: BridgeAddress is the bridge contract address on NetworkID + items: + type: integer + type: array + network_id: + description: NetworkID is the network BridgeAddress belongs to + type: integer + type: object + api.BridgeAddressResponse: + properties: + bridges: + description: Bridges holds the bridge contract address of every network currently + known + items: + $ref: '#/definitions/api.BridgeAddressItem' + type: array + type: object api.BridgeEventData: properties: amount: @@ -76,7 +184,7 @@ definitions: (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - *types.ClaimResult (StepWaitingClaim). nil until + *types.ClaimResult (StepClaimed). nil until the step produces one, and for steps that never do. Most steps only set this once Done, but StepCertificatePending (Status still InProgress) may already carry the certificate's current, not yet settled, status — see domain.ErrCertificateNotSettled @@ -160,14 +268,6 @@ definitions: - 1000000000 - 60000000000 - 3600000000000 - - -9223372036854775808 - - 9223372036854775807 - - 1 - - 1000 - - 1000000 - - 1000000000 - - 60000000000 - - 3600000000000 - 1 - 1000 - 1000000 @@ -193,14 +293,6 @@ definitions: - Second - Minute - Hour - - minDuration - - maxDuration - - Nanosecond - - Microsecond - - Millisecond - - Second - - Minute - - Hour - Nanosecond - Microsecond - Millisecond @@ -208,6 +300,165 @@ definitions: - Minute - Hour type: object + types.BridgeResponse: + description: Detailed information about a bridge event + properties: + amount: + description: Amount of tokens being bridged + example: "1000000000000000000" + type: string + block_num: + description: Block number where the bridge event was recorded + example: 1234 + type: integer + block_pos: + description: Position of the bridge event within the block + example: 1 + type: integer + block_timestamp: + description: Timestamp of the block containing the bridge event + example: 1684500000 + type: integer + bridge_hash: + description: Unique hash representing the bridge event, often used as an identifier + example: 0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd + type: string + deposit_count: + description: Count of total deposits processed so far for the given token/address + example: 10 + type: integer + destination_address: + description: Address of the token receiver on the destination network + example: 0xdef4567890abcdef1234567890abcdef12345678 + type: string + destination_network: + description: ID of the network where the bridge transaction is destined + example: 42161 + type: integer + from_address: + description: |- + Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA. + May be null if bridge was synced with SyncFromInBridges=false + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + global_index: + description: Global index of the bridge event (consisted of mainnet flag, + rollup id and deposit count) + example: "4294967296" + type: string + leaf_type: + description: Type of leaf (bridge event type) used in the tree structure + example: 1 + type: integer + metadata: + description: Optional metadata attached to the bridge event + example: "0xdeadbeef" + type: string + origin_address: + description: Address of the token sender on the origin network + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + origin_network: + description: ID of the network where the bridge transaction originated + example: 10 + type: integer + to_address: + description: Address of the contract that was the recipient of the transaction. + This may differ from the bridge contract address. + example: 0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0 + type: string + tx_hash: + description: Hash of the transaction that included the bridge event + example: 0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + type: string + txn_sender: + description: Address of the transaction sender who initiated the bridge transaction + example: 0xabc1234567890abcdef1234567890abcdef12345 + type: string + type: object + types.ClaimResponse: + description: Detailed information about a claim event + properties: + amount: + description: Amount claimed + example: "1000000000000000000" + type: string + block_num: + description: Block number where the claim was processed + example: 1234 + type: integer + block_timestamp: + description: Timestamp of the block containing the claim + example: 1684500000 + type: integer + destination_address: + description: Address receiving the claim on the destination network + example: 0xdef4567890abcdef1234567890abcdef12345678 + type: string + destination_network: + description: Destination network ID where the claim was processed + example: 42161 + type: integer + from_address: + description: Address from which the claim originated + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + global_exit_root: + description: Global exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + global_index: + description: Global index of the claim + example: "1000000000000000000" + type: string + is_message: + description: IsMessage indicates whether this is a message claim (leaf type + 1) rather than an asset claim (leaf type 0) + example: false + type: boolean + mainnet_exit_root: + description: Mainnet exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + metadata: + description: Metadata associated with the claim + example: "0xdeadbeef" + type: string + origin_address: + description: Address initiating the claim on the origin network + example: 0xabc1234567890abcdef1234567890abcdef1234 + type: string + origin_network: + description: Origin network ID where the claim was initiated + example: 10 + type: integer + proof_local_exit_root: + description: Proof local exit root associated with the claim (optional) + example: + - '[0x1' + - ' 0x2' + - ' 0x3...]' + items: + type: string + type: array + proof_rollup_exit_root: + description: Proof rollup exit root associated with the claim (optional) + example: + - '[0x4' + - ' 0x5' + - ' 0x6...]' + items: + type: string + type: array + rollup_exit_root: + description: Rollup exit root associated with the claim + example: 0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757 + type: string + tx_hash: + description: Transaction hash associated with the claim + example: 0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + type: string + type: object types.ErrorData: properties: code: @@ -303,6 +554,110 @@ info: title: Bridge Tracker API version: "1.0" paths: + /activity/from/{from_address}: + get: + description: |- + Scans every bridge service the tracker knows about for bridges sent by + from_address and reports each one's claim state, exactly as the bridge service + reported it. Results are cached: a bridge already known to be claimed, with its + claim record already fetched, is not rechecked on a later call. Passing + includeTracking=true additionally registers every still-unclaimed bridge with + the bridge tracker and includes its current tracking snapshot. filterBridges + restricts the result to bridges with only that claim state (claimed / still + pending / errored while checking). A network whose bridge service could not be + scanned is skipped and reported in the "warnings" field instead of failing the + whole request. + parameters: + - description: Address that sent the bridges to look up + in: path + name: from_address + required: true + type: string + - description: Register still-unclaimed bridges with the tracker + in: query + name: includeTracking + type: boolean + - default: all + description: Which bridges to return + enum: + - all + - claimed + - pending + - error + in: query + name: filterBridges + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ActivityResponse' + "400": + description: Invalid from_address or filterBridges + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Scanning the configured bridge services failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get bridge activity by sender address + tags: + - bridge-tracker + /bridge-address: + get: + description: |- + With no network_id, reports the bridge contract address of every network the + tracker currently knows about (via the bridge service finder). With network_id, + reports only that network's. + produces: + - application/json + responses: + "200": + description: Body when network_id is set + schema: + $ref: '#/definitions/api.BridgeAddressItem' + "400": + description: Invalid network_id + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Resolving the bridge contract address failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get the bridge contract address of one network, or every network + tags: + - bridge-tracker + /bridge-address/{network_id}: + get: + description: |- + With no network_id, reports the bridge contract address of every network the + tracker currently knows about (via the bridge service finder). With network_id, + reports only that network's. + parameters: + - description: Network to look up; omit to get every network + in: path + name: network_id + type: integer + produces: + - application/json + responses: + "200": + description: Body when network_id is set + schema: + $ref: '#/definitions/api.BridgeAddressItem' + "400": + description: Invalid network_id + schema: + $ref: '#/definitions/types.ErrorData' + "500": + description: Resolving the bridge contract address failed + schema: + $ref: '#/definitions/types.ErrorData' + summary: Get the bridge contract address of one network, or every network + tags: + - bridge-tracker /health: get: description: |- diff --git a/bridgetracker/bridge_address_test.go b/bridgetracker/bridge_address_test.go new file mode 100644 index 000000000..1bbc87bfc --- /dev/null +++ b/bridgetracker/bridge_address_test.go @@ -0,0 +1,149 @@ +package bridgetracker + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/agglayer/aggkit/bridgetracker/api" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +var ( + testBridgeAddressNetwork0 = common.HexToAddress("0x2222222222222222222222222222222222222222") + testBridgeAddressNetwork1 = common.HexToAddress("0x3333333333333333333333333333333333333333") +) + +// fakeBridgeAddressResolver is a hand-rolled BridgeAddressResolver for tests: addresses maps +// networkID -> address (BridgeAddress errors if networkID is absent, unless err is set, in +// which case every call fails with it), networkIDs is returned as-is by NetworkIDs. +type fakeBridgeAddressResolver struct { + networkIDs []uint32 + addresses map[uint32]common.Address + err error +} + +func (f *fakeBridgeAddressResolver) NetworkIDs() []uint32 { + return f.networkIDs +} + +func (f *fakeBridgeAddressResolver) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + if f.err != nil { + return common.Address{}, f.err + } + addr, ok := f.addresses[networkID] + if !ok { + return common.Address{}, errors.New("no bridge contract address configured for network") + } + return addr, nil +} + +// TestBridgeAddressHandlerNotRegisteredWithoutResolver verifies both bridge-address endpoints +// are absent (404) when Config.BridgeAddressResolver is left nil +func TestBridgeAddressHandlerNotRegisteredWithoutResolver(t *testing.T) { + _, router := newTestTracker(t) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusNotFound, resp.Code) + + resp = performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusNotFound, resp.Code) +} + +// TestBridgeAddressHandlerAllNetworks verifies GET /bridge-address reports the bridge contract +// address of every network the resolver currently knows about +func TestBridgeAddressHandlerAllNetworks(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{0, 1}, + addresses: map[uint32]common.Address{0: testBridgeAddressNetwork0, 1: testBridgeAddressNetwork1}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.BridgeAddressResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, []api.BridgeAddressItem{ + {NetworkID: 0, BridgeAddress: testBridgeAddressNetwork0}, + {NetworkID: 1, BridgeAddress: testBridgeAddressNetwork1}, + }, body.Bridges) +} + +// TestBridgeAddressHandlerSingleNetwork verifies GET /bridge-address/{network_id} reports only +// the requested network's bridge contract address +func TestBridgeAddressHandlerSingleNetwork(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{0, 1}, + addresses: map[uint32]common.Address{0: testBridgeAddressNetwork0, 1: testBridgeAddressNetwork1}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.BridgeAddressItem + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, api.BridgeAddressItem{NetworkID: 1, BridgeAddress: testBridgeAddressNetwork1}, body) +} + +// TestBridgeAddressHandlerInvalidNetworkID verifies a non-numeric network_id is rejected with 400 +func TestBridgeAddressHandlerInvalidNetworkID(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/foo") + require.Equal(t, http.StatusBadRequest, resp.Code) + + var errData types.ErrorData + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &errData)) + require.Equal(t, http.StatusBadRequest, errData.Code) + require.Contains(t, errData.Message, "network_id") +} + +// TestBridgeAddressHandlerResolverFailure verifies a resolver failure surfaces as 500 +func TestBridgeAddressHandlerResolverFailure(t *testing.T) { + wantErr := errors.New("rollup manager call failed") + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + BridgeAddressResolver: &fakeBridgeAddressResolver{ + networkIDs: []uint32{1}, + err: wantErr, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address/1") + require.Equal(t, http.StatusInternalServerError, resp.Code) + + resp = performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/bridge-address") + require.Equal(t, http.StatusInternalServerError, resp.Code) +} diff --git a/bridgetracker/bridgetracker.go b/bridgetracker/bridgetracker.go index 2d342e83c..4c2298986 100644 --- a/bridgetracker/bridgetracker.go +++ b/bridgetracker/bridgetracker.go @@ -30,10 +30,20 @@ func New(cfg *Config) *BridgeTracker { supervised = NewMemoryRegistry(cfg.MaxTrackedBridges) } + // The activity endpoint is only registered when both driven ports are wired (see + // Config.ActivityScanner/ActivityClaims); a nil ActivityQuerier tells api.NewAPI to skip it + var activity ActivityQuerier + if cfg.ActivityScanner != nil && cfg.ActivityClaims != nil { + activity = NewActivityCache( + cfg.ActivityScanner, cfg.ActivityClaims, supervised, cfg.Logger, cfg.ActivityIdleTimeout.Duration) + } + return &BridgeTracker{ logger: cfg.Logger, supervised: supervised, - api: api.NewAPI(cfg.Logger, cfg.ConfigSHA1, supervised, cfg.RegisterResolveTimeout.Duration, cfg.CORS), + api: api.NewAPI( + cfg.Logger, cfg.ConfigSHA1, supervised, activity, cfg.BridgeAddressResolver, + cfg.RegisterResolveTimeout.Duration, cfg.CORS), } } diff --git a/bridgetracker/bridgetracker_test.go b/bridgetracker/bridgetracker_test.go index 84603c908..e5275a2db 100644 --- a/bridgetracker/bridgetracker_test.go +++ b/bridgetracker/bridgetracker_test.go @@ -3,6 +3,7 @@ package bridgetracker import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -10,7 +11,9 @@ import ( "time" "github.com/agglayer/aggkit" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" "github.com/agglayer/aggkit/bridgetracker/api" + "github.com/agglayer/aggkit/bridgetracker/domain" "github.com/agglayer/aggkit/bridgetracker/types" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" @@ -329,3 +332,177 @@ func TestHealthHandlerNoSideEffects(t *testing.T) { defer reg.mu.RUnlock() require.Empty(t, reg.bridges) } + +// TestActivityHandlerNotRegisteredWithoutSources verifies the activity endpoint is absent +// (404) when Config.ActivityScanner/ActivityClaims are left nil, exactly like every tracker +// built before this endpoint existed +func TestActivityHandlerNotRegisteredWithoutSources(t *testing.T) { + _, router := newTestTracker(t) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusNotFound, resp.Code) +} + +// TestActivityHandlerInvalidAddress verifies an invalid from_address is rejected with 400 +func TestActivityHandlerInvalidAddress(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{}, + ActivityClaims: &fakeActivityClaims{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/not-an-address") + require.Equal(t, http.StatusBadRequest, resp.Code) +} + +// TestActivityHandlerHappyPath verifies the activity endpoint reports the bridges found by the +// wired sources, in the ActivityResponse wire shape +func TestActivityHandlerHappyPath(t *testing.T) { + bridge := testBridge(1) + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx"} + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, + }, + ActivityClaims: &fakeActivityClaims{ + isClaimed: []bool{true}, + claimInfo: []*bridgeservicetypes.ClaimResponse{claim}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, testFromAddress, body.FromAddress) + require.Len(t, body.Bridges, 1) + require.Equal(t, "true", body.Bridges[0].Claimed) + require.Equal(t, testScannedNetworkID, body.Bridges[0].BridgeNetworkID) + require.Equal(t, claim.TxHash, body.Bridges[0].Claim.TxHash) + require.Equal(t, bridge.DestinationNetwork, body.Bridges[0].ClaimNetworkID) + require.NotZero(t, body.Bridges[0].CreationTimestamp) + require.NotZero(t, body.Bridges[0].LastUpdatedTimestamp) +} + +// TestActivityHandlerScannerWarningsSurfaceInResponse verifies a network the scanner could not +// reach never fails the request: the bridges found on every other network are still returned, +// and the unreachable network is reported in the ActivityResponse's "warnings" field. +func TestActivityHandlerScannerWarningsSurfaceInResponse(t *testing.T) { + bridge := testBridge(1) + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, + warnings: []domain.ActivityWarning{{NetworkID: 7, Message: "bridge service unreachable"}}, + }, + ActivityClaims: &fakeActivityClaims{isClaimed: []bool{false}}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Len(t, body.Warnings, 1) + require.Equal(t, uint32(7), body.Warnings[0].NetworkID) + require.Equal(t, "bridge service unreachable", body.Warnings[0].Message) +} + +// TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage verifies a failed isClaimed() +// check surfaces as claimed="error" plus the failure message under errors["claim"], instead of +// being silently reported as unclaimed. +func TestActivityHandlerIsClaimedFailureReportsErrorStatusAndMessage(t *testing.T) { + bridge := testBridge(1) + wantErrMsg := "no bridge contract address configured for network 2" + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{scannedBridge(bridge, testScannedNetworkID)}, + }, + ActivityClaims: &fakeActivityClaims{ + isClaimed: []bool{false}, + isClaimedErrs: []error{errors.New(wantErrMsg)}, + }, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()) + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Equal(t, "error", body.Bridges[0].Claimed) + require.Nil(t, body.Bridges[0].Claim) + require.Equal(t, wantErrMsg, body.Bridges[0].Errors["claim"]) +} + +// TestActivityHandlerInvalidFilterBridges verifies an unrecognized filterBridges value is +// rejected with 400 +func TestActivityHandlerInvalidFilterBridges(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{}, + ActivityClaims: &fakeActivityClaims{}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, + api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()+"?filterBridges=bogus") + require.Equal(t, http.StatusBadRequest, resp.Code) +} + +// TestActivityHandlerFilterBridgesPendingExcludesClaimed verifies ?filterBridges=pending +// excludes an already-claimed bridge from the response +func TestActivityHandlerFilterBridgesPendingExcludesClaimed(t *testing.T) { + claimedBridge := testBridge(1) + pendingBridge := testBridge(2) + + gin.SetMode(gin.TestMode) + tracker := New(&Config{ + Logger: log.WithFields("module", "bridgetracker_test"), + ConfigSHA1: testConfigSHA1, + ActivityScanner: &fakeActivityScanner{ + bridges: []*domain.ScannedBridge{ + scannedBridge(claimedBridge, testScannedNetworkID), + scannedBridge(pendingBridge, testScannedNetworkID), + }, + }, + ActivityClaims: &fakeActivityClaims{isClaimed: []bool{true, false}}, + }) + router := gin.New() + tracker.API().RegisterRoutes(router) + + resp := performRequest(t, router, http.MethodGet, + api.TrackerV1Prefix+"/activity/from/"+testFromAddress.Hex()+"?filterBridges=pending") + require.Equal(t, http.StatusOK, resp.Code) + + var body api.ActivityResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Len(t, body.Bridges, 1) + require.Equal(t, "false", body.Bridges[0].Claimed) +} diff --git a/bridgetracker/config.go b/bridgetracker/config.go index 54db02863..a682663d0 100644 --- a/bridgetracker/config.go +++ b/bridgetracker/config.go @@ -41,6 +41,10 @@ const DefaultMaxTrackedBridges = 100_000 // semantics; both must stay in sync with the [Tracker] section of the proxy's default config) var DefaultIdleTimeout = types.Duration{Duration: DefaultEngineIdleTimeout} +// DefaultL2InjectionLookbackBlocks is the default Config.L2InjectionLookbackBlocks (must stay in +// sync with the [Tracker] section of the proxy's default config) +const DefaultL2InjectionLookbackBlocks = 1_000 + // Config holds the configuration of the bridge tracker service. Only the mapstructure-tagged // fields come from the configuration file; the rest are wired programmatically by the binary // (see proxy/cmd) @@ -87,6 +91,24 @@ type Config struct { // L1GlobalExitRootAddress is the L1 GlobalExitRoot contract address (see sources.GERSource) L1GlobalExitRootAddress common.Address `mapstructure:"L1GlobalExitRootAddress"` + // L2GlobalExitRootAddress is the static networkID -> GlobalExitRootManagerL2 contract address + // map used as a fallback when a destination network's bridge-service instance does not + // report the L2 block a covering GER was actually injected at (see sources.GERSource, + // WaitingGERInjection's InjectedGERResult.L2InjectedGER): for a network present here, the + // tracker scans that network's own L2 for the UpdateHashChainValue event instead of leaving + // L2InjectedGER absent. A network absent from this map (the default, empty map) never gets + // this fallback attempted — no different from before it existed + L2GlobalExitRootAddress map[uint32]common.Address `mapstructure:"L2GlobalExitRootAddress"` + + // L2InjectionLookbackBlocks bounds how many blocks the L2GlobalExitRootAddress fallback (see + // sources.GERSource.findL2InjectionBlockBackwards) scans backwards from the destination + // network's head before giving up, instead of continuing all the way back to genesis: on a + // network whose fallback never finds the event (e.g. a wrong L2GlobalExitRootAddress entry, + // or a GER injected before the contract even existed there), an unbounded scan would walk the + // entire chain history in bridgeservicefinder.DefaultBlockChunkSize-sized eth_getLogs calls + // every time. A value <= 0 falls back to DefaultL2InjectionLookbackBlocks. + L2InjectionLookbackBlocks uint64 `mapstructure:"L2InjectionLookbackBlocks"` + // MaxTrackedBridges bounds how many distinct bridges the in-memory registry (see Registry) // accepts at once; a request that would exceed it fails instead of registering the bridge — // reaching the cap never evicts an existing entry to make room, so RetentionPeriod and @@ -116,6 +138,27 @@ type Config struct { // why WebSocket needs its own check instead of reusing the REST CORS headers). Wired // programmatically from REST.CORS by the binary, not read directly from [Tracker]. CORS aggkitcommon.CORSConfig `mapstructure:"-"` + + // ActivityScanner and ActivityClaims wire the optional GET /activity/from/{from_address} + // endpoint (see ActivityCache): ActivityScanner scans every configured bridge service for + // bridges sent by an address, ActivityClaims resolves each one's claim state. Both are + // wired programmatically by the binary (see sources.ActivitySource, which implements + // both); leaving either nil leaves the endpoint unregistered entirely. + ActivityScanner ActivityBridgeScanner `mapstructure:"-"` + ActivityClaims ActivityClaimChecker `mapstructure:"-"` + + // ActivityIdleTimeout is how long a from_address's activity cache (see ActivityCache) stays + // in memory with no GET /activity/from/{from_address} call for it, before being forgotten + // entirely (bridges, claim state, everything cached for it). Same semantics as IdleTimeout, + // a separate knob because it governs a different cache. A value <= 0 falls back to + // DefaultIdleTimeout. + ActivityIdleTimeout types.Duration `mapstructure:"ActivityIdleTimeout"` + + // BridgeAddressResolver wires the optional GET /bridge-address[/{network_id}] endpoint: it + // resolves the bridge contract address for one network, or every network it currently + // knows about. Wired programmatically by the binary (bridgeservicefinder.Finder satisfies + // this port directly); leaving it nil leaves the endpoint unregistered entirely. + BridgeAddressResolver BridgeAddressResolver `mapstructure:"-"` } // Validate checks if the configuration is valid diff --git a/bridgetracker/domain/activity.go b/bridgetracker/domain/activity.go new file mode 100644 index 000000000..e55390351 --- /dev/null +++ b/bridgetracker/domain/activity.go @@ -0,0 +1,116 @@ +package domain + +import ( + "context" + "time" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/types" + "github.com/ethereum/go-ethereum/common" +) + +// ScannedBridge pairs a raw bridge event with the network whose bridge service actually +// returned it — i.e. the network the bridge-creating tx was sent to. This is deliberately NOT +// the same thing as Bridge.OriginNetwork, which is the origin network of the bridged ASSET: the +// two coincide for a first-time bridge of a native asset, but differ when re-bridging an asset +// that itself originated on a third network (e.g. an asset native to L1, already bridged to L2 +// A, now bridged again from L2 A to L2 B — that bridge is reported by L2 A's own bridge service, +// yet its OriginNetwork still reads L1). Anything keyed by "which network created this deposit" +// — isClaimed()'s sourceBridgeNetwork, the GlobalIndex encoding, the tracker's TrackingID — must +// use NetworkID here, never Bridge.OriginNetwork (see bridgeservice/utils.go's NewBridgeResponse, +// which threads the requested network — not Bridge.OriginNetwork — into GlobalIndexForBridge). +type ScannedBridge struct { + Bridge *bridgeservicetypes.BridgeResponse + NetworkID uint32 +} + +// ActivityEntry is one bridge found for a from_address, as of the last time it was (re)checked +// (see ActivityQuerier). Bridge and Claim are stored exactly as the bridge service returned +// them — this feature is a cache over that data, not a reinterpretation of it (see +// bridgeservice/types.BridgeResponse/ClaimResponse) +type ActivityEntry struct { + // Bridge is the raw bridge event, as returned by the origin network's bridge service + Bridge *bridgeservicetypes.BridgeResponse + // BridgeNetworkID is the network whose bridge service reported Bridge (see ScannedBridge) — + // NOT necessarily Bridge.OriginNetwork + BridgeNetworkID uint32 + // ClaimStatus is the tri-state result of the destination bridge contract's isClaimed() + // call the last time it was checked: Unclaimed, Claimed, or Error if the check itself + // failed (e.g. no bridge contract address configured for the destination network) — a + // consumer must not read Error as "not claimed" + ClaimStatus types.ClaimStatus + // Claim is the raw claim record, as returned by the destination network's bridge + // service, once ClaimStatus is Claimed and the indexer has recorded it; nil until then + Claim *bridgeservicetypes.ClaimResponse + // Tracking is the bridge tracker's current snapshot of this bridge, only populated while + // it is still unclaimed and the caller asked for it (includeTracking); nil otherwise + Tracking *TrackingData + // Errors holds the message of whatever check failed the last time this entry was + // refreshed, keyed by which check it was — currently only "claim", set when ClaimStatus is + // Error (the isClaimed() check itself failed). nil while nothing has failed + Errors map[string]string + // CreatedAt is when this bridge was first cached (its first successful refresh); it never + // changes after that + CreatedAt time.Time + // UpdatedAt is when this entry's claim/tracking state was last (re)computed — the last time + // refresh ran for it, whether or not anything about it actually changed. Frozen once the + // entry settles (see ActivityCache's settled), since a settled entry is never refreshed again + UpdatedAt time.Time +} + +// ActivityWarning reports one network's bridge service that could not be scanned/reached while +// serving a request that spans every configured network (see ActivityBridgeScanner.BridgesFrom, +// ActivityQuerier.GetActivity) — the scan still succeeds with whatever every other network +// reported, but the caller must be told this network's activity may be incomplete +type ActivityWarning struct { + // NetworkID is the network whose bridge service could not be scanned + NetworkID uint32 + // Message is the error encountered while scanning NetworkID + Message string +} + +// ActivityBridgeScanner is the driven port to the raw bridge-service data behind the +// GET /activity/from/{from_address} endpoint: it scans every bridge service the tracker knows +// about for bridges sent by fromAddress +type ActivityBridgeScanner interface { + // BridgesFrom returns every bridge whose sender is fromAddress and whose GlobalIndex (as a + // decimal string) is not already in known, across every configured bridge service. known is + // the caller's full set of already-cached global indexes for fromAddress (any network — a + // GlobalIndex is unique across the whole system); implementations may use it to stop + // scanning a network as soon as an already-known bridge is reached, since each network's + // own bridge service reports bridges newest-first and is append-only, so anything after the + // first known bridge is guaranteed already known too (see sources.ActivitySource). + // + // A network whose bridge service cannot be scanned does not fail the call: it is skipped and + // reported back as an ActivityWarning instead, so one misbehaving network never hides every + // other network's activity. + BridgesFrom( + ctx context.Context, fromAddress common.Address, known map[string]struct{}, + ) ([]*ScannedBridge, []ActivityWarning, error) +} + +// ActivityClaimChecker is the driven port to a bridge's claim state on its destination +// network: IsClaimed is the on-chain source of truth, ClaimInfo is the raw claim record the +// destination network's bridge service indexed for it once claimed +type ActivityClaimChecker interface { + // IsClaimed calls the destination bridge contract's isClaimed() for bridge + IsClaimed(ctx context.Context, bridge *ScannedBridge) (bool, error) + // ClaimInfo returns the raw claim record for bridge from its destination network's + // bridge service, or nil if the indexer has not recorded it yet + ClaimInfo(ctx context.Context, bridge *ScannedBridge) (*bridgeservicetypes.ClaimResponse, error) +} + +// ActivityQuerier is the driven port the GET /activity/from/{from_address} HTTP command +// depends on +type ActivityQuerier interface { + // GetActivity returns the bridges sent by fromAddress across every configured bridge + // service, enriched with their claim state and filtered per filter (see + // types.ActivityFilter); includeTracking additionally feeds every still-unclaimed bridge in + // the result to the bridge tracker (see ActivityEntry.Tracking). The returned + // []ActivityWarning lists every network whose bridge service could not be scanned this call + // (see ActivityBridgeScanner.BridgesFrom) — the result is still whatever every other network + // reported, just possibly incomplete for the networks listed + GetActivity( + ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter, + ) ([]*ActivityEntry, []ActivityWarning, error) +} diff --git a/bridgetracker/domain/bridge_address.go b/bridgetracker/domain/bridge_address.go new file mode 100644 index 000000000..cb55f9565 --- /dev/null +++ b/bridgetracker/domain/bridge_address.go @@ -0,0 +1,20 @@ +package domain + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" +) + +// BridgeAddressResolver is the driven port behind GET /bridge-address[/{network_id}]: it +// resolves the bridge contract address for one network, and enumerates every network currently +// known, so "every network" can be answered without a fixed config list. +// bridgeservicefinder.Finder satisfies it directly (see sources.NetworkLister, which widens the +// same shape further for the activity endpoint). +type BridgeAddressResolver interface { + // NetworkIDs returns the networkIDs of every network currently resolved + NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID (see + // bridgeservicefinder.Finder.BridgeAddress for the resolution/override rules) + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) +} diff --git a/bridgetracker/domain/bridge_step_path.go b/bridgetracker/domain/bridge_step_path.go index 75dd81ded..19b8dddc0 100644 --- a/bridgetracker/domain/bridge_step_path.go +++ b/bridgetracker/domain/bridge_step_path.go @@ -23,7 +23,7 @@ type BridgeStepPath struct { // (StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate), // *types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData // (StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or - // *types.ClaimResult (StepWaitingClaim). nil until the step produces one, and for steps + // *types.ClaimResult (StepClaimed). nil until the step produces one, and for steps // that never do. Most steps only set this once Done, but StepCertificatePending (Status // still InProgress) may already carry the certificate's current, not yet settled, status — // see ErrCertificateNotSettled diff --git a/bridgetracker/domain/resolve_step_certificate_pending.go b/bridgetracker/domain/resolve_step_certificate_pending.go index f47df8080..ccc06ad9f 100644 --- a/bridgetracker/domain/resolve_step_certificate_pending.go +++ b/bridgetracker/domain/resolve_step_certificate_pending.go @@ -9,7 +9,9 @@ import ( ) // ErrCertificateNotSettled means the bridge already has a certificate but it has not settled -// yet: the same "not ready" family as ErrStepPending (errors.Is matches both), but carries the +// yet — or it has, but its settlement tx is not visible on L1 yet (see CertificateSource. +// settlementBlockInfo, which can lag a tick behind the certificate itself turning Settled): the +// same "not ready" family as ErrStepPending (errors.Is matches both), but carries the // certificate's current status as its Result so clients can see it progress while they wait, // instead of only once it settles var ErrCertificateNotSettled = fmt.Errorf("certificate not settled yet: %w", ErrStepPending) @@ -25,7 +27,8 @@ type CertificateSource interface { // CertificatePendingResolver resolves StepCertificatePending: covers every status the // certificate goes through — Pending, Proven, Candidate or InError all park here, only its -// Result changes — until it settles, the only transition that moves the bridge on +// Result changes — until it settles AND its settlement tx's block is visible on L1 +// (CertificateData.BlockNumber/BlockTimestamp), the transition that moves the bridge on type CertificatePendingResolver struct { port CertificateSource } @@ -47,8 +50,8 @@ func (r *CertificatePendingResolver) Resolve( return nil, ErrStepPending } - if cert.Status.IsSettled() { + if cert.Status.IsSettled() && cert.BlockNumber != nil { return &cert.CertificateData, nil } - return &cert.CertificateData, ErrCertificateNotSettled // still awaiting settlement + return &cert.CertificateData, ErrCertificateNotSettled // still awaiting settlement, or its L1 block } diff --git a/bridgetracker/domain/resolve_step_claimed.go b/bridgetracker/domain/resolve_step_claimed.go new file mode 100644 index 000000000..f4b47a64f --- /dev/null +++ b/bridgetracker/domain/resolve_step_claimed.go @@ -0,0 +1,45 @@ +package domain + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/bridgetracker/types" + aggkitcommon "github.com/agglayer/aggkit/common" +) + +// ClaimSource is the driven port to the claim record of a bridge on its destination network +type ClaimSource interface { + // ClaimFor returns the claim transaction of bridge on the destination network, or nil if + // the destination network's bridge service has not indexed it yet + ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) +} + +// ClaimedResolver resolves StepClaimed: once StepWaitingClaim's on-chain check confirms the +// bridge is claimed, this fetches the claim transaction/block from the destination network's +// bridge service. It can stay pending a little after StepWaitingClaim completes — the bridge +// service may not have indexed the claim tx yet even though isClaimed() already returns true — +// so it does get its own fact check, unlike a plain waypoint +type ClaimedResolver struct { + port ClaimSource +} + +// NewClaimedResolver returns a ClaimedResolver reading claims through port +func NewClaimedResolver(port ClaimSource) *ClaimedResolver { + return &ClaimedResolver{port: port} +} + +// Resolve implements StepResolver +func (r *ClaimedResolver) Resolve( + logger aggkitcommon.Logger, ctx context.Context, tracking *TrackingData, _ int, +) (any, error) { + claim, err := r.port.ClaimFor(ctx, tracking.Info()) + if err != nil { + return nil, fmt.Errorf("claim info: %w", err) + } + if claim == nil { + return nil, ErrStepPending + } + + return claim, nil +} diff --git a/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go b/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go index 4b2d5c036..92149a3fd 100644 --- a/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go +++ b/bridgetracker/domain/resolve_step_wait_l1_settled_ger.go @@ -2,6 +2,7 @@ package domain import ( "context" + "errors" "fmt" "github.com/agglayer/aggkit/bridgetracker/types" @@ -9,11 +10,15 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// ErrLeafIndexNotResolved means the settlement tx is confirmed but its GER has not been -// resolved to an L1 info tree leaf index yet (only reached when UpdateL1InfoTreeV2 did not fire -// — see WaitL1SettledGERResolver): the same "not ready" family as ErrStepPending (errors.Is -// matches both), but carries the settlement evidence gathered so far as its Result -var ErrLeafIndexNotResolved = fmt.Errorf("settlement GER not resolved to a leaf index yet: %w", ErrStepPending) +var ( + // ErrLeafIndexNotResolved means the settlement tx is confirmed but its GER has not been + // resolved to an L1 info tree leaf index yet (only reached when UpdateL1InfoTreeV2 did not fire + // — see WaitL1SettledGERResolver): the same "not ready" family as ErrStepPending (errors.Is + // matches both), but carries the settlement evidence gathered so far as its Result + ErrLeafIndexNotResolved = fmt.Errorf("settlement GER not resolved to a leaf index yet: %w", ErrStepPending) + + ErrBadSettlementTx = Permanent(errors.New("settlement tx receipt does not carry required events")) +) // SettlementSource is the driven port to the L1 evidence a certificate's settlement produces type SettlementSource interface { diff --git a/bridgetracker/domain/resolve_step_waiting_claim.go b/bridgetracker/domain/resolve_step_waiting_claim.go index 5d968a30b..8a7d7f3c0 100644 --- a/bridgetracker/domain/resolve_step_waiting_claim.go +++ b/bridgetracker/domain/resolve_step_waiting_claim.go @@ -4,27 +4,27 @@ import ( "context" "fmt" - "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" ) -// ClaimSource is the driven port to the claim state of a bridge on its destination network -type ClaimSource interface { - // ClaimFor returns the claim transaction of bridge on the destination network, or nil if - // it has not been claimed yet - ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) +// ClaimChecker is the driven port to whether a bridge has been claimed on its destination +// network: the on-chain isClaimed() call, the same source of truth ActivityClaimChecker uses for +// the activity endpoint (see domain.ActivityClaimChecker) +type ClaimChecker interface { + // IsClaimed reports whether bridge has already been claimed on its destination network + IsClaimed(ctx context.Context, bridge *BridgeInfo) (bool, error) } // WaitingClaimResolver resolves StepWaitingClaim: whether the bridge has been claimed on its -// destination network. Completing it also completes StepClaimed in the same call (see -// UpdateStep): Claimed never has a fact check of its own, it is reached the instant the claim -// is found +// destination network, per the destination bridge contract's own isClaimed() — fast and +// authoritative, but it carries no claim transaction/block details of its own (see +// ClaimedResolver, which resolves those once this step completes) type WaitingClaimResolver struct { - port ClaimSource + port ClaimChecker } -// NewWaitingClaimResolver returns a WaitingClaimResolver reading claims through port -func NewWaitingClaimResolver(port ClaimSource) *WaitingClaimResolver { +// NewWaitingClaimResolver returns a WaitingClaimResolver checking claims through port +func NewWaitingClaimResolver(port ClaimChecker) *WaitingClaimResolver { return &WaitingClaimResolver{port: port} } @@ -32,13 +32,13 @@ func NewWaitingClaimResolver(port ClaimSource) *WaitingClaimResolver { func (r *WaitingClaimResolver) Resolve( logger aggkitcommon.Logger, ctx context.Context, tracking *TrackingData, _ int, ) (any, error) { - claim, err := r.port.ClaimFor(ctx, tracking.Info()) + claimed, err := r.port.IsClaimed(ctx, tracking.Info()) if err != nil { return nil, fmt.Errorf("claim status: %w", err) } - if claim == nil { + if !claimed { return nil, ErrStepPending } - return claim, nil + return nil, nil } diff --git a/bridgetracker/domain/resolve_step_waiting_ger_injection.go b/bridgetracker/domain/resolve_step_waiting_ger_injection.go index e6b20d8e6..f535e0483 100644 --- a/bridgetracker/domain/resolve_step_waiting_ger_injection.go +++ b/bridgetracker/domain/resolve_step_waiting_ger_injection.go @@ -55,7 +55,22 @@ func (r *WaitingGERInjectionResolver) Resolve( return nil, ErrStepPending } - return &types.InjectedGERResult{GER: *injected.GER}, nil + result := &types.InjectedGERResult{ + L1InfoTreeLeaf: types.InjectedGERL1Leaf{GER: *injected.GER}, + } + if injected.BlockNumber != nil { + result.L1InfoTreeLeaf.BlockNumber = *injected.BlockNumber + } + if injected.BlockTimestamp != nil { + result.L1InfoTreeLeaf.BlockTimestamp = *injected.BlockTimestamp + } + // L2InjectedGER stays nil (see InjectedGERResult's doc comment) when the destination's + // bridge-service instance does not report the actual L2 injection block yet + if injected.L2BlockNumber != nil { + result.L2InjectedGER = &types.InjectedL2GERBlock{BlockNumber: *injected.L2BlockNumber} + result.L2InjectedGER.BlockTimestamp = injected.L2BlockTimestamp + } + return result, nil } func (r *WaitingGERInjectionResolver) getLeafIndexFromPreviousStep( diff --git a/bridgetracker/domain/resolve_steps.go b/bridgetracker/domain/resolve_steps.go index 4b0bf0158..1125b85bf 100644 --- a/bridgetracker/domain/resolve_steps.go +++ b/bridgetracker/domain/resolve_steps.go @@ -89,18 +89,21 @@ func currentStepIndex(steps []BridgeStepPath) int { // the current step still in progress (StartDate stamped if not already) otherwise — either way // result becomes its Result, so a resolver can surface data before its milestone is fully met // (see ErrCertificateNotSettled). If stepErr is non-nil, the step is instead marked -// StepStatusError, accumulating stepErr onto its retry count and description rather than -// discarding the history of a transient source failure — the step-level counterpart of the -// tx-level error handling in ResolveBridgeTx; complete is meaningless in that case (a step -// cannot both fail and complete) and idx+1 is left untouched. With stepErr nil, any previous -// Error is cleared instead: a successful fact check, even an inconclusive one, clears a previous -// transient failure, evidence the retry is working, not just that a milestone was met. -// Completing idx opens idx+1 as the new current step (InProgress), completing it immediately, -// terminal, if it is StepClaimed — a step that never has a fact check of its own. Returns -// tracking unchanged only when there is truly nothing new to record: not complete, no stepErr, -// no Error to clear, and result unchanged from what is already stored. ResolveSteps calls this -// once per loop iteration, so completing one step (e.g. PendingInclusionResolver, see its doc) -// simply has the next resolver asked in turn +// StepStatusError — the step-level counterpart of the tx-level error handling in +// ResolveBridgeTx. A resolver marks stepErr as unrecoverable the same way a BridgeEventSource +// does (see Permanent/IsPermanent): IsPermanent(stepErr) makes the step StepErrorPermanent with +// just this failure, no point accumulating a retry history nothing will retry. Any other stepErr +// is StepErrorTransient, accumulating onto the step's retry count and description instead of +// discarding the history of a transient source failure. Either way complete is meaningless here +// (a step cannot both fail and complete) and idx+1 is left untouched. With stepErr nil, any +// previous Error is cleared instead: a successful fact check, even an inconclusive one, clears a +// previous transient failure, evidence the retry is working, not just that a milestone was met. +// Completing idx opens idx+1 as the new current step (InProgress) — including StepClaimed, which +// gets its own resolver call (ClaimedResolver) like any other step, ResolveSteps simply asks it +// in the same loop iteration. Returns tracking unchanged only when there is truly nothing new to +// record: not complete, no stepErr, no Error to clear, and result unchanged from what is already +// stored. ResolveSteps calls this once per loop iteration, so completing one step (e.g. +// PendingInclusionResolver, see its doc) simply has the next resolver asked in turn func UpdateStep( tracking *TrackingData, idx int, result any, complete bool, stepErr error, now time.Time, ) *TrackingData { @@ -118,12 +121,20 @@ func UpdateStep( current.SetResult(result) switch { case stepErr != nil: + current.Status = types.StepStatusError + if IsPermanent(stepErr) { + // unrecoverable: no retry history to accumulate, nothing will retry this step + current.Error = &types.ErrorStep{ + ErrorType: types.StepErrorPermanent, + Description: []string{stepErr.Error()}, + } + break + } retryCount, description := 1, []string{stepErr.Error()} if current.Error != nil { retryCount = current.Error.RetryCount + 1 description = append(append([]string{}, current.Error.Description...), stepErr.Error()) } - current.Status = types.StepStatusError current.Error = &types.ErrorStep{ ErrorType: types.StepErrorTransient, RetryCount: retryCount, @@ -150,11 +161,6 @@ func UpdateStep( next.Error = nil startDate := now next.StartDate = &startDate - if next.Step == types.StepClaimed { - next.Status = types.StepStatusDone - endDate := now - next.EndDate = &endDate - } newSteps[idx+1] = next } diff --git a/bridgetracker/domain/resolve_steps_test.go b/bridgetracker/domain/resolve_steps_test.go index e017e81ca..512b552e6 100644 --- a/bridgetracker/domain/resolve_steps_test.go +++ b/bridgetracker/domain/resolve_steps_test.go @@ -22,6 +22,7 @@ type fakeFacts struct { injectedGER *types.GERData injectedGERAtIndex *types.GERData l1InfoTreeIndex *uint32 + claimed bool claim *types.ClaimResult settlement *types.L1SettledGERResult @@ -31,6 +32,7 @@ type fakeFacts struct { injectedGERErr error injectedGERAtIndexErr error l1InfoTreeIndexErr error + claimedErr error claimErr error settlementErr error @@ -88,6 +90,11 @@ func (f *fakeFacts) L1InfoTreeIndexForGER( return f.l1InfoTreeIndex, f.l1InfoTreeIndexErr } +func (f *fakeFacts) IsClaimed(_ context.Context, _ *BridgeInfo) (bool, error) { + f.queried = append(f.queried, "isClaimed") + return f.claimed, f.claimedErr +} + func (f *fakeFacts) ClaimFor(_ context.Context, _ *BridgeInfo) (*types.ClaimResult, error) { f.queried = append(f.queried, "claimFor") return f.claim, f.claimErr @@ -115,6 +122,7 @@ func testResolvers(f *fakeFacts) map[types.BridgeStep]StepResolver { types.StepWaitL1SettledGER: NewWaitL1SettledGERResolver(f, f), types.StepWaitingGERInjection: NewWaitingGERInjectionResolver(f), types.StepWaitingClaim: NewWaitingClaimResolver(f), + types.StepClaimed: NewClaimedResolver(f), } } @@ -141,11 +149,16 @@ func TestResolveSteps(t *testing.T) { originLER := &types.LERUpdateResult{NetworkID: 1, LER: common.Hash{2}, BlockNumber: 200} injectedGER := &types.GERData{NetworkID: 2, GER: &common.Hash{1}} settlementTxHash := common.Hash{4} - settledCertData := types.CertificateData{Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash} + settledCertBlockNumber := uint64(400) + settledCertBlockTimestamp := uint64(1700000400) + settledCertData := types.CertificateData{ + Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledCertBlockNumber, BlockTimestamp: &settledCertBlockTimestamp, + } settledCert := &types.CertificateInclusionData{CertificateData: settledCertData} settlementLeafIndex := uint32(7) settlementResult := &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, + TxHash: settlementTxHash, SettlementBlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, } claim := &types.ClaimResult{ClaimTx: common.Hash{3}, BlockNumber: 300} @@ -258,7 +271,7 @@ func TestResolveSteps(t *testing.T) { }, expectedStep: types.StepWaitingClaim, expectedQueried: []string{ - "originLER", "certificate", "certificate", "settlementGERUpdate", "claimFor", + "originLER", "certificate", "certificate", "settlementGERUpdate", "isClaimed", }, resultOf: types.StepWaitL1SettledGER, result: settlementResult, @@ -282,14 +295,15 @@ func TestResolveSteps(t *testing.T) { certificate: settledCert, settlement: settlementResult, injectedGERAtIndex: injectedGER, + claimed: true, claim: claim, }, expectedStep: types.StepClaimed, expectedQueried: []string{ "originLER", "certificate", "certificate", "settlementGERUpdate", - "injectedGERAtIndex", "claimFor", + "injectedGERAtIndex", "isClaimed", "claimFor", }, - resultOf: types.StepWaitingClaim, + resultOf: types.StepClaimed, result: claim, }, { @@ -301,7 +315,7 @@ func TestResolveSteps(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, @@ -311,7 +325,7 @@ func TestResolveSteps(t *testing.T) { }, resultOf: types.StepWaitL1SettledGER, result: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, @@ -324,7 +338,7 @@ func TestResolveSteps(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, + TxHash: settlementTxHash, SettlementBlockNumber: 400, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, l1InfoTreeIndex: &settlementLeafIndex, @@ -336,16 +350,18 @@ func TestResolveSteps(t *testing.T) { }, resultOf: types.StepWaitL1SettledGER, result: &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, + TxHash: settlementTxHash, SettlementBlockNumber: 400, L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, }, { - name: "L1->L2 claimed", - bridgeType: types.BridgeTypeL1ToL2, - facts: fakeFacts{originGER: originGER, injectedGERAtIndex: injectedGER, claim: claim}, + name: "L1->L2 claimed", + bridgeType: types.BridgeTypeL1ToL2, + facts: fakeFacts{ + originGER: originGER, injectedGERAtIndex: injectedGER, claimed: true, claim: claim, + }, expectedStep: types.StepClaimed, - expectedQueried: []string{"originGER", "injectedGERAtIndex", "claimFor"}, + expectedQueried: []string{"originGER", "injectedGERAtIndex", "isClaimed", "claimFor"}, }, { name: "L1->L2 with GER update already done skips OriginGER", @@ -366,9 +382,9 @@ func TestResolveSteps(t *testing.T) { expectedQueried: []string{"injectedGERAtIndex"}, }, { - name: "L2->L2 with every milestone done but the claim only queries the claim", + name: "L2->L2 with every milestone done but the claim only queries isClaimed and claimFor", bridgeType: types.BridgeTypeL2ToL2, - facts: fakeFacts{claim: claim}, + facts: fakeFacts{claimed: true, claim: claim}, prevSteps: []BridgeStepPath{ {Step: types.StepWaitingLERUpdate, Status: types.StepStatusDone}, {Step: types.StepPendingInclusion, Status: types.StepStatusDone}, @@ -378,8 +394,8 @@ func TestResolveSteps(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, expectedStep: types.StepClaimed, - expectedQueried: []string{"claimFor"}, - resultOf: types.StepWaitingClaim, + expectedQueried: []string{"isClaimed", "claimFor"}, + resultOf: types.StepClaimed, result: claim, }, { @@ -432,14 +448,56 @@ func TestResolveSteps(t *testing.T) { } } +// TestResolveStepsClaimedNotIndexedYet pins the window between isClaimed() going true on-chain +// and the destination bridge service indexing the claim tx: StepWaitingClaim completes in this +// same call — its on-chain check needs nothing else — but StepClaimed, which still needs its own +// ClaimFor fact (nil here, i.e. not indexed yet), stays the current step instead of being +// auto-completed alongside it (see UpdateStep's doc, "including StepClaimed, which gets its own +// resolver call") +func TestResolveStepsClaimedNotIndexedYet(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) + tracking := newTracking(types.BridgeTypeL2ToL2, []BridgeStepPath{ + {Step: types.StepWaitingLERUpdate, Status: types.StepStatusDone}, + {Step: types.StepPendingInclusion, Status: types.StepStatusDone}, + {Step: types.StepCertificatePending, Status: types.StepStatusDone}, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusDone}, + {Step: types.StepWaitingClaim, Status: types.StepStatusInProgress}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, now) + facts := fakeFacts{claimed: true} // isClaimed() -> true; ClaimFor (claim) left nil: not indexed yet + + result, err := ResolveSteps(context.Background(), log.NewLoggerNil(), testResolvers(&facts), tracking, now) + require.NoError(t, err) + require.Equal(t, []string{"isClaimed", "claimFor"}, facts.queried) + + steps := result.AllSteps() + waitingClaim := steps[indexOfStep(steps, types.StepWaitingClaim)] + require.Equal(t, types.StepStatusDone, waitingClaim.Status, "on-chain isClaimed() is authoritative on its own") + + claimed := steps[indexOfStep(steps, types.StepClaimed)] + require.Equal(t, types.StepStatusInProgress, claimed.Status, "still waiting on its own ClaimFor fact") + require.Nil(t, claimed.Result(), "no claim tx indexed yet") + + idx := result.StepIndex() + require.NotNil(t, idx) + require.Equal(t, types.StepClaimed, steps[*idx].Step) +} + func TestResolveStepsErrors(t *testing.T) { t.Parallel() now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC) originLER := &types.LERUpdateResult{NetworkID: 1, LER: common.Hash{2}, BlockNumber: 200} settlementTxHash := common.Hash{4} + settledCertBlockNumber := uint64(400) + settledCertBlockTimestamp := uint64(1700000400) settledCert := &types.CertificateInclusionData{ - CertificateData: types.CertificateData{Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash}, + CertificateData: types.CertificateData{ + Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledCertBlockNumber, BlockTimestamp: &settledCertBlockTimestamp, + }, } settlementLeafIndex := uint32(7) settlementResult := &types.L1SettledGERResult{ @@ -504,11 +562,24 @@ func TestResolveStepsErrors(t *testing.T) { originLER: originLER, certificate: settledCert, settlement: settlementResult, - claimErr: factsErr, + claimedErr: factsErr, }, expectedErr: "claim status", expectedStep: types.StepWaitingClaim, }, + { + name: "claim info error", + bridgeType: types.BridgeTypeL2ToL1, + facts: fakeFacts{ + originLER: originLER, + certificate: settledCert, + settlement: settlementResult, + claimed: true, + claimErr: factsErr, + }, + expectedErr: "claim info", + expectedStep: types.StepClaimed, + }, } for _, tc := range testCases { @@ -582,7 +653,7 @@ func TestUpdateStep(t *testing.T) { }, steps) }) - t.Run("terminal step completes the moment it is reached", func(t *testing.T) { + t.Run("completing WaitingClaim opens Claimed as InProgress, not auto-completed", func(t *testing.T) { t.Parallel() tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ @@ -592,14 +663,13 @@ func TestUpdateStep(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, t1) - claim := &types.ClaimResult{ClaimTx: common.Hash{2}, BlockNumber: 200} - advanced := UpdateStep(tracking, 2, claim, true, nil, t2) + advanced := UpdateStep(tracking, 2, nil, true, nil, t2) last := advanced.AllSteps()[len(advanced.AllSteps())-1] require.Equal(t, types.StepClaimed, last.Step) - require.Equal(t, types.StepStatusDone, last.Status) + require.Equal(t, types.StepStatusInProgress, last.Status, "Claimed now needs its own resolver call to complete") require.Equal(t, &t2, last.StartDate) - require.Equal(t, &t2, last.EndDate) + require.Nil(t, last.EndDate) }) t.Run("a successful check clears a previous transient error even without progress", func(t *testing.T) { @@ -705,6 +775,50 @@ func TestUpdateStep(t *testing.T) { require.Equal(t, 2, sp.Error.RetryCount) require.Equal(t, []string{errFakeUpdateStep.Error(), errFakeUpdateStep.Error()}, sp.Error.Description) }) + + t.Run("a stepErr wrapped as Permanent marks the step StepErrorPermanent with no retry count", func(t *testing.T) { + t.Parallel() + + tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ + {Step: types.StepWaitingGERUpdate, Status: types.StepStatusInProgress, StartDate: &t1}, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusPending}, + {Step: types.StepWaitingClaim, Status: types.StepStatusPending}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, t1) + + advanced := UpdateStep(tracking, 0, nil, false, Permanent(errFakeUpdateStep), t2) + + sp := advanced.AllSteps()[0] + require.Equal(t, types.StepStatusError, sp.Status) + require.NotNil(t, sp.Error) + require.Equal(t, types.StepErrorPermanent, sp.Error.ErrorType) + require.Equal(t, 0, sp.Error.RetryCount) + require.Equal(t, []string{errFakeUpdateStep.Error()}, sp.Error.Description) + }) + + t.Run("a repeated Permanent stepErr does not accumulate onto a previous transient history", func(t *testing.T) { + t.Parallel() + + tracking := newTracking(types.BridgeTypeL1ToL2, []BridgeStepPath{ + { + Step: types.StepWaitingGERUpdate, Status: types.StepStatusError, StartDate: &t1, + Error: &types.ErrorStep{ + ErrorType: types.StepErrorTransient, RetryCount: 3, + Description: []string{errFakeUpdateStep.Error(), errFakeUpdateStep.Error(), errFakeUpdateStep.Error()}, + }, + }, + {Step: types.StepWaitingGERInjection, Status: types.StepStatusPending}, + {Step: types.StepWaitingClaim, Status: types.StepStatusPending}, + {Step: types.StepClaimed, Status: types.StepStatusPending}, + }, t1) + + advanced := UpdateStep(tracking, 0, nil, false, Permanent(errFakeUpdateStep), t2) + + sp := advanced.AllSteps()[0] + require.Equal(t, types.StepErrorPermanent, sp.Error.ErrorType) + require.Equal(t, 0, sp.Error.RetryCount, "nothing will retry a permanent step, so the count resets") + require.Equal(t, []string{errFakeUpdateStep.Error()}, sp.Error.Description) + }) } // TestCertificateResolverSkipsWaypoints pins that a certificate observed already Settled, with @@ -726,14 +840,19 @@ func TestCertificateResolverSkipsWaypoints(t *testing.T) { {Step: types.StepClaimed, Status: types.StepStatusPending}, }, t1) + settledBlockNumber := uint64(400) + settledBlockTimestamp := uint64(1700000400) cert := &types.CertificateInclusionData{ - CertificateData: types.CertificateData{CertificateID: common.Hash{9}, Status: agglayertypes.Settled}, + CertificateData: types.CertificateData{ + CertificateID: common.Hash{9}, Status: agglayertypes.Settled, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, + }, } facts := &fakeFacts{certificate: cert} result, err := ResolveSteps(context.Background(), log.NewLoggerNil(), testResolvers(facts), tracking, t2) require.NoError(t, err) - require.Equal(t, []string{"certificate", "certificate", "claimFor"}, facts.queried) + require.Equal(t, []string{"certificate", "certificate", "isClaimed"}, facts.queried) steps := result.AllSteps() require.Equal(t, types.StepStatusDone, steps[1].Status, "PendingInclusion skipped straight through") diff --git a/bridgetracker/engine.go b/bridgetracker/engine.go index 96c55bb78..63653577b 100644 --- a/bridgetracker/engine.go +++ b/bridgetracker/engine.go @@ -82,6 +82,7 @@ type EngineSources struct { GERs GERSource WaitingGERUpdateSource domain.WaitingGERUpdateSource LERs LERSource + ClaimChecker ClaimChecker Claims ClaimSource Settlement SettlementSource } @@ -119,6 +120,8 @@ func NewEngine( return nil, errors.New("engine requires a GERSource") case sources.LERs == nil: return nil, errors.New("engine requires a LERSource") + case sources.ClaimChecker == nil: + return nil, errors.New("engine requires a ClaimChecker") case sources.Claims == nil: return nil, errors.New("engine requires a ClaimSource") case sources.Settlement == nil: @@ -144,7 +147,8 @@ func createResolvers(logger aggkitcommon.Logger, sources EngineSources) map[type types.StepCertificatePending: domain.NewCertificatePendingResolver(sources.Certificates), types.StepWaitL1SettledGER: domain.NewWaitL1SettledGERResolver(sources.Settlement, sources.GERs), types.StepWaitingGERInjection: domain.NewWaitingGERInjectionResolver(sources.GERs), - types.StepWaitingClaim: domain.NewWaitingClaimResolver(sources.Claims), + types.StepWaitingClaim: domain.NewWaitingClaimResolver(sources.ClaimChecker), + types.StepClaimed: domain.NewClaimedResolver(sources.Claims), } } diff --git a/bridgetracker/engine_test.go b/bridgetracker/engine_test.go index 0f5273cec..37f788886 100644 --- a/bridgetracker/engine_test.go +++ b/bridgetracker/engine_test.go @@ -41,8 +41,10 @@ type fakeSources struct { cert *types.CertificateInclusionData certErr error - claim *types.ClaimResult - claimErr error + claimed bool + claimedErr error + claim *types.ClaimResult + claimErr error settlement *types.L1SettledGERResult settlementErr error @@ -96,6 +98,10 @@ func (f *fakeSources) L1InfoTreeIndexForGER( return f.l1InfoTreeIndex, f.l1InfoTreeIndexErr } +func (f *fakeSources) IsClaimed(_ context.Context, _ *BridgeInfo) (bool, error) { + return f.claimed, f.claimedErr +} + func (f *fakeSources) ClaimFor(_ context.Context, _ *BridgeInfo) (*types.ClaimResult, error) { return f.claim, f.claimErr } @@ -108,7 +114,7 @@ func (f *fakeSources) SettlementGERUpdate( func (f *fakeSources) engineSources() EngineSources { return EngineSources{ - Bridges: f, Certificates: f, GERs: f, LERs: f, Claims: f, Settlement: f, + Bridges: f, Certificates: f, GERs: f, LERs: f, ClaimChecker: f, Claims: f, Settlement: f, WaitingGERUpdateSource: f, } } @@ -186,6 +192,11 @@ func TestEngineNewValidation(t *testing.T) { _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) require.ErrorContains(t, err, "LERSource") + sources = f.engineSources() + sources.ClaimChecker = nil + _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) + require.ErrorContains(t, err, "ClaimChecker") + sources = f.engineSources() sources.Claims = nil _, err = NewEngine(EngineConfig{}, logger, newMemoryRegistry(0), sources) @@ -519,10 +530,13 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { require.Equal(t, &f.cert.CertificateData, inError.AllSteps()[*inError.StepIndex()].Result(), "the certificate's current, not yet settled, status is visible while waiting") + settledBlockNumber := uint64(1500) + settledBlockTimestamp := uint64(1700001500) f.cert = &types.CertificateInclusionData{ CertificateData: types.CertificateData{ CertificateID: common.HexToHash("0x02"), Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, }, } engine.tick(t.Context()) @@ -542,7 +556,7 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { settlementLeafIndex := uint32(7) f.settlement = &types.L1SettledGERResult{ - TxHash: settlementTxHash, BlockNumber: 2000, GER: common.HexToHash("0x0b"), + TxHash: settlementTxHash, SettlementBlockNumber: 2000, GER: common.HexToHash("0x0b"), L1InfoTreeIndex: &settlementLeafIndex, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, } @@ -557,14 +571,49 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { } injectedGER := common.HexToHash("0x04") - f.injectedGERAtIndex = &types.GERData{NetworkID: 2, GER: &injectedGER, LERType: types.LERTypeLocal} + injectedGERBlockNumber := uint64(200) + injectedGERTimestamp := uint64(1700000000) + injectedGERL2BlockNumber := uint64(300) + injectedGERL2Timestamp := uint64(1700000300) + f.injectedGERAtIndex = &types.GERData{ + NetworkID: 2, GER: &injectedGER, LERType: types.LERTypeLocal, + BlockNumber: &injectedGERBlockNumber, BlockTimestamp: &injectedGERTimestamp, + L2BlockNumber: &injectedGERL2BlockNumber, L2BlockTimestamp: &injectedGERL2Timestamp, + } engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) allSteps = tracking.AllSteps() require.Equal(t, types.StepWaitingClaim, currentStep(t, store)) for _, sp := range allSteps { if sp.Step == types.StepWaitingGERInjection { - require.Equal(t, &types.InjectedGERResult{GER: injectedGER}, sp.Result()) + require.Equal(t, &types.InjectedGERResult{ + L1InfoTreeLeaf: types.InjectedGERL1Leaf{ + GER: injectedGER, BlockNumber: injectedGERBlockNumber, BlockTimestamp: injectedGERTimestamp, + }, + L2InjectedGER: &types.InjectedL2GERBlock{ + BlockNumber: injectedGERL2BlockNumber, BlockTimestamp: &injectedGERL2Timestamp, + }, + }, sp.Result()) + } + } + + // isClaimed() goes true on-chain a tick before the bridge service has indexed the claim tx: + // StepWaitingClaim completes on its own, but StepClaimed stays the current step rather than + // being auto-completed alongside it + f.claimed = true + engine.tick(t.Context()) + tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) + require.False(t, tracking.Failed()) + allSteps = tracking.AllSteps() + require.Equal(t, types.StepClaimed, allSteps[*tracking.StepIndex()].Step, + "on-chain isClaimed() completed WaitingClaim, but Claimed still needs its own ClaimFor fact") + for _, sp := range allSteps { + switch sp.Step { + case types.StepWaitingClaim: + require.Equal(t, types.StepStatusDone, sp.Status, "on-chain isClaimed() is authoritative on its own") + case types.StepClaimed: + require.Equal(t, types.StepStatusInProgress, sp.Status) + require.Nil(t, sp.Result(), "no claim tx indexed yet") } } @@ -576,10 +625,10 @@ func TestEngineLifecycleL2ToL2(t *testing.T) { require.Equal(t, types.StepClaimed, allSteps[*tracking.StepIndex()].Step) final := allSteps[len(allSteps)-1] require.Equal(t, types.StepClaimed, final.Step) - require.Equal(t, types.StepStatusDone, final.Status, "Claimed is terminal: done, not inProgress") + require.Equal(t, types.StepStatusDone, final.Status, "Claimed is done once the bridge service confirms the claim") for _, sp := range allSteps { - if sp.Step == types.StepWaitingClaim { + if sp.Step == types.StepClaimed { require.Equal(t, f.claim, sp.Result()) } } @@ -600,10 +649,13 @@ func TestEngineIncrementalResolution(t *testing.T) { // walk the bridge up to WaitingClaim: every milestone but the claim is done f.originLER = &types.LERUpdateResult{NetworkID: 1, LER: common.HexToHash("0x0a"), BlockNumber: 10} + settledBlockNumber := uint64(1500) + settledBlockTimestamp := uint64(1700001500) f.cert = &types.CertificateInclusionData{ CertificateData: types.CertificateData{ CertificateID: common.HexToHash("0x01"), Status: agglayertypes.Settled, SettlementTxHash: &settlementTxHash, + BlockNumber: &settledBlockNumber, BlockTimestamp: &settledBlockTimestamp, }, } settlementLeafIndex := uint32(7) @@ -634,7 +686,8 @@ func TestEngineIncrementalResolution(t *testing.T) { require.Nil(t, tracking.Error(), "done milestones must not be re-queried") require.Equal(t, types.StepWaitingClaim, currentStep(t, store)) - // the bridge still finishes through the only remaining fact, the claim + // the bridge still finishes through the only remaining facts, the claim status and its record + f.claimed = true f.claim = &types.ClaimResult{ClaimTx: common.HexToHash("0x03"), BlockNumber: 30} engine.tick(t.Context()) tracking = mustGet(t, store, TrackingID{NetworkID: 1, TxHash: testHash}) diff --git a/bridgetracker/mocks/mock_activity_bridge_scanner.go b/bridgetracker/mocks/mock_activity_bridge_scanner.go new file mode 100644 index 000000000..bef53b739 --- /dev/null +++ b/bridgetracker/mocks/mock_activity_bridge_scanner.go @@ -0,0 +1,109 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + domain "github.com/agglayer/aggkit/bridgetracker/domain" + + mock "github.com/stretchr/testify/mock" +) + +// ActivityBridgeScanner is an autogenerated mock type for the ActivityBridgeScanner type +type ActivityBridgeScanner struct { + mock.Mock +} + +type ActivityBridgeScanner_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityBridgeScanner) EXPECT() *ActivityBridgeScanner_Expecter { + return &ActivityBridgeScanner_Expecter{mock: &_m.Mock} +} + +// BridgesFrom provides a mock function with given fields: ctx, fromAddress, known +func (_m *ActivityBridgeScanner) BridgesFrom(ctx context.Context, fromAddress common.Address, known map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { + ret := _m.Called(ctx, fromAddress, known) + + if len(ret) == 0 { + panic("no return value specified for BridgesFrom") + } + + var r0 []*domain.ScannedBridge + var r1 []domain.ActivityWarning + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error)); ok { + return rf(ctx, fromAddress, known) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, map[string]struct{}) []*domain.ScannedBridge); ok { + r0 = rf(ctx, fromAddress, known) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain.ScannedBridge) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, map[string]struct{}) []domain.ActivityWarning); ok { + r1 = rf(ctx, fromAddress, known) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.ActivityWarning) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, common.Address, map[string]struct{}) error); ok { + r2 = rf(ctx, fromAddress, known) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ActivityBridgeScanner_BridgesFrom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BridgesFrom' +type ActivityBridgeScanner_BridgesFrom_Call struct { + *mock.Call +} + +// BridgesFrom is a helper method to define mock.On call +// - ctx context.Context +// - fromAddress common.Address +// - known map[string]struct{} +func (_e *ActivityBridgeScanner_Expecter) BridgesFrom(ctx interface{}, fromAddress interface{}, known interface{}) *ActivityBridgeScanner_BridgesFrom_Call { + return &ActivityBridgeScanner_BridgesFrom_Call{Call: _e.mock.On("BridgesFrom", ctx, fromAddress, known)} +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Run(run func(ctx context.Context, fromAddress common.Address, known map[string]struct{})) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(map[string]struct{})) + }) + return _c +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) Return(_a0 []*domain.ScannedBridge, _a1 []domain.ActivityWarning, _a2 error) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *ActivityBridgeScanner_BridgesFrom_Call) RunAndReturn(run func(context.Context, common.Address, map[string]struct{}) ([]*domain.ScannedBridge, []domain.ActivityWarning, error)) *ActivityBridgeScanner_BridgesFrom_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityBridgeScanner creates a new instance of ActivityBridgeScanner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityBridgeScanner(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityBridgeScanner { + mock := &ActivityBridgeScanner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/mocks/mock_activity_claim_checker.go b/bridgetracker/mocks/mock_activity_claim_checker.go new file mode 100644 index 000000000..4026160a3 --- /dev/null +++ b/bridgetracker/mocks/mock_activity_claim_checker.go @@ -0,0 +1,155 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + domain "github.com/agglayer/aggkit/bridgetracker/domain" + mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/bridgeservice/types" +) + +// ActivityClaimChecker is an autogenerated mock type for the ActivityClaimChecker type +type ActivityClaimChecker struct { + mock.Mock +} + +type ActivityClaimChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityClaimChecker) EXPECT() *ActivityClaimChecker_Expecter { + return &ActivityClaimChecker_Expecter{mock: &_m.Mock} +} + +// ClaimInfo provides a mock function with given fields: ctx, bridge +func (_m *ActivityClaimChecker) ClaimInfo(ctx context.Context, bridge *domain.ScannedBridge) (*types.ClaimResponse, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for ClaimInfo") + } + + var r0 *types.ClaimResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) (*types.ClaimResponse, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) *types.ClaimResponse); ok { + r0 = rf(ctx, bridge) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.ClaimResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *domain.ScannedBridge) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityClaimChecker_ClaimInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClaimInfo' +type ActivityClaimChecker_ClaimInfo_Call struct { + *mock.Call +} + +// ClaimInfo is a helper method to define mock.On call +// - ctx context.Context +// - bridge *domain.ScannedBridge +func (_e *ActivityClaimChecker_Expecter) ClaimInfo(ctx interface{}, bridge interface{}) *ActivityClaimChecker_ClaimInfo_Call { + return &ActivityClaimChecker_ClaimInfo_Call{Call: _e.mock.On("ClaimInfo", ctx, bridge)} +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) Run(run func(ctx context.Context, bridge *domain.ScannedBridge)) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*domain.ScannedBridge)) + }) + return _c +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) Return(_a0 *types.ClaimResponse, _a1 error) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityClaimChecker_ClaimInfo_Call) RunAndReturn(run func(context.Context, *domain.ScannedBridge) (*types.ClaimResponse, error)) *ActivityClaimChecker_ClaimInfo_Call { + _c.Call.Return(run) + return _c +} + +// IsClaimed provides a mock function with given fields: ctx, bridge +func (_m *ActivityClaimChecker) IsClaimed(ctx context.Context, bridge *domain.ScannedBridge) (bool, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for IsClaimed") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) (bool, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *domain.ScannedBridge) bool); ok { + r0 = rf(ctx, bridge) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, *domain.ScannedBridge) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ActivityClaimChecker_IsClaimed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsClaimed' +type ActivityClaimChecker_IsClaimed_Call struct { + *mock.Call +} + +// IsClaimed is a helper method to define mock.On call +// - ctx context.Context +// - bridge *domain.ScannedBridge +func (_e *ActivityClaimChecker_Expecter) IsClaimed(ctx interface{}, bridge interface{}) *ActivityClaimChecker_IsClaimed_Call { + return &ActivityClaimChecker_IsClaimed_Call{Call: _e.mock.On("IsClaimed", ctx, bridge)} +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *domain.ScannedBridge)) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*domain.ScannedBridge)) + }) + return _c +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) Return(_a0 bool, _a1 error) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ActivityClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *domain.ScannedBridge) (bool, error)) *ActivityClaimChecker_IsClaimed_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityClaimChecker creates a new instance of ActivityClaimChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityClaimChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityClaimChecker { + mock := &ActivityClaimChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/mocks/mock_activity_querier.go b/bridgetracker/mocks/mock_activity_querier.go new file mode 100644 index 000000000..07fd4eb09 --- /dev/null +++ b/bridgetracker/mocks/mock_activity_querier.go @@ -0,0 +1,112 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + domain "github.com/agglayer/aggkit/bridgetracker/domain" + + mock "github.com/stretchr/testify/mock" + + types "github.com/agglayer/aggkit/bridgetracker/types" +) + +// ActivityQuerier is an autogenerated mock type for the ActivityQuerier type +type ActivityQuerier struct { + mock.Mock +} + +type ActivityQuerier_Expecter struct { + mock *mock.Mock +} + +func (_m *ActivityQuerier) EXPECT() *ActivityQuerier_Expecter { + return &ActivityQuerier_Expecter{mock: &_m.Mock} +} + +// GetActivity provides a mock function with given fields: ctx, fromAddress, includeTracking, filter +func (_m *ActivityQuerier) GetActivity(ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error) { + ret := _m.Called(ctx, fromAddress, includeTracking, filter) + + if len(ret) == 0 { + panic("no return value specified for GetActivity") + } + + var r0 []*domain.ActivityEntry + var r1 []domain.ActivityWarning + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool, types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error)); ok { + return rf(ctx, fromAddress, includeTracking, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, common.Address, bool, types.ActivityFilter) []*domain.ActivityEntry); ok { + r0 = rf(ctx, fromAddress, includeTracking, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*domain.ActivityEntry) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, common.Address, bool, types.ActivityFilter) []domain.ActivityWarning); ok { + r1 = rf(ctx, fromAddress, includeTracking, filter) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.ActivityWarning) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, common.Address, bool, types.ActivityFilter) error); ok { + r2 = rf(ctx, fromAddress, includeTracking, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ActivityQuerier_GetActivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActivity' +type ActivityQuerier_GetActivity_Call struct { + *mock.Call +} + +// GetActivity is a helper method to define mock.On call +// - ctx context.Context +// - fromAddress common.Address +// - includeTracking bool +// - filter types.ActivityFilter +func (_e *ActivityQuerier_Expecter) GetActivity(ctx interface{}, fromAddress interface{}, includeTracking interface{}, filter interface{}) *ActivityQuerier_GetActivity_Call { + return &ActivityQuerier_GetActivity_Call{Call: _e.mock.On("GetActivity", ctx, fromAddress, includeTracking, filter)} +} + +func (_c *ActivityQuerier_GetActivity_Call) Run(run func(ctx context.Context, fromAddress common.Address, includeTracking bool, filter types.ActivityFilter)) *ActivityQuerier_GetActivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(common.Address), args[2].(bool), args[3].(types.ActivityFilter)) + }) + return _c +} + +func (_c *ActivityQuerier_GetActivity_Call) Return(_a0 []*domain.ActivityEntry, _a1 []domain.ActivityWarning, _a2 error) *ActivityQuerier_GetActivity_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *ActivityQuerier_GetActivity_Call) RunAndReturn(run func(context.Context, common.Address, bool, types.ActivityFilter) ([]*domain.ActivityEntry, []domain.ActivityWarning, error)) *ActivityQuerier_GetActivity_Call { + _c.Call.Return(run) + return _c +} + +// NewActivityQuerier creates a new instance of ActivityQuerier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewActivityQuerier(t interface { + mock.TestingT + Cleanup(func()) +}) *ActivityQuerier { + mock := &ActivityQuerier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/mocks/mock_claim_checker.go b/bridgetracker/mocks/mock_claim_checker.go new file mode 100644 index 000000000..889f0e2dc --- /dev/null +++ b/bridgetracker/mocks/mock_claim_checker.go @@ -0,0 +1,95 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + context "context" + + bridgetracker "github.com/agglayer/aggkit/bridgetracker" + + mock "github.com/stretchr/testify/mock" +) + +// ClaimChecker is an autogenerated mock type for the ClaimChecker type +type ClaimChecker struct { + mock.Mock +} + +type ClaimChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *ClaimChecker) EXPECT() *ClaimChecker_Expecter { + return &ClaimChecker_Expecter{mock: &_m.Mock} +} + +// IsClaimed provides a mock function with given fields: ctx, bridge +func (_m *ClaimChecker) IsClaimed(ctx context.Context, bridge *bridgetracker.BridgeInfo) (bool, error) { + ret := _m.Called(ctx, bridge) + + if len(ret) == 0 { + panic("no return value specified for IsClaimed") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *bridgetracker.BridgeInfo) (bool, error)); ok { + return rf(ctx, bridge) + } + if rf, ok := ret.Get(0).(func(context.Context, *bridgetracker.BridgeInfo) bool); ok { + r0 = rf(ctx, bridge) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, *bridgetracker.BridgeInfo) error); ok { + r1 = rf(ctx, bridge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ClaimChecker_IsClaimed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsClaimed' +type ClaimChecker_IsClaimed_Call struct { + *mock.Call +} + +// IsClaimed is a helper method to define mock.On call +// - ctx context.Context +// - bridge *bridgetracker.BridgeInfo +func (_e *ClaimChecker_Expecter) IsClaimed(ctx interface{}, bridge interface{}) *ClaimChecker_IsClaimed_Call { + return &ClaimChecker_IsClaimed_Call{Call: _e.mock.On("IsClaimed", ctx, bridge)} +} + +func (_c *ClaimChecker_IsClaimed_Call) Run(run func(ctx context.Context, bridge *bridgetracker.BridgeInfo)) *ClaimChecker_IsClaimed_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*bridgetracker.BridgeInfo)) + }) + return _c +} + +func (_c *ClaimChecker_IsClaimed_Call) Return(_a0 bool, _a1 error) *ClaimChecker_IsClaimed_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ClaimChecker_IsClaimed_Call) RunAndReturn(run func(context.Context, *bridgetracker.BridgeInfo) (bool, error)) *ClaimChecker_IsClaimed_Call { + _c.Call.Return(run) + return _c +} + +// NewClaimChecker creates a new instance of ClaimChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClaimChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *ClaimChecker { + mock := &ClaimChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/bridgetracker/ports.go b/bridgetracker/ports.go index b08e30c16..2d9bbfac7 100644 --- a/bridgetracker/ports.go +++ b/bridgetracker/ports.go @@ -102,13 +102,39 @@ type LERSource interface { OriginLER(ctx context.Context, bridge *BridgeInfo) (*types.LERUpdateResult, error) } -// ClaimSource is the driven port to the claim state of a bridge on its destination network +// ClaimChecker is the driven port to whether a bridge has been claimed on its destination +// network, per isClaimed() on the destination bridge contract — the on-chain source of truth +// StepWaitingClaim resolves against +type ClaimChecker interface { + // IsClaimed reports whether bridge has already been claimed on its destination network + IsClaimed(ctx context.Context, bridge *BridgeInfo) (bool, error) +} + +// ClaimSource is the driven port to the claim record of a bridge on its destination network type ClaimSource interface { // ClaimFor returns the claim transaction of the bridge on the destination network, or - // nil if it has not been claimed yet + // nil if the destination network's bridge service has not indexed it yet ClaimFor(ctx context.Context, bridge *BridgeInfo) (*types.ClaimResult, error) } +// ActivityEntry is one bridge found for a from_address by the GET /activity/from/{from_address} +// endpoint, enriched with its claim/tracking state; see domain.ActivityEntry +type ActivityEntry = domain.ActivityEntry + +// ActivityBridgeScanner is the driven port to the raw bridge-service data behind the activity +// endpoint: every bridge sent by a given address, across every configured bridge service +type ActivityBridgeScanner = domain.ActivityBridgeScanner + +// ActivityClaimChecker is the driven port to a bridge's claim state on its destination network +type ActivityClaimChecker = domain.ActivityClaimChecker + +// ActivityQuerier is the driven port the activity endpoint depends on +type ActivityQuerier = domain.ActivityQuerier + +// BridgeAddressResolver is the driven port the bridge-address endpoint depends on: it resolves +// the bridge contract address for one network, or enumerates every network currently known +type BridgeAddressResolver = domain.BridgeAddressResolver + // SettlementSource is the driven port to the L1 evidence a certificate's settlement produces: // the RollupManager/GlobalExitRoot events (VerifyBatchesTrustedAggregator, UpdateL1InfoTree[V2]) // emitted by the settlement tx itself diff --git a/bridgetracker/sources/activity.go b/bridgetracker/sources/activity.go new file mode 100644 index 000000000..408b79bd0 --- /dev/null +++ b/bridgetracker/sources/activity.go @@ -0,0 +1,168 @@ +package sources + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/bridgeservice/client" + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgetracker/domain" + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum/common" +) + +// activityPageSize is the page size used to page through a network's GET /bridge/v1/bridges +// while scanning for a given from_address (see ActivitySource.BridgesFrom) +const activityPageSize = uint32(100) + +// NetworkLister widens NetworkURLResolver with network enumeration and bridge contract address +// resolution: it is the slice of bridgeservicefinder.Finder ActivitySource needs on top of the +// per-network URL lookup every other source already uses, so it knows which bridge services to +// scan for a given address (without a fixed config list) and which contract to check +// isClaimed() against. bridgeservicefinder.Finder satisfies it. +type NetworkLister interface { + NetworkURLResolver + // NetworkIDs returns the networkIDs of every network currently resolved + NetworkIDs() []uint32 + // BridgeAddress returns the bridge contract address for networkID (see + // bridgeservicefinder.Finder.BridgeAddress for the resolution/override rules) + BridgeAddress(ctx context.Context, networkID uint32) (common.Address, error) +} + +// ActivitySource implements bridgetracker.ActivityBridgeScanner and ActivityClaimChecker: it +// scans every network the finder currently knows about for bridges sent by a given address +// (via each network's own bridge service), and resolves a bridge's claim state on its +// destination network — isClaimed() on the destination bridge contract as the source of truth, +// then the destination bridge service's own claim record once claimed. +type ActivitySource struct { + logger aggkitcommon.Logger + services *bridgeServiceClients + finder NetworkLister + // contractClaimCheckers resolves/caches the on-chain isClaimed() binding per destination + // network; embedded so tests can still reach newContract directly (see claim_checker.go, + // shared with ClaimChecker so the binding/cache logic isn't duplicated between them) + *contractClaimCheckers +} + +// NewActivitySource returns an ActivitySource resolving bridge services, JSON-RPC clients and +// destination bridge contract addresses through finder/ethClients (see +// bridgeservicefinder.Finder.BridgeAddress for how a destination network's contract address is +// resolved and overridden) +func NewActivitySource(finder NetworkLister, ethClients EthClientResolver, logger aggkitcommon.Logger) *ActivitySource { + return &ActivitySource{ + logger: logger, + services: newBridgeServiceClients(finder), + finder: finder, + contractClaimCheckers: newContractClaimCheckers(finder, ethClients), + } +} + +// BridgesFrom implements bridgetracker.ActivityBridgeScanner: it queries every network's own +// bridge service GET /bridge/v1/bridges filtered by from_address, paging until either a short +// page or an already-known bridge is reached (see fetchNewBridgesFrom — this relies on the +// bridge service reporting bridges newest-first). A network that cannot be reached is logged and +// skipped, reported back as a domain.ActivityWarning, rather than failing the whole scan, so one +// misbehaving bridge service does not hide every other network's activity. +func (s *ActivitySource) BridgesFrom( + ctx context.Context, fromAddress common.Address, known map[string]struct{}, +) ([]*domain.ScannedBridge, []domain.ActivityWarning, error) { + addr := fromAddress.Hex() + + var all []*domain.ScannedBridge + var warnings []domain.ActivityWarning + for _, networkID := range s.finder.NetworkIDs() { + svc, err := s.services.aggkitBridgeClientFor(networkID) + if err != nil { + warnings = append(warnings, s.warnf(networkID, + "resolving bridge service client for network %d: %v", networkID, err)) + continue + } + + items, err := fetchNewBridgesFrom(ctx, svc, networkID, addr, activityPageSize, known) + if err != nil { + warnings = append(warnings, s.warnf(networkID, + "fetching bridges from %s on network %d: %v", fromAddress, networkID, err)) + continue + } + all = append(all, items...) + } + return all, warnings, nil +} + +// warnf logs msg (formatted per fmt.Sprintf's rules on format/args) and turns it into the +// domain.ActivityWarning BridgesFrom reports back for networkID +func (s *ActivitySource) warnf(networkID uint32, format string, args ...any) domain.ActivityWarning { + message := fmt.Sprintf(format, args...) + s.logger.Warnf("activity: %s", message) + return domain.ActivityWarning{NetworkID: networkID, Message: message} +} + +// fetchNewBridgesFrom pages through networkID's GET /bridge/v1/bridges filtered by fromAddress, +// newest bridge first (the bridge service's own order, by descending deposit_count), stopping as +// soon as either a page shorter than pageSize is returned (no more data) or a bridge already in +// known is reached. The latter is safe because the feed is append-only and strictly ordered: +// once a known bridge is seen, every bridge after it (same page or later pages) is guaranteed +// already known too, so nothing new is missed by stopping there. Each returned bridge is paired +// with networkID — the network whose bridge service reported it — via domain.ScannedBridge, +// since that is NOT always the same as the bridge's own OriginNetwork field (see ScannedBridge). +func fetchNewBridgesFrom( + ctx context.Context, svc *client.Client, networkID uint32, fromAddress string, pageSize uint32, + known map[string]struct{}, +) ([]*domain.ScannedBridge, error) { + var out []*domain.ScannedBridge + for page := uint32(1); ; page++ { + res, err := svc.GetBridges(ctx, client.GetBridgesParams{ + NetworkID: networkID, + FromAddress: &fromAddress, + PageNumber: &page, + PageSize: &pageSize, + }) + if err != nil { + return nil, err + } + for _, b := range res.Bridges { + if _, ok := known[b.GlobalIndex.String()]; ok { + return out, nil + } + out = append(out, &domain.ScannedBridge{Bridge: b, NetworkID: networkID}) + } + if uint32(len(res.Bridges)) < pageSize { + return out, nil + } + } +} + +// IsClaimed implements bridgetracker.ActivityClaimChecker: it calls isClaimed() on bridge's +// destination bridge contract. The on-chain sourceBridgeNetwork argument is bridge.NetworkID — +// the network the bridge-creating tx was actually sent to — never bridge.Bridge.OriginNetwork, +// which can differ for a re-bridged asset (see domain.ScannedBridge) +func (s *ActivitySource) IsClaimed(ctx context.Context, bridge *domain.ScannedBridge) (bool, error) { + return s.isClaimed(ctx, bridge.Bridge.DestinationNetwork, bridge.Bridge.DepositCount, bridge.NetworkID) +} + +// ClaimInfo implements bridgetracker.ActivityClaimChecker: it asks bridge's destination +// network's bridge service for the claim record matching bridge's global index +func (s *ActivitySource) ClaimInfo( + ctx context.Context, bridge *domain.ScannedBridge, +) (*bridgeservicetypes.ClaimResponse, error) { + svc, err := s.services.aggkitBridgeClientFor(bridge.Bridge.DestinationNetwork) + if err != nil { + return nil, err + } + + res, err := svc.GetClaims(ctx, client.GetClaimsParams{ + NetworkID: bridge.Bridge.DestinationNetwork, + GlobalIndex: bridge.Bridge.GlobalIndex, + }) + if isNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("fetching claim of global index %s on network %d: %w", + bridge.Bridge.GlobalIndex, bridge.Bridge.DestinationNetwork, err) + } + if res.Count == 0 || len(res.Claims) == 0 { + return nil, nil + } + return res.Claims[0], nil +} diff --git a/bridgetracker/sources/activity_test.go b/bridgetracker/sources/activity_test.go new file mode 100644 index 000000000..67c0958bc --- /dev/null +++ b/bridgetracker/sources/activity_test.go @@ -0,0 +1,334 @@ +package sources + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + bridgeservicetypes "github.com/agglayer/aggkit/bridgeservice/types" + "github.com/agglayer/aggkit/bridgeservicefinder" + "github.com/agglayer/aggkit/bridgetracker/domain" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const testFromAddress = "0x1111111111111111111111111111111111111111" + +// fakeActivityBridgeService emulates the bridge-service endpoints ActivitySource consumes: +// GET /bridge/v1/bridges (paginated, filtered by network_id/from_address) and +// GET /bridge/v1/claims (filtered by network_id/global_index). +type fakeActivityBridgeService struct { + // bridgesByNetwork holds every bridge served for a network, in page order + bridgesByNetwork map[uint32][]*bridgeservicetypes.BridgeResponse + // claimsByGlobalIndex holds the claim served for a given global index (decimal string), if any + claimsByGlobalIndex map[string]*bridgeservicetypes.ClaimResponse +} + +func (f *fakeActivityBridgeService) start(t *testing.T) string { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/bridge/v1/bridges", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + networkID, err := strconv.ParseUint(q.Get("network_id"), 10, 32) + require.NoError(t, err) + pageNumber, err := strconv.Atoi(q.Get("page_number")) + require.NoError(t, err) + pageSize, err := strconv.Atoi(q.Get("page_size")) + require.NoError(t, err) + + var matching []*bridgeservicetypes.BridgeResponse + for _, b := range f.bridgesByNetwork[uint32(networkID)] { + if from := q.Get("from_address"); from != "" && (b.FromAddress == nil || string(*b.FromAddress) != from) { + continue + } + matching = append(matching, b) + } + + start := (pageNumber - 1) * pageSize + end := min(start+pageSize, len(matching)) + if start > len(matching) { + start = len(matching) + } + page := matching[start:end] + + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.BridgesResult{ + Bridges: page, Count: len(matching), + })) + }) + mux.HandleFunc("/bridge/v1/claims", func(w http.ResponseWriter, r *http.Request) { + globalIndex := r.URL.Query().Get("global_index") + claim, ok := f.claimsByGlobalIndex[globalIndex] + if !ok { + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.ClaimsResult{Count: 0})) + return + } + require.NoError(t, json.NewEncoder(w).Encode(bridgeservicetypes.ClaimsResult{ + Claims: []*bridgeservicetypes.ClaimResponse{claim}, Count: 1, + })) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server.URL +} + +// fakeNetworkLister is a fixed NetworkLister for tests: every networkID resolves to the same +// bridge service base URL. bridgeAddrs backs BridgeAddress; a networkID absent from it errors +// (bridgeAddrErr if set, a generic "not configured" error otherwise), mirroring +// bridgeservicefinder's own behaviour when neither an override nor the on-chain default applies. +type fakeNetworkLister struct { + networkIDs []uint32 + url string + bridgeAddrs map[uint32]common.Address + bridgeAddrErr error +} + +func (f fakeNetworkLister) GetURL(uint32) (bridgeservicefinder.NetworkURLs, error) { + return bridgeservicefinder.NetworkURLs{BridgeURL: f.url}, nil +} + +func (f fakeNetworkLister) NetworkIDs() []uint32 { return f.networkIDs } + +func (f fakeNetworkLister) BridgeAddress(_ context.Context, networkID uint32) (common.Address, error) { + if addr, ok := f.bridgeAddrs[networkID]; ok { + return addr, nil + } + if f.bridgeAddrErr != nil { + return common.Address{}, f.bridgeAddrErr + } + return common.Address{}, fmt.Errorf("no bridge contract address configured for network %d", networkID) +} + +func bridgeResponse(networkID, destNetwork, depositCount uint32, from string, globalIndex int64) *bridgeservicetypes.BridgeResponse { + fromAddr := bridgeservicetypes.Address(from) + return &bridgeservicetypes.BridgeResponse{ + OriginNetwork: networkID, + DestinationNetwork: destNetwork, + DepositCount: depositCount, + FromAddress: &fromAddr, + GlobalIndex: big.NewInt(globalIndex), + TxHash: bridgeservicetypes.Hash(fmt.Sprintf("0x%d", globalIndex)), + } +} + +// TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork verifies BridgesFrom pages +// through each network until a short page, scans every network the lister reports, and filters +// by from_address. +func TestActivitySource_BridgesFrom_PaginatesAndScansEveryNetwork(t *testing.T) { + other := "0x2222222222222222222222222222222222222222" + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 0, testFromAddress, 1), + bridgeResponse(1, 2, 1, testFromAddress, 2), + bridgeResponse(1, 2, 2, testFromAddress, 3), + bridgeResponse(1, 2, 3, other, 4), // different sender, must be filtered out + }, + 2: { + bridgeResponse(2, 1, 0, testFromAddress, 5), + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1, 2}, url: url} + + source := NewActivitySource(lister, nil, testLogger) + + items, warnings, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) + require.NoError(t, err) + require.Empty(t, warnings) + require.Len(t, items, 4) + + globalIndexes := make([]int64, 0, len(items)) + for _, item := range items { + globalIndexes = append(globalIndexes, item.Bridge.GlobalIndex.Int64()) + } + require.ElementsMatch(t, []int64{1, 2, 3, 5}, globalIndexes) +} + +// TestActivitySource_BridgesFrom_SkipsUnreachableNetworkAndWarns verifies a network whose bridge +// service cannot be resolved is skipped rather than failing the whole scan, and is reported back +// as an ActivityWarning — every other network's bridges are still returned. +func TestActivitySource_BridgesFrom_SkipsUnreachableNetworkAndWarns(t *testing.T) { + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: {bridgeResponse(1, 2, 0, testFromAddress, 1)}, + }, + } + url := svc.start(t) + // networkID 2 resolves to an empty bridge service URL, which aggkitBridgeClientFor rejects + lister := fakeMixedNetworkLister{ + networkIDs: []uint32{1, 2}, + urls: map[uint32]string{1: url}, + } + + source := NewActivitySource(lister, nil, testLogger) + + items, warnings, err := source.BridgesFrom(t.Context(), common.HexToAddress(testFromAddress), nil) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, int64(1), items[0].Bridge.GlobalIndex.Int64()) + + require.Len(t, warnings, 1) + require.Equal(t, uint32(2), warnings[0].NetworkID) + require.Contains(t, warnings[0].Message, "network 2") +} + +// fakeMixedNetworkLister is a NetworkLister whose GetURL result varies per network, unlike +// fakeNetworkLister's single fixed URL — used to simulate one network being unreachable while +// others resolve fine. +type fakeMixedNetworkLister struct { + networkIDs []uint32 + urls map[uint32]string +} + +func (f fakeMixedNetworkLister) GetURL(networkID uint32) (bridgeservicefinder.NetworkURLs, error) { + return bridgeservicefinder.NetworkURLs{BridgeURL: f.urls[networkID]}, nil +} + +func (f fakeMixedNetworkLister) NetworkIDs() []uint32 { return f.networkIDs } + +func (f fakeMixedNetworkLister) BridgeAddress(context.Context, uint32) (common.Address, error) { + return common.Address{}, errors.New("not implemented") +} + +// TestFetchNewBridgesFrom_Pagination exercises the pagination loop directly with a small page +// size, so a short page (fewer results than requested) stops the loop. +func TestFetchNewBridgesFrom_Pagination(t *testing.T) { + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 0, testFromAddress, 1), + bridgeResponse(1, 2, 1, testFromAddress, 2), + bridgeResponse(1, 2, 2, testFromAddress, 3), + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} + source := NewActivitySource(lister, nil, testLogger) + client, err := source.services.aggkitBridgeClientFor(1) + require.NoError(t, err) + + items, err := fetchNewBridgesFrom(t.Context(), client, 1, testFromAddress, 2, nil) + require.NoError(t, err) + require.Len(t, items, 3) +} + +// TestFetchNewBridgesFrom_StopsAtFirstKnownBridge verifies pagination stops as soon as an +// already-known bridge is reached, without walking further pages, and returns only the bridges +// found before it (the newer ones, per the server's newest-first order). +func TestFetchNewBridgesFrom_StopsAtFirstKnownBridge(t *testing.T) { + // bridgesByNetwork is given newest-first (global index 3, then 2, then 1), matching the real + // bridge service's own deposit_count DESC order + svc := &fakeActivityBridgeService{ + bridgesByNetwork: map[uint32][]*bridgeservicetypes.BridgeResponse{ + 1: { + bridgeResponse(1, 2, 2, testFromAddress, 3), + bridgeResponse(1, 2, 1, testFromAddress, 2), // already known: pagination stops here + bridgeResponse(1, 2, 0, testFromAddress, 1), // must never be fetched + }, + }, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{1}, url: url} + source := NewActivitySource(lister, nil, testLogger) + client, err := source.services.aggkitBridgeClientFor(1) + require.NoError(t, err) + + known := map[string]struct{}{"2": {}} + items, err := fetchNewBridgesFrom(t.Context(), client, 1, testFromAddress, 1, known) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, int64(3), items[0].Bridge.GlobalIndex.Int64()) +} + +// TestActivitySource_IsClaimed_NoBridgeAddrConfigured verifies IsClaimed errors clearly when +// the destination network has no bridge contract address configured. +func TestActivitySource_IsClaimed_NoBridgeAddrConfigured(t *testing.T) { + source := NewActivitySource(fakeNetworkLister{}, StaticClients{}, testLogger) + + bridge := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 3, testFromAddress, 1), NetworkID: 1} + _, err := source.IsClaimed(t.Context(), bridge) + require.ErrorContains(t, err, "no bridge contract address configured for network 2") +} + +// TestActivitySource_IsClaimed_UsesScannedNetworkNotBridgeOriginNetwork verifies IsClaimed binds +// the destination network's contract and calls isClaimed(depositCount, scannedNetworkID) using +// ScannedBridge.NetworkID — never Bridge.OriginNetwork, which is set here to a deliberately +// different value to prove the two are not confused (see domain.ScannedBridge) — and that the +// binding is cached across calls. +func TestActivitySource_IsClaimed_UsesScannedNetworkNotBridgeOriginNetwork(t *testing.T) { + destAddr := common.HexToAddress("0xdead") + client := StaticClients{2: nil} + + stub := &stubClaimChecker{claimed: true} + buildCalls := 0 + lister := fakeNetworkLister{bridgeAddrs: map[uint32]common.Address{2: destAddr}} + source := NewActivitySource(lister, client, testLogger) + source.newContract = func(addr common.Address, _ aggkittypes.BaseEthereumClienter) (claimChecker, error) { + buildCalls++ + require.Equal(t, destAddr, addr) + return stub, nil + } + + // OriginNetwork (99) is the asset-origin decoy: isClaimed must use NetworkID (5, the network + // the deposit tx was actually sent to) instead + const scannedNetworkID = uint32(5) + bridge := &domain.ScannedBridge{Bridge: bridgeResponse(99, 2, 9, testFromAddress, 1), NetworkID: scannedNetworkID} + claimed, err := source.IsClaimed(t.Context(), bridge) + require.NoError(t, err) + require.True(t, claimed) + require.Equal(t, uint32(9), stub.lastLeafIndex) + require.Equal(t, scannedNetworkID, stub.lastSourceNetwork) + + // A second call for the same destination network reuses the cached binding + _, err = source.IsClaimed(t.Context(), bridge) + require.NoError(t, err) + require.Equal(t, 1, buildCalls) +} + +// stubClaimChecker is an injectable claimChecker for tests +type stubClaimChecker struct { + claimed bool + err error + lastLeafIndex uint32 + lastSourceNetwork uint32 +} + +func (s *stubClaimChecker) IsClaimed(_ *bind.CallOpts, leafIndex, sourceBridgeNetwork uint32) (bool, error) { + s.lastLeafIndex = leafIndex + s.lastSourceNetwork = sourceBridgeNetwork + return s.claimed, s.err +} + +// TestActivitySource_ClaimInfo verifies ClaimInfo fetches the raw claim record by global index, +// and returns nil (not an error) when the destination bridge service has not indexed it yet. +func TestActivitySource_ClaimInfo(t *testing.T) { + claim := &bridgeservicetypes.ClaimResponse{TxHash: "0xclaimtx", GlobalIndex: "1"} + svc := &fakeActivityBridgeService{ + claimsByGlobalIndex: map[string]*bridgeservicetypes.ClaimResponse{"1": claim}, + } + url := svc.start(t) + lister := fakeNetworkLister{networkIDs: []uint32{2}, url: url} + source := NewActivitySource(lister, nil, testLogger) + + found := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 0, testFromAddress, 1), NetworkID: 1} + got, err := source.ClaimInfo(t.Context(), found) + require.NoError(t, err) + require.Equal(t, claim, got) + + notIndexedYet := &domain.ScannedBridge{Bridge: bridgeResponse(1, 2, 0, testFromAddress, 999), NetworkID: 1} + got, err = source.ClaimInfo(t.Context(), notIndexedYet) + require.NoError(t, err) + require.Nil(t, got) +} diff --git a/bridgetracker/sources/certificate.go b/bridgetracker/sources/certificate.go index bc6b924b1..1b4ff7d4e 100644 --- a/bridgetracker/sources/certificate.go +++ b/bridgetracker/sources/certificate.go @@ -2,12 +2,14 @@ package sources import ( "context" + "errors" "fmt" agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/bridgetracker" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" ) @@ -28,16 +30,21 @@ type CertificateSource struct { // services resolves bridge.NetworkID's own aggkit bridge service, used to translate a // certificate's NewLocalExitRoot into a deposit-count position (see rootIndexFor) services *bridgeServiceClients - logger aggkitcommon.Logger + // clients resolves L1's JSON-RPC client, used to locate a settled certificate's settlement + // tx once it is visible there (see settlementBlockInfo) + clients EthClientResolver + logger aggkitcommon.Logger } // NewCertificateSource returns a CertificateSource fetching certificate headers through client, -// and resolving local exit root positions through the per-network bridge service clients finder -// resolves +// resolving local exit root positions through the per-network bridge service clients finder +// resolves, and locating settlement txs on L1 through clients func NewCertificateSource( - client CertificateHeaderClient, finder NetworkURLResolver, logger aggkitcommon.Logger, + client CertificateHeaderClient, finder NetworkURLResolver, clients EthClientResolver, logger aggkitcommon.Logger, ) *CertificateSource { - return &CertificateSource{client: client, services: newBridgeServiceClients(finder), logger: logger} + return &CertificateSource{ + client: client, services: newBridgeServiceClients(finder), clients: clients, logger: logger, + } } // CertificateFor implements bridgetracker.CertificateSource: it resolves the certificate that @@ -125,7 +132,9 @@ func (s *CertificateSource) rootIndexFor(ctx context.Context, networkID uint32, } // certificateHeaderFor fetches certificateID's current header from the agglayer and maps it -// into the tracker's trackertypes.CertificateInclusionData +// into the tracker's trackertypes.CertificateInclusionData. Once the certificate is settled, +// also resolves its settlement tx's block on L1 (see settlementBlockInfo) — nil/nil while that +// tx is not visible there yet, which CertificatePendingResolver treats as still pending func (s *CertificateSource) certificateHeaderFor( ctx context.Context, certificateID common.Hash, ) (*trackertypes.CertificateInclusionData, error) { @@ -138,6 +147,15 @@ func (s *CertificateSource) certificateHeaderFor( if header.Error != nil { errMsg = header.Error.Error() } + + var blockNumber, blockTimestamp *uint64 + if header.Status.IsSettled() && header.SettlementTxHash != nil { + blockNumber, blockTimestamp, err = s.settlementBlockInfo(ctx, *header.SettlementTxHash) + if err != nil { + return nil, err + } + } + s.logger.Debugf("certificate %s status: %s (settlementTxHash=%s, error=%q)", certificateID, header.Status, header.SettlementTxHash, errMsg) return &trackertypes.CertificateInclusionData{ @@ -146,8 +164,41 @@ func (s *CertificateSource) certificateHeaderFor( Status: header.Status, Error: errMsg, SettlementTxHash: header.SettlementTxHash, + BlockNumber: blockNumber, + BlockTimestamp: blockTimestamp, }, PreviousLocalExitRoot: header.PreviousLocalExitRoot, NewLocalExitRoot: header.NewLocalExitRoot, }, nil } + +// settlementBlockInfo resolves settlementTxHash's block number and timestamp on L1, or nil/nil +// if its receipt is not visible there yet: unlike SettlementSource (StepWaitL1SettledGER), this +// is not gated by L1 finality — it only needs to know where the tx landed, not to validate what +// it did there +func (s *CertificateSource) settlementBlockInfo( + ctx context.Context, settlementTxHash common.Hash, +) (*uint64, *uint64, error) { + client, err := s.clients.RPCClientFor(ctx, 0) // a certificate always settles on L1 + if err != nil { + return nil, nil, fmt.Errorf("resolving L1 JSON-RPC client: %w", err) + } + + receipt, err := client.TransactionReceipt(ctx, settlementTxHash) + if errors.Is(err, ethereum.NotFound) { + return nil, nil, nil // not mined/visible on L1 yet + } + if err != nil { + return nil, nil, fmt.Errorf("fetching settlement tx receipt %s: %w", settlementTxHash, err) + } + if receipt.BlockNumber == nil { + return nil, nil, nil // defensive: a mined receipt always carries one, but just in case + } + + timestamp, err := blockTimestamp(ctx, client, receipt.BlockHash) + if err != nil { + return nil, nil, err + } + number := receipt.BlockNumber.Uint64() + return &number, ×tamp, nil +} diff --git a/bridgetracker/sources/certificate_test.go b/bridgetracker/sources/certificate_test.go index aa8dc0182..77d261df6 100644 --- a/bridgetracker/sources/certificate_test.go +++ b/bridgetracker/sources/certificate_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/big" "net/http" "net/http/httptest" "testing" @@ -11,7 +12,11 @@ import ( agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/bridgeservicefinder" "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/types/mocks" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -85,7 +90,12 @@ func TestCertificateSourceCertificateHeaderFor(t *testing.T) { PreviousLocalExitRoot: &previousLER, NewLocalExitRoot: newLER, }} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, + }, nil) + expectBlockTimestamp(client) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), StaticClients{0: client}, testLogger) cert, err := source.certificateHeaderFor(t.Context(), certID) require.NoError(t, err) @@ -95,11 +105,35 @@ func TestCertificateSourceCertificateHeaderFor(t *testing.T) { require.Empty(t, cert.Error) require.Equal(t, &previousLER, cert.PreviousLocalExitRoot) require.Equal(t, newLER, cert.NewLocalExitRoot) + require.NotNil(t, cert.BlockNumber) + require.Equal(t, uint64(12345), *cert.BlockNumber) + require.NotNil(t, cert.BlockTimestamp) + require.Equal(t, testBlockTimestamp, *cert.BlockTimestamp) +} + +// TestCertificateSourceCertificateHeaderForSettlementTxNotMinedYet pins that a settled +// certificate whose settlement tx is not visible on L1 yet resolves with BlockNumber/ +// BlockTimestamp left nil, rather than erroring: CertificatePendingResolver treats that as +// still pending (see ErrCertificateNotSettled) +func TestCertificateSourceCertificateHeaderForSettlementTxNotMinedYet(t *testing.T) { + certID := common.HexToHash("0x0f") + settlementTx := common.HexToHash("0x10") + fake := &fakeCertificateHeaderClient{header: &agglayertypes.CertificateHeader{ + CertificateID: certID, Status: agglayertypes.Settled, SettlementTxHash: &settlementTx, + }} + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(nil, ethereum.NotFound) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), StaticClients{0: client}, testLogger) + + cert, err := source.certificateHeaderFor(t.Context(), certID) + require.NoError(t, err) + require.Nil(t, cert.BlockNumber) + require.Nil(t, cert.BlockTimestamp) } func TestCertificateSourceCertificateHeaderForTransientError(t *testing.T) { fake := &fakeCertificateHeaderClient{err: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateHeaderFor(t.Context(), common.HexToHash("0x0f")) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -113,7 +147,7 @@ func TestCertificateIDForSettledCovers(t *testing.T) { fake := &fakeCertificateHeaderClient{ settled: &agglayertypes.CertificateHeader{CertificateID: certID, NewLocalExitRoot: ler}, } - source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -132,7 +166,7 @@ func TestCertificateIDForSettledNotCoveredButPendingSurfaced(t *testing.T) { } // pending's root deliberately does not cover bridge (index 3 < DepositCount 7) either: it // must still be surfaced (see certificateIDFor's doc) since it is not settled/terminal - source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5, pendingLER.Hex(): 3}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5, pendingLER.Hex(): 3}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -148,7 +182,7 @@ func TestCertificateIDForSettledNotCoveredNoPending(t *testing.T) { } // A settled-but-non-covering certificate must never be returned: CertificatePendingResolver // treats Settled as "done", so this would make the tracker think the step completed early - source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{settledLER.Hex(): 5}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -158,7 +192,7 @@ func TestCertificateIDForSettledNotCoveredNoPending(t *testing.T) { func TestCertificateIDForNoSettledNoPending(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -173,7 +207,7 @@ func TestCertificateIDForNoSettledPendingSurfaced(t *testing.T) { fake := &fakeCertificateHeaderClient{ pending: &agglayertypes.CertificateHeader{CertificateID: pendingCertID, NewLocalExitRoot: pendingLER}, } - source := NewCertificateSource(fake, fakeRootIndexes{pendingLER.Hex(): 7}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{pendingLER.Hex(): 7}.start(t), nil, testLogger) got, err := source.certificateIDFor(t.Context(), bridge) require.NoError(t, err) @@ -183,7 +217,7 @@ func TestCertificateIDForNoSettledPendingSurfaced(t *testing.T) { func TestCertificateIDForSettledErrorPropagates(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{settledErr: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -192,7 +226,7 @@ func TestCertificateIDForSettledErrorPropagates(t *testing.T) { func TestCertificateIDForPendingErrorPropagates(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{pendingErr: errFakeCertificateHeaderClient} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.ErrorIs(t, err, errFakeCertificateHeaderClient) @@ -207,7 +241,7 @@ func TestCertificateIDForRootNotSyncedYetIsTransient(t *testing.T) { } // the bridge service on bridge.NetworkID has not synced settledLER yet: retried by the // engine, not treated as "not covered" - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) _, err := source.certificateIDFor(t.Context(), bridge) require.Error(t, err) @@ -216,7 +250,7 @@ func TestCertificateIDForRootNotSyncedYetIsTransient(t *testing.T) { func TestCertificateForNotCovered(t *testing.T) { bridge := l2ToL1Bridge() fake := &fakeCertificateHeaderClient{} - source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), testLogger) + source := NewCertificateSource(fake, fakeRootIndexes{}.start(t), nil, testLogger) cert, err := source.CertificateFor(t.Context(), bridge) require.NoError(t, err) @@ -237,7 +271,12 @@ func TestCertificateForCovered(t *testing.T) { SettlementTxHash: &settlementTx, }, } - source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), testLogger) + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, settlementTx).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, + }, nil) + expectBlockTimestamp(client) + source := NewCertificateSource(fake, fakeRootIndexes{ler.Hex(): 7}.start(t), StaticClients{0: client}, testLogger) cert, err := source.CertificateFor(t.Context(), bridge) require.NoError(t, err) diff --git a/bridgetracker/sources/claim.go b/bridgetracker/sources/claim.go index 9e1c8f690..0c88d4b4d 100644 --- a/bridgetracker/sources/claim.go +++ b/bridgetracker/sources/claim.go @@ -50,7 +50,8 @@ func (s *ClaimSource) ClaimFor( return nil, nil } return &trackertypes.ClaimResult{ - ClaimTx: common.HexToHash(string(claims.Claims[0].TxHash)), - BlockNumber: claims.Claims[0].BlockNum, + ClaimTx: common.HexToHash(string(claims.Claims[0].TxHash)), + BlockNumber: claims.Claims[0].BlockNum, + BlockTimestamp: claims.Claims[0].BlockTimestamp, }, nil } diff --git a/bridgetracker/sources/claim_checker.go b/bridgetracker/sources/claim_checker.go new file mode 100644 index 000000000..02f1523a5 --- /dev/null +++ b/bridgetracker/sources/claim_checker.go @@ -0,0 +1,108 @@ +package sources + +import ( + "context" + "fmt" + "sync" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/agglayer/aggkit/bridgetracker" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// claimChecker is the minimal bridge contract surface needed to check a bridge's on-chain claim +// state; *agglayerbridgel2.Agglayerbridgel2 satisfies it. Shared by ActivitySource (the activity +// endpoint) and ClaimChecker (the tracker engine's StepWaitingClaim) via contractClaimCheckers — +// both resolve the exact same isClaimed() call, just off differently-shaped bridge inputs +type claimChecker interface { + IsClaimed(opts *bind.CallOpts, leafIndex uint32, sourceBridgeNetwork uint32) (bool, error) +} + +// contractClaimCheckers resolves and caches, per destination network, the claim-checking +// contract binding used to call isClaimed() on-chain. Factored out of ActivitySource so +// ClaimChecker (below) reuses the same binding/cache logic instead of duplicating it +type contractClaimCheckers struct { + finder NetworkLister + ethClients EthClientResolver + // newContract builds the claim-checking contract binding for a destination network, + // injectable for tests. Defaults to agglayerbridgel2.NewAgglayerbridgel2 + newContract func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) + + mu sync.Mutex + contracts map[uint32]claimChecker // destination networkID -> bound contract, built lazily +} + +// newContractClaimCheckers returns a contractClaimCheckers resolving bridge contract +// addresses/clients through finder/ethClients +func newContractClaimCheckers(finder NetworkLister, ethClients EthClientResolver) *contractClaimCheckers { + return &contractClaimCheckers{ + finder: finder, + ethClients: ethClients, + newContract: func(addr common.Address, c aggkittypes.BaseEthereumClienter) (claimChecker, error) { + return agglayerbridgel2.NewAgglayerbridgel2(addr, c) + }, + contracts: make(map[uint32]claimChecker), + } +} + +// isClaimed calls isClaimed() on destinationNetwork's bridge contract, for the leaf identified +// by depositCount within sourceNetworkID's exit tree +func (c *contractClaimCheckers) isClaimed( + ctx context.Context, destinationNetwork, depositCount, sourceNetworkID uint32, +) (bool, error) { + contract, err := c.claimCheckerFor(ctx, destinationNetwork) + if err != nil { + return false, err + } + return contract.IsClaimed(&bind.CallOpts{Context: ctx}, depositCount, sourceNetworkID) +} + +// claimCheckerFor returns (building and caching if necessary) the claim-checking contract +// binding for the given destination network +func (c *contractClaimCheckers) claimCheckerFor(ctx context.Context, networkID uint32) (claimChecker, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if cc, ok := c.contracts[networkID]; ok { + return cc, nil + } + + addr, err := c.finder.BridgeAddress(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving bridge contract address for network %d: %w", networkID, err) + } + rpcClient, err := c.ethClients.RPCClientFor(ctx, networkID) + if err != nil { + return nil, fmt.Errorf("resolving JSON-RPC client for network %d: %w", networkID, err) + } + contract, err := c.newContract(addr, rpcClient) + if err != nil { + return nil, fmt.Errorf("binding bridge contract %s on network %d: %w", addr, networkID, err) + } + + c.contracts[networkID] = contract + return contract, nil +} + +// ClaimChecker implements bridgetracker.ClaimChecker over StepWaitingClaim: whether a bridge has +// been claimed on its destination network, per isClaimed() on the destination bridge contract — +// the same on-chain check ActivitySource uses for the activity endpoint, applied here to a +// resolved bridgetracker.BridgeInfo instead of a domain.ScannedBridge +type ClaimChecker struct { + *contractClaimCheckers +} + +// NewClaimChecker returns a ClaimChecker resolving bridge contract addresses/clients through +// finder/ethClients +func NewClaimChecker(finder NetworkLister, ethClients EthClientResolver) *ClaimChecker { + return &ClaimChecker{contractClaimCheckers: newContractClaimCheckers(finder, ethClients)} +} + +// IsClaimed implements bridgetracker.ClaimChecker: it calls isClaimed() on bridge's destination +// bridge contract. The on-chain sourceBridgeNetwork argument is bridge.NetworkID — the network +// the bridge-creating tx was actually sent to +func (c *ClaimChecker) IsClaimed(ctx context.Context, bridge *bridgetracker.BridgeInfo) (bool, error) { + return c.isClaimed(ctx, bridge.DestinationNetwork, bridge.DepositCount, bridge.NetworkID) +} diff --git a/bridgetracker/sources/ger.go b/bridgetracker/sources/ger.go index ab4b17579..a8d5a6642 100644 --- a/bridgetracker/sources/ger.go +++ b/bridgetracker/sources/ger.go @@ -7,12 +7,15 @@ import ( "math/big" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerger" + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayergerl2" + "github.com/agglayer/aggkit/bridgeservicefinder" "github.com/agglayer/aggkit/bridgetracker" "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkitcommon "github.com/agglayer/aggkit/common" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum" + "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" @@ -20,6 +23,13 @@ import ( const MainnetNetworkID = 0 +// updateHashChainValueSignature is the topic0 of AgglayerGERL2's UpdateHashChainValue event — +// event UpdateHashChainValue(bytes32 indexed newGlobalExitRoot, bytes32 indexed +// newHashChainValue) — the event findL2InjectionBlockBackwards scans for. Not referenced by the +// filtering itself (l2GERManager.FilterUpdateHashChainValue's ABI binding computes it), only +// documented here for the same reason l2gersync names it (insertGEREventSignature) +var updateHashChainValueSignature = crypto.Keccak256Hash([]byte("UpdateHashChainValue(bytes32,bytes32)")) + // errNotCoveredYet marks a bridge not covered by any L1 info tree leaf yet, as opposed to a // transient failure (URL resolution, network). It never escapes this package var errNotCoveredYet = errors.New("bridge not covered by any L1 info tree leaf yet") @@ -33,15 +43,33 @@ type GERSource struct { clients EthClientResolver ContractGlobalExitRootAddress common.Address L1BlockFinality aggkittypes.BlockNumberFinality + // l2GERAddrs is the static networkID -> GlobalExitRootManagerL2 contract address map used by + // findL2InjectionBlockBackwards, the fallback that scans a destination network's own L2 for + // the UpdateHashChainValue event when its bridge-service instance does not report the + // injection block itself (see InjectedGERAtIndex). A network absent from this map (the + // default, empty map) simply never gets that fallback attempted — L2InjectedGER just stays + // nil for it, exactly as before this existed + l2GERAddrs map[uint32]common.Address + // l2InjectionLookbackBlocks bounds how many blocks findL2InjectionBlockBackwards scans + // backwards from the destination network's head before giving up, instead of continuing all + // the way back to genesis (see bridgetracker.Config.L2InjectionLookbackBlocks). A value <= 0 + // falls back to bridgetracker.DefaultL2InjectionLookbackBlocks + l2InjectionLookbackBlocks uint64 } // NewGERSource returns a GERSource resolving per-network bridge service clients through finder, // and the L1 (network 0) JSON-RPC client used by FindFirstL1InfoTreeAfterBlock through clients. // contractGlobalExitRootAddress is the L1 GlobalExitRoot contract FindFirstL1InfoTreeAfterBlock -// reads UpdateL1InfoTree/UpdateL1InfoTreeV2 logs and state from; l1Finality caps its search range +// reads UpdateL1InfoTree/UpdateL1InfoTreeV2 logs and state from; l1Finality caps its search range. +// l2GERAddrs is the per-network GlobalExitRootManagerL2 contract address map backing +// findL2InjectionBlockBackwards (see GERSource.l2GERAddrs); nil/empty simply disables that +// fallback for every network. l2InjectionLookbackBlocks bounds how far back that same fallback +// scans (see GERSource.l2InjectionLookbackBlocks); a value <= 0 falls back to +// bridgetracker.DefaultL2InjectionLookbackBlocks func NewGERSource( finder NetworkURLResolver, clients EthClientResolver, contractGlobalExitRootAddress common.Address, - l1Finality aggkittypes.BlockNumberFinality, logger aggkitcommon.Logger, + l1Finality aggkittypes.BlockNumberFinality, l2GERAddrs map[uint32]common.Address, + l2InjectionLookbackBlocks uint64, logger aggkitcommon.Logger, ) *GERSource { return &GERSource{ logger: logger, @@ -49,6 +77,8 @@ func NewGERSource( clients: clients, ContractGlobalExitRootAddress: contractGlobalExitRootAddress, L1BlockFinality: l1Finality, + l2GERAddrs: l2GERAddrs, + l2InjectionLookbackBlocks: l2InjectionLookbackBlocks, } } @@ -274,13 +304,140 @@ func (s *GERSource) InjectedGERAtIndex( ger := common.HexToHash(string(leaf.GlobalExitRoot)) mer := common.HexToHash(string(leaf.MainnetExitRoot)) rer := common.HexToHash(string(leaf.RollupExitRoot)) - return &trackertypes.GERData{ - NetworkID: bridge.DestinationNetwork, - GER: &ger, - MER: &mer, - RER: &rer, - LERType: trackertypes.LERTypeNA, - }, nil + + // leaf.BlockNumber/Timestamp are always the L1 event that produced the leaf, even for this L2 + // lookup (see InjectedL1InfoLeafHandler) — they never describe the L2 injection block, and + // must not be presented as if they did (that conflation was #1818). The actual L2 injection + // block/timestamp, when the destination's bridge-service instance reports them, go on the + // separate L2BlockNumber/L2BlockTimestamp fields instead — left nil otherwise, rather than + // falling back to the L1 block, so WaitingGERInjectionResolver can honestly report + // InjectedGERResult.L2InjectedGER as absent instead of a wrong value + blockNumber := leaf.BlockNumber + timestamp := leaf.Timestamp + gerData := &trackertypes.GERData{ + NetworkID: bridge.DestinationNetwork, + GER: &ger, + MER: &mer, + RER: &rer, + LERType: trackertypes.LERTypeNA, + BlockNumber: &blockNumber, + BlockTimestamp: ×tamp, + } + if leaf.InjectedL2BlockNumber != nil { + l2BlockNumber := *leaf.InjectedL2BlockNumber + gerData.L2BlockNumber = &l2BlockNumber + if leaf.InjectedL2BlockTimestamp != nil { + l2Timestamp := *leaf.InjectedL2BlockTimestamp + gerData.L2BlockTimestamp = &l2Timestamp + } + return gerData, nil + } + + // The destination's bridge-service instance predates injected_l2_block_num (see #1818): fall + // back to finding the injection ourselves, straight off the destination network's own L2, if + // its GlobalExitRootManagerL2 address is configured (l2GERAddrs). Best effort — any failure + // here just leaves L2BlockNumber/L2BlockTimestamp nil, same as when the fallback isn't + // configured at all; it must never fail InjectedGERAtIndex itself over this + l2BlockNumber, l2Timestamp, err := s.findL2InjectionBlockBackwards(ctx, bridge.DestinationNetwork, ger) + if err != nil { + s.logger.Warnf("finding L2 injection block for GER %s on network %d: %v", + ger.Hex(), bridge.DestinationNetwork, err) + return gerData, nil + } + gerData.L2BlockNumber = l2BlockNumber + gerData.L2BlockTimestamp = l2Timestamp + return gerData, nil +} + +// findL2InjectionBlockBackwards scans backward, in fixed-size chunks (mirroring +// SettlementSource.findEventUpdateL1InfoTreeBackwards), for the UpdateHashChainValue event that +// injected ger into the GlobalExitRootManagerL2 contract on networkID — used only as a fallback +// when that network's bridge-service instance does not report the injection block itself (see +// InjectedGERAtIndex). Returns (nil, nil, nil) — not an error — when networkID has no configured +// l2GERAddrs entry (the fallback is simply not available for it) or the scan reaches genesis, or +// s.l2InjectionLookbackBlocks (see GERSource.l2InjectionLookbackBlocks), without finding the +// event; the timestamp alone can be (non-nil, nil) if resolving it off the found block's hash +// fails, since the block number is already a genuine, useful answer on its own +func (s *GERSource) findL2InjectionBlockBackwards( + ctx context.Context, networkID uint32, ger common.Hash, +) (blockNumber, timestamp *uint64, err error) { + addr, ok := s.l2GERAddrs[networkID] + if !ok { + return nil, nil, nil // fallback not configured for this network, not an error (see doc comment) + } + client, err := s.clients.RPCClientFor(ctx, networkID) + if err != nil { + return nil, nil, err + } + l2GERManager, err := agglayergerl2.NewAgglayergerl2(addr, client) + if err != nil { + return nil, nil, fmt.Errorf("binding GlobalExitRootManagerL2 contract at %s: %w", addr, err) + } + head, err := client.CustomHeaderByNumber(ctx, &aggkittypes.LatestBlock) + if err != nil { + return nil, nil, fmt.Errorf("fetching latest block of network %d: %w", networkID, err) + } + + lookback := s.l2InjectionLookbackBlocks + if lookback == 0 { + lookback = bridgetracker.DefaultL2InjectionLookbackBlocks + } + // floor is the lowest block the scan will look at: head.Number - lookback, or genesis (0) + // if the network's chain is shorter than that + floor := uint64(0) + if head.Number > lookback { + floor = head.Number - lookback + } + + toBlock := head.Number + for { + fromBlock := floor + if toBlock-floor > bridgeservicefinder.DefaultBlockChunkSize { + fromBlock = toBlock - bridgeservicefinder.DefaultBlockChunkSize + } + + end := toBlock + found, filterErr := s.filterUpdateHashChainValue(ctx, l2GERManager, fromBlock, end, ger) + if filterErr != nil { + return nil, nil, fmt.Errorf("filtering UpdateHashChainValue logs [%d,%d] on network %d: %w", + fromBlock, end, networkID, filterErr) + } + if found != nil { + block := found.BlockNumber + ts, tsErr := blockTimestamp(ctx, client, found.BlockHash) + if tsErr != nil { + return &block, nil, fmt.Errorf("resolving timestamp of block %d: %w", block, tsErr) + } + return &block, &ts, nil + } + if fromBlock == floor { + // scanned back to genesis or the configured lookback limit, the injection event + // genuinely isn't there (within that window) + return nil, nil, nil + } + toBlock = fromBlock - 1 + } +} + +// filterUpdateHashChainValue returns the most recent UpdateHashChainValue log for ger within +// [fromBlock, toBlock] on l2GERManager, or nil if none. The event's first indexed topic is the +// GER itself, so the topic filter alone does the matching — no need to decode every log in range +func (s *GERSource) filterUpdateHashChainValue( + ctx context.Context, l2GERManager *agglayergerl2.Agglayergerl2, fromBlock, toBlock uint64, ger common.Hash, +) (*gethtypes.Log, error) { + iter, err := l2GERManager.FilterUpdateHashChainValue( + &bind.FilterOpts{Start: fromBlock, End: &toBlock, Context: ctx}, [][32]byte{ger}, nil) + if err != nil { + return nil, err + } + defer iter.Close() + + var found *gethtypes.Log + for iter.Next() { + raw := iter.Event.Raw + found = &raw + } + return found, iter.Error() } // coveringLeafIndex resolves the L1 info tree index whose leaf covers the bridge, asking diff --git a/bridgetracker/sources/settlement.go b/bridgetracker/sources/settlement.go index d2140be67..325e2849e 100644 --- a/bridgetracker/sources/settlement.go +++ b/bridgetracker/sources/settlement.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "math/big" "github.com/agglayer/aggkit/bridgetracker" + "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum" @@ -13,6 +15,13 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) +// l1InfoTreeBackwardsSearchChunkSize bounds how many blocks each FilterLogs call in +// findEventUpdateL1InfoTreeBackwards covers: most RPC providers cap how wide a single +// eth_getLogs range can be, so a search spanning the chain's whole history has to walk +// backwards one chunk at a time instead of in a single call. Same chunk size aggkit already +// uses elsewhere for bounded log queries (e.g. bridgeservicefinder.DefaultBlockChunkSize) +const l1InfoTreeBackwardsSearchChunkSize = 10_000 + var ( // updateL1InfoTreeSignature is the topic0 of the GlobalExitRoot contract's UpdateL1InfoTree // event (same signature l1infotreesync matches) @@ -39,12 +48,24 @@ type SettlementSource struct { // never re-checked (TrackingBridgeTx.IsDone), so accepting a receipt that later gets // reorged out would otherwise be permanent l1Finality aggkittypes.BlockNumberFinality + // contractGlobalExitRootAddress is the L1 GlobalExitRoot contract address + // findEventUpdateL1InfoTreeBackwards reads UpdateL1InfoTree logs from, for a settlement tx + // whose own receipt does not carry the event + contractGlobalExitRootAddress common.Address } // NewSettlementSource returns a SettlementSource resolving the L1 (network 0) JSON-RPC client -// through clients, accepting a settlement tx's receipt only once it reaches l1Finality -func NewSettlementSource(clients EthClientResolver, l1Finality aggkittypes.BlockNumberFinality) *SettlementSource { - return &SettlementSource{clients: clients, l1Finality: l1Finality} +// through clients, accepting a settlement tx's receipt only once it reaches l1Finality. +// contractGlobalExitRootAddress is the L1 GlobalExitRoot contract findEventUpdateL1InfoTreeBackwards +// searches when a settlement tx's own receipt does not carry an UpdateL1InfoTree event +func NewSettlementSource( + clients EthClientResolver, l1Finality aggkittypes.BlockNumberFinality, contractGlobalExitRootAddress common.Address, +) *SettlementSource { + return &SettlementSource{ + clients: clients, + l1Finality: l1Finality, + contractGlobalExitRootAddress: contractGlobalExitRootAddress, + } } // SettlementGERUpdate implements bridgetracker.SettlementSource: it fetches settlementTxHash's @@ -76,7 +97,16 @@ func (s *SettlementSource) SettlementGERUpdate( return nil, nil // mined, but not yet at the required finality } - result := &trackertypes.L1SettledGERResult{TxHash: settlementTxHash, BlockNumber: receipt.BlockNumber.Uint64()} + settlementBlockTimestamp, err := blockTimestamp(ctx, client, receipt.BlockHash) + if err != nil { + return nil, err + } + + result := &trackertypes.L1SettledGERResult{ + TxHash: settlementTxHash, + SettlementBlockNumber: receipt.BlockNumber.Uint64(), + SettlementBlockTimestamp: settlementBlockTimestamp, + } for _, l := range receipt.Logs { if len(l.Topics) == 0 { continue @@ -84,6 +114,7 @@ func (s *SettlementSource) SettlementGERUpdate( switch l.Topics[0] { case verifyBatchesTrustedAggregatorSignature: result.HasVerifyBatchesTrustedAggregator = true + result.SettlementLogIndex = l.Index case updateL1InfoTreeSignature: // both mainnetExitRoot and rollupExitRoot are indexed bytes32 params, so they sit // directly in the topics (fixed-size indexed values are not hashed, unlike dynamic @@ -94,6 +125,10 @@ func (s *SettlementSource) SettlementGERUpdate( mainnetExitRoot, rollupExitRoot := l.Topics[1], l.Topics[2] result.HasUpdateL1InfoTree = true result.GER = crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]) + result.GERBlockNumber = receipt.BlockNumber.Uint64() + // same block as the settlement tx itself, so its timestamp is already known + result.GERBlockTimestamp = settlementBlockTimestamp + result.GERLogIndex = l.Index case updateL1InfoTreeV2Signature: result.HasUpdateL1InfoTreeV2 = true // leafCount is the only indexed param, so it sits directly in Topics[1] (a uint32 @@ -110,8 +145,110 @@ func (s *SettlementSource) SettlementGERUpdate( } } - if !result.HasVerifyBatchesTrustedAggregator || !result.HasUpdateL1InfoTree { - return nil, nil // mandatory evidence not there yet + if !result.HasVerifyBatchesTrustedAggregator { + return nil, fmt.Errorf("settlement tx %s receipt does not carry VerifyBatchesTrustedAggregator: %w", + settlementTxHash, domain.ErrBadSettlementTx) + } + // This cert's settlement did not move the GER itself (no UpdateL1InfoTree in its own + // receipt): the value it propagated is whichever one an earlier settlement already + // established, so look backwards on L1 for that event + if !result.HasUpdateL1InfoTree { + event, err := s.findEventUpdateL1InfoTreeBackwards( + ctx, client, receipt.BlockNumber.Uint64(), result.SettlementLogIndex) + if err != nil { + return nil, err + } + result.GER = event.GER + result.GERBlockNumber = event.BlockNumber + result.GERBlockTimestamp = event.BlockTimestamp + result.GERLogIndex = event.LogIndex } + return result, nil } + +// updateL1InfoTreeEvent is the GER-relevant evidence of a single UpdateL1InfoTree event: the +// GER it produced and where on L1 it landed +type updateL1InfoTreeEvent struct { + GER common.Hash + BlockNumber uint64 + BlockTimestamp uint64 + LogIndex uint +} + +// findEventUpdateL1InfoTreeBackwards looks for the most recent UpdateL1InfoTree event on the +// GlobalExitRoot contract at or before (fromBlock, settlementLogIndex) — the settlement tx's own +// position — walking backwards in l1InfoTreeBackwardsSearchChunkSize chunks until one is found +// or block 0 is reached. settlementLogIndex excludes any log that shares fromBlock with the +// settlement but sits at or after it: such a log comes from a later transaction in the same +// block, so the GER it produced did not exist yet when the settlement executed (mirrors the +// position filtering FindFirstL1InfoTreeAfterBlock already does in ger.go, just looking +// backwards instead of forwards). Only called when the settlement tx's own receipt does not +// carry the event (see SettlementGERUpdate): the L1 Global Exit Root is never unset, so some +// earlier update always exists, unless the settlement tx is not what it claims to be, in which +// case this returns domain.ErrBadSettlementTx (Permanent — see the sentinel's own doc) +func (s *SettlementSource) findEventUpdateL1InfoTreeBackwards( + ctx context.Context, client aggkittypes.BaseEthereumClienter, fromBlock uint64, settlementLogIndex uint, +) (*updateL1InfoTreeEvent, error) { + toBlock := fromBlock + for { + fromBlockChunk := uint64(0) + if toBlock >= l1InfoTreeBackwardsSearchChunkSize { + // FromBlock/ToBlock are both inclusive, so the naive toBlock-chunkSize would span + // chunkSize+1 blocks (e.g. [15000,25000] for a 10_000 chunk) — the +1 keeps each + // query at exactly chunkSize blocks, which matters for providers that reject an + // eth_getLogs range wider than that + fromBlockChunk = toBlock - l1InfoTreeBackwardsSearchChunkSize + 1 + } + + logs, err := client.FilterLogs(ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromBlockChunk), + ToBlock: new(big.Int).SetUint64(toBlock), + Addresses: []common.Address{s.contractGlobalExitRootAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }) + if err != nil { + return nil, fmt.Errorf("fetching UpdateL1InfoTree logs from block %d to %d: %w", + fromBlockChunk, toBlock, err) + } + + // Only the settlement's own block (fromBlock) can carry a log at or after + // settlementLogIndex — every other block queried here is strictly earlier — so this + // only ever trims the first chunk + filtered := logs[:0] + for _, l := range logs { + if l.BlockNumber == fromBlock && l.Index >= settlementLogIndex { + continue + } + filtered = append(filtered, l) + } + logs = filtered + + if len(logs) > 0 { + // FilterLogs returns logs in ascending block/log-index order, so the last one is the + // most recent event in this chunk — the closest one at or before fromBlock + last := logs[len(logs)-1] + if len(last.Topics) < 3 { //nolint:mnd + return nil, fmt.Errorf("UpdateL1InfoTree log at block %d missing its exit root topics: %w", + last.BlockNumber, domain.ErrBadSettlementTx) + } + mainnetExitRoot, rollupExitRoot := last.Topics[1], last.Topics[2] + timestamp, err := blockTimestamp(ctx, client, last.BlockHash) + if err != nil { + return nil, err + } + return &updateL1InfoTreeEvent{ + GER: crypto.Keccak256Hash(mainnetExitRoot[:], rollupExitRoot[:]), + BlockNumber: last.BlockNumber, + BlockTimestamp: timestamp, + LogIndex: last.Index, + }, nil + } + + if fromBlockChunk == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTree event found at or before block %d: %w", + fromBlock, domain.ErrBadSettlementTx) + } + toBlock = fromBlockChunk - 1 + } +} diff --git a/bridgetracker/sources/settlement_test.go b/bridgetracker/sources/settlement_test.go index 0d17ae811..7221b08ea 100644 --- a/bridgetracker/sources/settlement_test.go +++ b/bridgetracker/sources/settlement_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/agglayer/aggkit/bridgetracker" + "github.com/agglayer/aggkit/bridgetracker/domain" trackertypes "github.com/agglayer/aggkit/bridgetracker/types" aggkittypes "github.com/agglayer/aggkit/types" "github.com/agglayer/aggkit/types/mocks" @@ -17,8 +18,28 @@ import ( "github.com/stretchr/testify/require" ) +// testGERAddress is the canned GlobalExitRoot contract address findEventUpdateL1InfoTreeBackwards +// filters by in these tests +var testGERAddress = common.HexToAddress("0xge2") + func newSettlementSource(client *mocks.BaseEthereumClienter) *SettlementSource { - return NewSettlementSource(StaticClients{0: client}, aggkittypes.FinalizedBlock) + return NewSettlementSource(StaticClients{0: client}, aggkittypes.FinalizedBlock, testGERAddress) +} + +// expectBackwardsUpdateL1InfoTreeLog stubs client's FilterLogs to answer +// findEventUpdateL1InfoTreeBackwards' very first chunk (fromBlock down to fromBlock minus +// l1InfoTreeBackwardsSearchChunkSize plus one, or 0) with a single matching log +func expectBackwardsUpdateL1InfoTreeLog(client *mocks.BaseEthereumClienter, fromBlock uint64, log gethtypes.Log) { + fromChunk := uint64(0) + if fromBlock >= l1InfoTreeBackwardsSearchChunkSize { + fromChunk = fromBlock - l1InfoTreeBackwardsSearchChunkSize + 1 + } + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromChunk), + ToBlock: new(big.Int).SetUint64(fromBlock), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{log}, nil) } // mainnetExitRoot/rollupExitRoot are the canned exit roots the tests use to build an @@ -32,22 +53,40 @@ var ( } ) +// testBackwardsBlockHash/testBackwardsBlockTimestamp stub the block hash/timestamp of the +// earlier UpdateL1InfoTree event findEventUpdateL1InfoTreeBackwards finds, in tests where it +// lands in a different block than the settlement tx itself (testBlockHash/testBlockTimestamp, +// from sources_test.go, stand for the settlement tx's own block) +var ( + testBackwardsBlockHash = common.HexToHash("0xba0000000000000000000000000000000000000000000000000000000000ba") + testBackwardsBlockTimestamp = uint64(1690000000) +) + +// expectBackwardsBlockTimestamp stubs client's HeaderByHash for testBackwardsBlockHash to +// report testBackwardsBlockTimestamp +func expectBackwardsBlockTimestamp(client *mocks.BaseEthereumClienter) { + client.EXPECT().HeaderByHash(mock.Anything, testBackwardsBlockHash). + Return(&gethtypes.Header{Time: testBackwardsBlockTimestamp}, nil) +} + func TestSettlementSourceBothMandatoryEventsPresent(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) require.Equal(t, &trackertypes.L1SettledGERResult{ - TxHash: testTxHash, BlockNumber: 12345, GER: wantGER, + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12345, GERBlockTimestamp: testBlockTimestamp, HasVerifyBatchesTrustedAggregator: true, HasUpdateL1InfoTree: true, }, result) } @@ -59,7 +98,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { leafCount := uint64(8) client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, @@ -69,6 +108,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) @@ -84,7 +124,7 @@ func TestSettlementSourceOptionalV2Captured(t *testing.T) { func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, updateL1InfoTree, @@ -92,6 +132,7 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) @@ -102,38 +143,46 @@ func TestSettlementSourceMalformedUpdateL1InfoTreeV2Log(t *testing.T) { // TestSettlementSourceMalformedUpdateL1InfoTreeLog pins that an UpdateL1InfoTree log missing its // indexed topics (fewer than 3 total) is not mistaken for a match: without both exit roots there -// is no GER to compute, so the mandatory event is treated as not there yet +// is no GER to compute from it, so it is treated the same as the event being altogether absent +// from this receipt — falling back to findEventUpdateL1InfoTreeBackwards for an earlier one func TestSettlementSourceMalformedUpdateL1InfoTreeLog(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: []*gethtypes.Log{ {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, {Topics: []common.Hash{updateL1InfoTreeSignature}}, }, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) + expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ + BlockNumber: 12000, Index: 3, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }) + expectBackwardsBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.NoError(t, err) - require.Nil(t, result) + require.Equal(t, &trackertypes.L1SettledGERResult{ + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12000, GERBlockTimestamp: testBackwardsBlockTimestamp, GERLogIndex: 3, + HasVerifyBatchesTrustedAggregator: true, + }, result) } -func TestSettlementSourceMissingMandatoryEvent(t *testing.T) { +// TestSettlementSourceMissingVerifyBatchesTrustedAggregator pins that a finalized settlement tx +// receipt missing VerifyBatchesTrustedAggregator is a permanent failure (domain.ErrBadSettlementTx): +// unlike a not-yet-finalized receipt, this one is already final and simply does not carry an +// event a genuine settlement always emits, so retrying it can never change the outcome +func TestSettlementSourceMissingVerifyBatchesTrustedAggregator(t *testing.T) { testCases := []struct { name string logs []*gethtypes.Log }{ {name: "no logs at all"}, - { - name: "only VerifyBatchesTrustedAggregator", - logs: []*gethtypes.Log{{Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}}, - }, - { - name: "only UpdateL1InfoTree", - logs: []*gethtypes.Log{updateL1InfoTree}, - }, + {name: "only UpdateL1InfoTree", logs: []*gethtypes.Log{updateL1InfoTree}}, { name: "only the optional UpdateL1InfoTreeV2", logs: []*gethtypes.Log{{Topics: []common.Hash{updateL1InfoTreeV2Signature}}}, @@ -144,18 +193,173 @@ func TestSettlementSourceMissingMandatoryEvent(t *testing.T) { t.Run(tc.name, func(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ - BlockNumber: big.NewInt(12345), Logs: tc.logs, + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, Logs: tc.logs, }, nil) expectFinalized(client, 12345) + expectBlockTimestamp(client) result, err := newSettlementSource(client).SettlementGERUpdate( t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) - require.NoError(t, err) - require.Nil(t, result, "mandatory evidence missing, still pending") + require.ErrorIs(t, err, domain.ErrBadSettlementTx) + require.Nil(t, result) }) } } +// TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent pins that a settlement tx +// whose own receipt carries VerifyBatchesTrustedAggregator but not UpdateL1InfoTree is not +// rejected outright: the settlement simply did not move the GER itself, so +// findEventUpdateL1InfoTreeBackwards supplies the GER an earlier settlement already established +func TestSettlementSourceMissingUpdateL1InfoTreeFallsBackToEarlierEvent(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(12345), BlockHash: testBlockHash, + Logs: []*gethtypes.Log{ + {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, + }, + }, nil) + expectFinalized(client, 12345) + expectBlockTimestamp(client) + expectBackwardsUpdateL1InfoTreeLog(client, 12345, gethtypes.Log{ + BlockNumber: 12000, Index: 3, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }) + expectBackwardsBlockTimestamp(client) + + result, err := newSettlementSource(client).SettlementGERUpdate( + t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) + require.NoError(t, err) + require.Equal(t, &trackertypes.L1SettledGERResult{ + TxHash: testTxHash, SettlementBlockNumber: 12345, SettlementBlockTimestamp: testBlockTimestamp, + GER: wantGER, GERBlockNumber: 12000, GERBlockTimestamp: testBackwardsBlockTimestamp, GERLogIndex: 3, + HasVerifyBatchesTrustedAggregator: true, + }, result) +} + +// TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent pins that when +// findEventUpdateL1InfoTreeBackwards finds nothing either, SettlementGERUpdate surfaces its +// domain.ErrBadSettlementTx rather than swallowing it +func TestSettlementSourceMissingUpdateL1InfoTreeAndNoEarlierEvent(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(&gethtypes.Receipt{ + BlockNumber: big.NewInt(5000), BlockHash: testBlockHash, + Logs: []*gethtypes.Log{ + {Topics: []common.Hash{verifyBatchesTrustedAggregatorSignature}}, + }, + }, nil) + expectFinalized(client, 5000) + expectBlockTimestamp(client) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + + result, err := newSettlementSource(client).SettlementGERUpdate( + t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) + require.Nil(t, result) +} + +// TestFindEventUpdateL1InfoTreeBackwardsPaginates pins that an empty first chunk does not stop +// the search: it keeps walking further back in l1InfoTreeBackwardsSearchChunkSize chunks until +// one of them carries a match +func TestFindEventUpdateL1InfoTreeBackwardsPaginates(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(15001), ToBlock: big.NewInt(25000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(5001), ToBlock: big.NewInt(15000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{ + { + BlockNumber: 10000, Index: 2, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, + }, nil) + expectBackwardsBlockTimestamp(client) + + event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 25000, 0) + require.NoError(t, err) + require.Equal(t, &updateL1InfoTreeEvent{ + GER: wantGER, BlockNumber: 10000, BlockTimestamp: testBackwardsBlockTimestamp, LogIndex: 2, + }, event) +} + +// TestFindEventUpdateL1InfoTreeBackwardsExcludesLaterLogInSettlementBlock pins that a match in +// the settlement's own block (fromBlock) is only accepted at or before settlementLogIndex: a log +// there from a later transaction produced a GER that did not exist yet when the settlement +// executed, so it must be skipped in favor of an earlier chunk's match +func TestFindEventUpdateL1InfoTreeBackwardsExcludesLaterLogInSettlementBlock(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return([]gethtypes.Log{ + { + BlockNumber: 4000, Index: 2, BlockHash: testBackwardsBlockHash, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, + { + // same block as the settlement (fromBlock=5000), but at/after its log index (3): a + // later transaction's GER update, not yet in effect when the settlement executed + BlockNumber: 5000, Index: 3, + Topics: []common.Hash{updateL1InfoTreeSignature, mainnetExitRoot, rollupExitRoot}, + }, + }, nil) + expectBackwardsBlockTimestamp(client) + + event, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 3) + require.NoError(t, err) + require.Equal(t, &updateL1InfoTreeEvent{ + GER: wantGER, BlockNumber: 4000, BlockTimestamp: testBackwardsBlockTimestamp, LogIndex: 2, + }, event) +} + +// TestFindEventUpdateL1InfoTreeBackwardsNotFound pins that reaching block 0 without a match is +// domain.ErrBadSettlementTx: the L1 Global Exit Root is never unset, so its absence anywhere +// before fromBlock means the settlement tx is not what it claims to be +func TestFindEventUpdateL1InfoTreeBackwardsNotFound(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, ethereum.FilterQuery{ + FromBlock: big.NewInt(0), ToBlock: big.NewInt(5000), + Addresses: []common.Address{testGERAddress}, + Topics: [][]common.Hash{{updateL1InfoTreeSignature}}, + }).Return(nil, nil) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) +} + +// TestFindEventUpdateL1InfoTreeBackwardsMalformedLog pins that a matched log missing its +// indexed exit root topics is a domain.ErrBadSettlementTx too, rather than being silently +// skipped: a genuine UpdateL1InfoTree event always carries both, so this signals corrupt data, +// not something an earlier chunk would fix +func TestFindEventUpdateL1InfoTreeBackwardsMalformedLog(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {BlockNumber: 4000, Topics: []common.Hash{updateL1InfoTreeSignature}}, + }, nil) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) + require.ErrorIs(t, err, domain.ErrBadSettlementTx) +} + +// TestFindEventUpdateL1InfoTreeBackwardsFetchError pins that a FilterLogs failure propagates +// as-is (transient, retried by the engine), not wrapped as domain.ErrBadSettlementTx +func TestFindEventUpdateL1InfoTreeBackwardsFetchError(t *testing.T) { + client := mocks.NewBaseEthereumClienter(t) + client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return(nil, errors.New("rpc down")) + + _, err := newSettlementSource(client).findEventUpdateL1InfoTreeBackwards(t.Context(), client, 5000, 0) + require.ErrorContains(t, err, "rpc down") +} + func TestSettlementSourceTxNotFound(t *testing.T) { client := mocks.NewBaseEthereumClienter(t) client.EXPECT().TransactionReceipt(mock.Anything, testTxHash).Return(nil, ethereum.NotFound) @@ -193,7 +397,7 @@ func TestSettlementSourceReceiptFetchError(t *testing.T) { } func TestSettlementSourceUnknownNetwork(t *testing.T) { - source := NewSettlementSource(StaticClients{}, aggkittypes.FinalizedBlock) + source := NewSettlementSource(StaticClients{}, aggkittypes.FinalizedBlock, testGERAddress) _, err := source.SettlementGERUpdate(t.Context(), &bridgetracker.BridgeInfo{}, testTxHash) require.ErrorContains(t, err, "network 0") diff --git a/bridgetracker/sources/sources_test.go b/bridgetracker/sources/sources_test.go index 985a1d144..23cb00bfe 100644 --- a/bridgetracker/sources/sources_test.go +++ b/bridgetracker/sources/sources_test.go @@ -3,6 +3,7 @@ package sources import ( "context" "encoding/json" + "errors" "fmt" "math/big" "net/http" @@ -209,9 +210,11 @@ type fakeBridgeService struct { injectedLeaf map[string]any // claimsCount is served by /bridge/v1/claims claimsCount int - // claimTxHash and claimBlockNum populate the single claim served when claimsCount > 0 - claimTxHash string - claimBlockNum uint64 + // claimTxHash, claimBlockNum and claimBlockTimestamp populate the single claim served when + // claimsCount > 0 + claimTxHash string + claimBlockNum uint64 + claimBlockTimestamp uint64 // lastLeafIndexQuery records the leaf_index of the last injected-l1-info-leaf request lastLeafIndexQuery string @@ -249,8 +252,8 @@ func (f *fakeBridgeService) start(t *testing.T) NetworkURLResolver { fmt.Fprint(w, `{"claims":[],"count":0}`) return } - fmt.Fprintf(w, `{"claims":[{"tx_hash":"%s","block_num":%d}],"count":%d}`, - f.claimTxHash, f.claimBlockNum, f.claimsCount) + fmt.Fprintf(w, `{"claims":[{"tx_hash":"%s","block_num":%d,"block_timestamp":%d}],"count":%d}`, + f.claimTxHash, f.claimBlockNum, f.claimBlockTimestamp, f.claimsCount) }) server := httptest.NewServer(mux) @@ -318,7 +321,7 @@ func TestFinderClients(t *testing.T) { func TestGERSourceOriginGER(t *testing.T) { fake := &fakeBridgeService{} - source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) // not covered yet -> nil, nil ger, err := source.OriginGER(t.Context(), l1ToL2Bridge()) @@ -346,7 +349,8 @@ func TestGERSourceOriginGER(t *testing.T) { func TestGERSourceInjectedGER(t *testing.T) { fake := &fakeBridgeService{} - source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + source := NewGERSource(fake.start(t), nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, + log.WithFields("module", "sources_test")) // not even covered on origin -> nil injected, err := source.InjectedGER(t.Context(), l1ToL2Bridge()) @@ -361,12 +365,14 @@ func TestGERSourceInjectedGER(t *testing.T) { require.Nil(t, injected) require.Equal(t, "42", fake.lastLeafIndexQuery, "must ask for the covering leaf index") - // injected -> GERData with the leaf roots + // injected -> GERData with the leaf roots and the injection's block number/timestamp fake.injectedLeaf = map[string]any{ "l1_info_tree_index": 42, "global_exit_root": "0x0a", "mainnet_exit_root": "0x0b", "rollup_exit_root": "0x0c", + "block_num": 200, + "timestamp": 1700000000, } injected, err = source.InjectedGER(t.Context(), l1ToL2Bridge()) require.NoError(t, err) @@ -375,6 +381,230 @@ func TestGERSourceInjectedGER(t *testing.T) { require.Equal(t, common.HexToHash("0x0a"), *injected.GER) require.Equal(t, common.HexToHash("0x0b"), *injected.MER) require.Equal(t, common.HexToHash("0x0c"), *injected.RER) + require.Equal(t, uint64(200), *injected.BlockNumber) + require.Equal(t, uint64(1700000000), *injected.BlockTimestamp) + // no L2BlockNumber/L2BlockTimestamp yet: this bridge-service instance predates them, so + // WaitingGERInjectionResolver must not mistake the L1 block above for the L2 injection one + // (that conflation was #1818) + require.Nil(t, injected.L2BlockNumber) + require.Nil(t, injected.L2BlockTimestamp) + + // once the bridge-service reports the real L2 injection block/timestamp, they land on their + // own fields — block_num/timestamp above stay the L1 event's, per InjectedL1InfoLeafHandler + fake.injectedLeaf["injected_l2_block_num"] = 999 + fake.injectedLeaf["injected_l2_block_timestamp"] = 1800000000 + injected, err = source.InjectedGER(t.Context(), l1ToL2Bridge()) + require.NoError(t, err) + require.NotNil(t, injected) + require.Equal(t, uint64(200), *injected.BlockNumber, "the L1 block must stay untouched") + require.Equal(t, uint64(1700000000), *injected.BlockTimestamp, "the L1 timestamp must stay untouched") + require.Equal(t, uint64(999), *injected.L2BlockNumber) + require.Equal(t, uint64(1800000000), *injected.L2BlockTimestamp) +} + +// TestGERSourceInjectedGER_FallsBackToL2Scan covers the #1818 fallback: when the destination's +// bridge-service instance does not report injected_l2_block_num at all, and its +// GlobalExitRootManagerL2 address is configured (l2GERAddrs), InjectedGER finds the injection +// itself by scanning the destination network's own UpdateHashChainValue logs. +func TestGERSourceInjectedGER_FallsBackToL2Scan(t *testing.T) { + fake := &fakeBridgeService{} + idx := uint32(42) + fake.l1InfoTreeIndex = &idx + fake.injectedLeaf = map[string]any{ + "l1_info_tree_index": 42, + "global_exit_root": "0x0a", + "mainnet_exit_root": "0x0b", + "rollup_exit_root": "0x0c", + "block_num": 200, + "timestamp": 1700000000, + // no injected_l2_block_num/injected_l2_block_timestamp: an old bridge-service instance + } + + l2GERAddr := common.HexToAddress("0x1234") + destNetwork := l1ToL2Bridge().DestinationNetwork + + mockL2Client := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(fake.start(t), StaticClients{destNetwork: mockL2Client}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{destNetwork: l2GERAddr}, 0, nil) + + head := uint64(5000) // within a single chunk (DefaultBlockChunkSize=10_000): no backward paging + mockL2Client.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: head}, nil) + + targetGER := common.HexToHash("0x0a") // must match fake.injectedLeaf's global_exit_root + otherGER := common.HexToHash("0x0b") + blockHash := common.HexToHash("0xblockhash") + mockL2Client.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + { // an unrelated GER's injection must not be mistaken for the one being searched + Topics: []common.Hash{updateHashChainValueSignature, otherGER, {}}, + BlockNumber: 111, + BlockHash: common.HexToHash("0xother"), + }, + { + Topics: []common.Hash{updateHashChainValueSignature, targetGER, {}}, + BlockNumber: 4321, + BlockHash: blockHash, + }, + }, nil).Once() + mockL2Client.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1800000000}, nil) + + injected, err := source.InjectedGER(t.Context(), l1ToL2Bridge()) + require.NoError(t, err) + require.NotNil(t, injected) + require.Equal(t, uint64(200), *injected.BlockNumber, "the L1 block must stay untouched") + require.NotNil(t, injected.L2BlockNumber) + require.Equal(t, uint64(4321), *injected.L2BlockNumber) + require.NotNil(t, injected.L2BlockTimestamp) + require.Equal(t, uint64(1800000000), *injected.L2BlockTimestamp) +} + +// TestFindL2InjectionBlockBackwards exercises GERSource.findL2InjectionBlockBackwards directly: +// the paginated backward scan, its termination conditions, and how it degrades (never an error +// InjectedGERAtIndex must propagate) when the fallback simply isn't configured for the network. +func TestFindL2InjectionBlockBackwards(t *testing.T) { + ger := common.HexToHash("0x0a") + l2GERAddr := common.HexToAddress("0x1234") + networkID := uint32(1) + + t.Run("network not in l2GERAddrs: no RPC call, nil result", func(t *testing.T) { + source := NewGERSource(nil, nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("found on the very first (most recent) chunk", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 400, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1700000000}, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Equal(t, uint64(400), *blockNumber) + require.Equal(t, uint64(1700000000), *timestamp) + }) + + t.Run("found only after paginating backwards past an empty chunk", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + // an explicit lookback well past head, so the pagination isn't cut short by + // DefaultL2InjectionLookbackBlocks (1_000) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 20_000, nil) + + // head is past one full DefaultBlockChunkSize (10_000), so the first chunk covers + // [5_000, 15_000] (empty) before the second one, [0, 4_999], finds the log + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 15_000}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 123, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash). + Return(&gethtypes.Header{Time: 1600000000}, nil) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Equal(t, uint64(123), *blockNumber) + require.Equal(t, uint64(1600000000), *timestamp) + }) + + t.Run("never found: scans back to genesis, returns nil without error", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("respects a configured lookback: never scans below the floor", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 5_000, nil) + + // floor = head (15_000) - lookback (5_000) = 10_000: the single chunk [10_000, 15_000] + // already covers the whole allowed window, so exactly one FilterLogs call is made and the + // scan gives up there instead of continuing down towards genesis (a second, unexpected + // FilterLogs call would fail this test: mockClient has no expectation registered for it) + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 15_000}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("lookback <= 0 falls back to DefaultL2InjectionLookbackBlocks: scans back to genesis", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + // head (500) is well within DefaultL2InjectionLookbackBlocks, so the floor is genesis (0) + // exactly as if no lookback had been configured at all + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{}, nil).Once() + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.NoError(t, err) + require.Nil(t, blockNumber) + require.Nil(t, timestamp) + }) + + t.Run("head lookup fails: propagates the error", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(nil, errors.New("boom")) + + _, _, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.ErrorContains(t, err, "boom") + }) + + t.Run("found, but resolving the block's timestamp fails: block number still returned", func(t *testing.T) { + mockClient := mocks.NewBaseEthereumClienter(t) + source := NewGERSource(nil, StaticClients{networkID: mockClient}, common.Address{}, + aggkittypes.FinalizedBlock, map[uint32]common.Address{networkID: l2GERAddr}, 0, nil) + + mockClient.EXPECT().CustomHeaderByNumber(mock.Anything, &aggkittypes.LatestBlock). + Return(&aggkittypes.BlockHeader{Number: 500}, nil) + blockHash := common.HexToHash("0xblockhash") + mockClient.EXPECT().FilterLogs(mock.Anything, mock.Anything).Return([]gethtypes.Log{ + {Topics: []common.Hash{updateHashChainValueSignature, ger, {}}, + BlockNumber: 400, BlockHash: blockHash}, + }, nil).Once() + mockClient.EXPECT().HeaderByHash(mock.Anything, blockHash).Return(nil, errors.New("boom")) + + blockNumber, timestamp, err := source.findL2InjectionBlockBackwards(t.Context(), networkID, ger) + require.ErrorContains(t, err, "boom") + require.Equal(t, uint64(400), *blockNumber) + require.Nil(t, timestamp) + }) } func TestClaimSourceClaimFor(t *testing.T) { @@ -393,11 +623,13 @@ func TestClaimSourceClaimFor(t *testing.T) { fake.claimsCount = 1 fake.claimTxHash = "0x0d" fake.claimBlockNum = 50 + fake.claimBlockTimestamp = 1700000000 claim, err = source.ClaimFor(t.Context(), l1ToL2Bridge()) require.NoError(t, err) require.NotNil(t, claim) require.Equal(t, common.HexToHash("0x0d"), claim.ClaimTx) require.Equal(t, uint64(50), claim.BlockNumber) + require.Equal(t, uint64(1700000000), claim.BlockTimestamp) } // rootCallOutput ABI-encodes the bridge contract's getRoot() return value, like a JSON-RPC @@ -455,7 +687,7 @@ func TestLERSourceBridgeEventLogNotFound(t *testing.T) { func TestSourcesUnresolvedNetworkIsTransient(t *testing.T) { resolver := staticURLs{} // no networks resolved - gerSource := NewGERSource(resolver, nil, common.Address{}, aggkittypes.FinalizedBlock, nil) + gerSource := NewGERSource(resolver, nil, common.Address{}, aggkittypes.FinalizedBlock, nil, 0, nil) claimSource := NewClaimSource(resolver) lerSource := NewLERSource(StaticClients{}) diff --git a/bridgetracker/types/activity_filter.go b/bridgetracker/types/activity_filter.go new file mode 100644 index 000000000..fecb605f4 --- /dev/null +++ b/bridgetracker/types/activity_filter.go @@ -0,0 +1,53 @@ +package types + +import "fmt" + +// ActivityFilter selects which bridges GetActivity (see domain.ActivityQuerier) returns for a +// from_address, based on ClaimStatus, and doubles as a hint to skip fetching data the caller +// does not want: requesting ActivityFilterPending or ActivityFilterError skips the destination +// bridge service's claim record for a bridge found to be claimed, since it would be filtered +// out of the result anyway — that bridge's cache entry simply stays unsettled and is fetched +// normally the next time a filter that needs it is used (see bridgetracker.ActivityCache.refresh) +type ActivityFilter int + +const ( + // ActivityFilterAll returns every bridge found, regardless of claim state (the default) + ActivityFilterAll ActivityFilter = iota + // ActivityFilterClaimed returns only bridges confirmed claimed + ActivityFilterClaimed + // ActivityFilterPending returns only bridges confirmed still unclaimed (ClaimStatusUnclaimed) + // — a bridge whose claim state could not be checked is not "pending", see ActivityFilterError + ActivityFilterPending + // ActivityFilterError returns only bridges whose isClaimed() check itself failed + // (ClaimStatusError): their claim state is unknown, neither claimed nor confirmed pending + ActivityFilterError +) + +var activityFilterNames = map[ActivityFilter]string{ + ActivityFilterAll: "all", + ActivityFilterClaimed: "claimed", + ActivityFilterPending: "pending", + ActivityFilterError: "error", +} + +// String representation of the enum: "all", "claimed", "pending" or "error" +func (f ActivityFilter) String() string { + if name, ok := activityFilterNames[f]; ok { + return name + } + return fmt.Sprintf("Unknown(%d)", int(f)) +} + +// ParseActivityFilter parses the filterBridges query parameter: "" (unset) and "all" both mean +// ActivityFilterAll. Returns an error for any other value. +func ParseActivityFilter(s string) (ActivityFilter, error) { + if s == "" { + return ActivityFilterAll, nil + } + for f, name := range activityFilterNames { + if name == s { + return f, nil + } + } + return ActivityFilterAll, fmt.Errorf("invalid filterBridges %q: must be one of all, claimed, pending, error", s) +} diff --git a/bridgetracker/types/activity_filter_test.go b/bridgetracker/types/activity_filter_test.go new file mode 100644 index 000000000..38419a489 --- /dev/null +++ b/bridgetracker/types/activity_filter_test.go @@ -0,0 +1,42 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestActivityFilterString(t *testing.T) { + require.Equal(t, "all", ActivityFilterAll.String()) + require.Equal(t, "claimed", ActivityFilterClaimed.String()) + require.Equal(t, "pending", ActivityFilterPending.String()) + require.Equal(t, "error", ActivityFilterError.String()) + require.Equal(t, "Unknown(99)", ActivityFilter(99).String()) +} + +func TestParseActivityFilter(t *testing.T) { + tests := []struct { + input string + want ActivityFilter + wantErr bool + }{ + {input: "", want: ActivityFilterAll}, + {input: "all", want: ActivityFilterAll}, + {input: "claimed", want: ActivityFilterClaimed}, + {input: "pending", want: ActivityFilterPending}, + {input: "error", want: ActivityFilterError}, + {input: "bogus", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseActivityFilter(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/bridgetracker/types/claim_status.go b/bridgetracker/types/claim_status.go new file mode 100644 index 000000000..34f4a26c0 --- /dev/null +++ b/bridgetracker/types/claim_status.go @@ -0,0 +1,36 @@ +package types + +import "fmt" + +// ClaimStatus is the tri-state result of checking a bridge's on-chain claim state (see +// domain.ActivityEntry / the GET /activity/from/{from_address} endpoint): unlike a plain bool, +// it distinguishes "confirmed unclaimed" from "the check itself failed" (e.g. no bridge +// contract address configured for the destination network, or an RPC failure) — a caller must +// not read ClaimStatusError as "not claimed". +type ClaimStatus int + +const ( + // ClaimStatusUnclaimed the destination bridge contract's isClaimed() call succeeded and + // reported the bridge as not yet claimed + ClaimStatusUnclaimed ClaimStatus = iota + // ClaimStatusClaimed the destination bridge contract's isClaimed() call succeeded and + // reported the bridge as claimed + ClaimStatusClaimed + // ClaimStatusError the isClaimed() check itself failed; the claim state is unknown and + // will be retried on the next call + ClaimStatusError +) + +var claimStatusNames = map[ClaimStatus]string{ + ClaimStatusUnclaimed: "false", + ClaimStatusClaimed: "true", + ClaimStatusError: "error", +} + +// String representation of the enum: "false", "true" or "error" +func (s ClaimStatus) String() string { + if name, ok := claimStatusNames[s]; ok { + return name + } + return fmt.Sprintf("Unknown(%d)", int(s)) +} diff --git a/bridgetracker/types/status.go b/bridgetracker/types/status.go index 533220986..30d0a40f6 100644 --- a/bridgetracker/types/status.go +++ b/bridgetracker/types/status.go @@ -264,11 +264,35 @@ type GERUpdateResult struct { LogIndex uint `json:"log_index"` } -// InjectedGERResult is the result of StepWaitingGERInjection once it completes: the GER -// injected on the destination network that covers the bridge. The injection source does not -// expose the block it was injected in, unlike GERUpdateResult +// InjectedGERResult is the result of StepWaitingGERInjection once it completes: the GER covering +// the bridge, resolved to its L1 Info Tree leaf (L1InfoTreeLeaf, always known once the step +// completes) and, once actually injected on the destination network, that injection's own L2 +// block/timestamp (L2InjectedGER). L2InjectedGER is nil when the destination's bridge-service +// instance does not report it yet (predates injected_l2_block_num/injected_l2_block_timestamp on +// GET /bridge/v1/injected-l1-info-leaf, see bridgeservice/types.L1InfoTreeLeafResponse) — in that +// case L1InfoTreeLeaf's own BlockNumber/BlockTimestamp are the L1 event that produced the leaf, +// not the L2 injection block, and must not be mistaken for it (this conflation was #1818) type InjectedGERResult struct { - GER common.Hash `json:"ger"` + L1InfoTreeLeaf InjectedGERL1Leaf `json:"l1_info_tree_leaf"` + L2InjectedGER *InjectedL2GERBlock `json:"l2_injected_ger,omitempty"` +} + +// InjectedGERL1Leaf is the L1 Info Tree leaf covering the bridge: its GER and the L1 block/ +// timestamp of the UpdateL1InfoTree/UpdateL1InfoTreeV2 event that produced it +type InjectedGERL1Leaf struct { + GER common.Hash `json:"ger"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp uint64 `json:"block_timestamp"` +} + +// InjectedL2GERBlock is the L2 block the GER was actually injected at on the destination +// network. BlockTimestamp is only known once resolved from the destination's L2 RPC (see +// l2gersync.L2GERSync.GetFirstGERAfterL1InfoTreeIndex), so it may briefly be absent right after +// upgrading a bridge-service instance that only just started reporting BlockNumber, resolving on +// a later request +type InjectedL2GERBlock struct { + BlockNumber uint64 `json:"block_number"` + BlockTimestamp *uint64 `json:"block_timestamp,omitempty"` } // LERUpdateResult is the result of StepWaitingLERUpdate once it completes: the LER produced @@ -279,27 +303,40 @@ type LERUpdateResult struct { BlockNumber uint64 `json:"block_number"` } -// ClaimResult is the result of StepWaitingClaim once it completes: the claim transaction on +// ClaimResult is the result of StepClaimed once it completes: the claim transaction on // the destination network and the block it was mined in type ClaimResult struct { - ClaimTx common.Hash `json:"claim_tx"` - BlockNumber uint64 `json:"block_number"` + ClaimTx common.Hash `json:"claim_tx"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp uint64 `json:"block_timestamp"` } // L1SettledGERResult is the result of StepWaitL1SettledGER once it completes: the evidence, // read off the certificate's settlement tx receipt on L1, that the settlement propagated to // the L1 Global Exit Root. HasVerifyBatchesTrustedAggregator and HasUpdateL1InfoTree are both -// required for the step to complete; HasUpdateL1InfoTreeV2 is only informational. GER is the -// Global Exit Root produced by the settlement (computed from UpdateL1InfoTree's mainnet/rollup -// exit roots), used by StepWaitingGERInjection to check whether it has reached the destination. -// L1InfoTreeIndex is the leaf index GER landed at: populated straight from UpdateL1InfoTreeV2's -// LeafCount when that (optional) event fires, otherwise resolved by the step itself with one -// extra lookup (GER -> leaf) before it can complete — either way, by the time this step is -// Done, L1InfoTreeIndex is never nil +// required for the step to complete; HasUpdateL1InfoTreeV2 is only informational. +// SettlementBlockNumber/SettlementBlockTimestamp/SettlementLogIndex locate the settlement tx's +// own VerifyBatchesTrustedAggregator log — the event that confirms this tx is a genuine +// certificate settlement. GER is the Global Exit Root produced by the settlement (computed +// from UpdateL1InfoTree's mainnet/rollup exit roots), used by StepWaitingGERInjection to check +// whether it has reached the destination. GERBlockNumber/GERBlockTimestamp/GERLogIndex locate +// the UpdateL1InfoTree event GER was computed from: normally the same block as the settlement +// (HasUpdateL1InfoTree true), but when the settlement tx's own receipt does not carry the +// event (the settlement did not move the GER itself), they instead point to the closest +// earlier one on L1 (see sources.SettlementSource.findEventUpdateL1InfoTreeBackwards), whose +// GER is still the one this settlement propagated. L1InfoTreeIndex is the leaf index GER +// landed at: populated straight from UpdateL1InfoTreeV2's LeafCount when that (optional) event +// fires, otherwise resolved by the step itself with one extra lookup (GER -> leaf) before it +// can complete — either way, by the time this step is Done, L1InfoTreeIndex is never nil type L1SettledGERResult struct { TxHash common.Hash `json:"tx_hash"` - BlockNumber uint64 `json:"block_number"` + SettlementBlockNumber uint64 `json:"settlement_block_number"` + SettlementBlockTimestamp uint64 `json:"settlement_block_timestamp"` + SettlementLogIndex uint `json:"settlement_log_index"` GER common.Hash `json:"ger"` + GERBlockNumber uint64 `json:"ger_block_number"` + GERBlockTimestamp uint64 `json:"ger_block_timestamp"` + GERLogIndex uint `json:"ger_log_index"` L1InfoTreeIndex *uint32 `json:"l1_info_tree_index,omitempty"` HasVerifyBatchesTrustedAggregator bool `json:"has_verify_batches_trusted_aggregator"` HasUpdateL1InfoTree bool `json:"has_update_l1_info_tree"` @@ -322,10 +359,25 @@ type GERData struct { LERType LERType `json:"ler_type"` // LERTypeString is the string representation of LERType, auto-populated on JSON marshaling LERTypeString string `json:"ler_type_string"` - // BlockNumber is the block where the GER update happened. Only populated when resolving - // the origin GER of an L1-originated bridge. Internal only: GERData is not serialized on - // any tracker response, it is the domain layer's currency to decide GER coverage + // BlockNumber is the block where the GER update happened. Populated when resolving the + // origin GER of an L1-originated bridge, and the GER injected on a bridge's destination + // network (see StepWaitingGERInjection's InjectedGERResult, which carries it on the wire). + // Internal only otherwise: GERData is not serialized on any tracker response, it is the + // domain layer's currency to decide GER coverage BlockNumber *uint64 `json:"-"` + // BlockTimestamp is BlockNumber's block timestamp. Populated (and carried on the wire) under + // the same conditions as BlockNumber + BlockTimestamp *uint64 `json:"-"` + // L2BlockNumber/L2BlockTimestamp are the actual L2 block/timestamp the GER was injected at on + // the destination network. Only set by InjectedGERAtIndex, and only when the destination's + // bridge-service instance reports it (see bridgeservice/types.L1InfoTreeLeafResponse's + // InjectedL2BlockNumber/InjectedL2BlockTimestamp). Unlike BlockNumber/BlockTimestamp above — + // always the L1 event, even here — these stay nil while unknown instead of being backfilled + // with the L1 block, which is exactly the #1818 bug this pair exists to avoid repeating + L2BlockNumber *uint64 `json:"-"` + // L2BlockTimestamp is L2BlockNumber's timestamp; may lag L2BlockNumber briefly if resolving + // it from the L2 RPC failed (see l2gersync.L2GERSync.GetFirstGERAfterL1InfoTreeIndex) + L2BlockTimestamp *uint64 `json:"-"` } // MarshalJSON is the implementation of the json.Marshaler interface. @@ -345,6 +397,12 @@ type CertificateData struct { // Error is only set if the certificate carries an error message (relevant for InError certs) Error string `json:"error,omitempty"` SettlementTxHash *common.Hash `json:"settlement_tx_hash,omitempty"` + // BlockNumber/BlockTimestamp locate SettlementTxHash on L1, once its receipt is visible + // there. Only ever set once Status.IsSettled(): even then, both stay nil for a transient + // tick (the settlement tx's own receipt can lag a step behind the certificate turning + // Settled — see CertificatePendingResolver), so nil is not necessarily permanent + BlockNumber *uint64 `json:"block_number,omitempty"` + BlockTimestamp *uint64 `json:"block_timestamp,omitempty"` } // MarshalJSON is the implementation of the json.Marshaler interface. diff --git a/docs/assets/swagger/bridge_tracker/swagger.json b/docs/assets/swagger/bridge_tracker/swagger.json index 9d9ec95c0..015001b08 100644 --- a/docs/assets/swagger/bridge_tracker/swagger.json +++ b/docs/assets/swagger/bridge_tracker/swagger.json @@ -15,6 +15,138 @@ }, "basePath": "/tracker/v1", "paths": { + "/activity/from/{from_address}": { + "get": { + "description": "Scans every bridge service the tracker knows about for bridges sent by\nfrom_address and reports each one's claim state, exactly as the bridge service\nreported it. Results are cached: a bridge already known to be claimed, with its\nclaim record already fetched, is not rechecked on a later call. Passing\nincludeTracking=true additionally registers every still-unclaimed bridge with\nthe bridge tracker and includes its current tracking snapshot. filterBridges\nrestricts the result to bridges with only that claim state (claimed / still\npending / errored while checking). A network whose bridge service could not be\nscanned is skipped and reported in the \"warnings\" field instead of failing the\nwhole request.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get bridge activity by sender address", + "parameters": [ + { + "type": "string", + "description": "Address that sent the bridges to look up", + "name": "from_address", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Register still-unclaimed bridges with the tracker", + "name": "includeTracking", + "in": "query" + }, + { + "enum": [ + "all", + "claimed", + "pending", + "error" + ], + "type": "string", + "default": "all", + "description": "Which bridges to return", + "name": "filterBridges", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActivityResponse" + } + }, + "400": { + "description": "Invalid from_address or filterBridges", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Scanning the configured bridge services failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, + "/bridge-address/{network_id}": { + "get": { + "description": "With no network_id, reports the bridge contract address of every network the\ntracker currently knows about (via the bridge service finder). With network_id,\nreports only that network's.", + "produces": [ + "application/json" + ], + "tags": [ + "bridge-tracker" + ], + "summary": "Get the bridge contract address of one network, or every network", + "parameters": [ + { + "type": "integer", + "description": "Network to look up; omit to get every network", + "name": "network_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Body when network_id is set", + "schema": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + }, + "400": { + "description": "Invalid network_id", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + }, + "500": { + "description": "Resolving the bridge contract address failed", + "schema": { + "$ref": "#/definitions/types.ErrorData" + } + } + } + } + }, "/health": { "get": { "description": "Returns the health status, instance identity and build information of the\nrunning instance. Useful as liveness/readiness probe and to check which\nbuild/configuration runs on each instance behind the proxy", @@ -120,6 +252,129 @@ } }, "definitions": { + "api.ActivityItem": { + "type": "object", + "properties": { + "bridge": { + "description": "Bridge is the raw bridge event, exactly as returned by the origin network's bridge\nservice, unmodified", + "allOf": [ + { + "$ref": "#/definitions/types.BridgeResponse" + } + ] + }, + "bridge_network_id": { + "description": "BridgeNetworkID is the network whose bridge service reported Bridge — not necessarily\nBridge.OriginNetwork, which is the origin network of the bridged asset and can differ for\na re-bridged asset (see domain.ScannedBridge)", + "type": "integer" + }, + "claim": { + "description": "Claim is the raw claim record, exactly as returned by the destination network's bridge\nservice, unmodified, once Claimed is true and the indexer has recorded it", + "allOf": [ + { + "$ref": "#/definitions/types.ClaimResponse" + } + ] + }, + "claim_network_id": { + "description": "ClaimNetworkID is the network whose bridge service reported Claim (the bridge's\ndestination network); only present alongside Claim", + "type": "integer" + }, + "claimed": { + "description": "Claimed is the tri-state result of the destination bridge contract's isClaimed() call\nthe last time it was checked: \"false\" (confirmed unclaimed), \"true\" (claimed), or\n\"error\" if the check itself failed (e.g. no bridge contract address configured for the\ndestination network) — callers must not read \"error\" as \"false\"", + "type": "string" + }, + "creation_timestamp": { + "description": "CreationTimestamp is when this bridge was first cached by the activity endpoint (unix\nseconds); it never changes after that", + "type": "integer" + }, + "errors": { + "description": "Errors holds the message of whatever check failed the last time this item was refreshed,\nkeyed by which check it was — currently only \"claim\", present when Claimed is \"error\"", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "last_updated_timestamp": { + "description": "LastUpdatedTimestamp is when this item's claim/tracking state was last (re)checked (unix\nseconds), whether or not anything about it actually changed. Stops advancing once the\nbridge is claimed with its claim record fetched — nothing left to recheck", + "type": "integer" + }, + "tracking": { + "description": "Tracking is the bridge tracker's current status for this bridge; only present when the\nrequest set includeTracking=true and the bridge is still unclaimed", + "allOf": [ + { + "$ref": "#/definitions/api.TrackingData" + } + ] + } + } + }, + "api.ActivityResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds every bridge found for FromAddress across every configured bridge service", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityItem" + } + }, + "from_address": { + "description": "FromAddress is the address requested", + "type": "array", + "items": { + "type": "integer" + } + }, + "warnings": { + "description": "Warnings lists every network whose bridge service could not be scanned this call; absent\nwhen every configured network was scanned successfully. Bridges may be incomplete for the\nnetworks listed here, but is still valid for every other network", + "type": "array", + "items": { + "$ref": "#/definitions/api.ActivityWarningItem" + } + } + } + }, + "api.ActivityWarningItem": { + "type": "object", + "properties": { + "message": { + "description": "Message is the error encountered while scanning NetworkID", + "type": "string" + }, + "network_id": { + "description": "NetworkID is the network whose bridge service could not be scanned", + "type": "integer" + } + } + }, + "api.BridgeAddressItem": { + "type": "object", + "properties": { + "bridge_address": { + "description": "BridgeAddress is the bridge contract address on NetworkID", + "type": "array", + "items": { + "type": "integer" + } + }, + "network_id": { + "description": "NetworkID is the network BridgeAddress belongs to", + "type": "integer" + } + } + }, + "api.BridgeAddressResponse": { + "type": "object", + "properties": { + "bridges": { + "description": "Bridges holds the bridge contract address of every network currently known", + "type": "array", + "items": { + "$ref": "#/definitions/api.BridgeAddressItem" + } + } + } + }, "api.BridgeEventData": { "type": "object", "properties": { @@ -206,7 +461,7 @@ "$ref": "#/definitions/github_com_agglayer_aggkit_bridgetracker_types.Duration" }, "result": { - "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepWaitingClaim). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" + "description": "Result is the data the step has produced so far; its shape depends on Step:\n*types.GERUpdateResult (StepWaitingGERUpdate), *types.InjectedGERResult\n(StepWaitingGERInjection), *types.LERUpdateResult (StepWaitingLERUpdate),\n*types.PendingInclusionResult (StepPendingInclusion), *types.CertificateData\n(StepCertificatePending), *types.L1SettledGERResult (StepWaitL1SettledGER) or\n*types.ClaimResult (StepClaimed). nil until\nthe step produces one, and for steps that never do. Most steps only set this once Done,\nbut StepCertificatePending (Status still InProgress) may already carry the certificate's\ncurrent, not yet settled, status — see domain.ErrCertificateNotSettled" }, "start_date": { "type": "string" @@ -295,14 +550,6 @@ 1000000000, 60000000000, 3600000000000, - -9223372036854775808, - 9223372036854775807, - 1, - 1000, - 1000000, - 1000000000, - 60000000000, - 3600000000000, 1, 1000, 1000000, @@ -327,14 +574,6 @@ "Second", "Minute", "Hour", - "minDuration", - "maxDuration", - "Nanosecond", - "Microsecond", - "Millisecond", - "Second", - "Minute", - "Hour", "Nanosecond", "Microsecond", "Millisecond", @@ -345,6 +584,202 @@ } } }, + "types.BridgeResponse": { + "description": "Detailed information about a bridge event", + "type": "object", + "properties": { + "amount": { + "description": "Amount of tokens being bridged", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the bridge event was recorded", + "type": "integer", + "example": 1234 + }, + "block_pos": { + "description": "Position of the bridge event within the block", + "type": "integer", + "example": 1 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the bridge event", + "type": "integer", + "example": 1684500000 + }, + "bridge_hash": { + "description": "Unique hash representing the bridge event, often used as an identifier", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd" + }, + "deposit_count": { + "description": "Count of total deposits processed so far for the given token/address", + "type": "integer", + "example": 10 + }, + "destination_address": { + "description": "Address of the token receiver on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "ID of the network where the bridge transaction is destined", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address that initiated the transaction on bridge contract. It can be intermediary contract or EOA.\nMay be null if bridge was synced with SyncFromInBridges=false", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_index": { + "description": "Global index of the bridge event (consisted of mainnet flag, rollup id and deposit count)", + "type": "string", + "example": "4294967296" + }, + "leaf_type": { + "description": "Type of leaf (bridge event type) used in the tree structure", + "type": "integer", + "example": 1 + }, + "metadata": { + "description": "Optional metadata attached to the bridge event", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address of the token sender on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "ID of the network where the bridge transaction originated", + "type": "integer", + "example": 10 + }, + "to_address": { + "description": "Address of the contract that was the recipient of the transaction. This may differ from the bridge contract address.", + "type": "string", + "example": "0xF9D64d54D32EE2BDceAAbFA60C4C438E224427d0" + }, + "tx_hash": { + "description": "Hash of the transaction that included the bridge event", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + "txn_sender": { + "description": "Address of the transaction sender who initiated the bridge transaction", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef12345" + } + } + }, + "types.ClaimResponse": { + "description": "Detailed information about a claim event", + "type": "object", + "properties": { + "amount": { + "description": "Amount claimed", + "type": "string", + "example": "1000000000000000000" + }, + "block_num": { + "description": "Block number where the claim was processed", + "type": "integer", + "example": 1234 + }, + "block_timestamp": { + "description": "Timestamp of the block containing the claim", + "type": "integer", + "example": 1684500000 + }, + "destination_address": { + "description": "Address receiving the claim on the destination network", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef12345678" + }, + "destination_network": { + "description": "Destination network ID where the claim was processed", + "type": "integer", + "example": 42161 + }, + "from_address": { + "description": "Address from which the claim originated", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "global_exit_root": { + "description": "Global exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "global_index": { + "description": "Global index of the claim", + "type": "string", + "example": "1000000000000000000" + }, + "is_message": { + "description": "IsMessage indicates whether this is a message claim (leaf type 1) rather than an asset claim (leaf type 0)", + "type": "boolean", + "example": false + }, + "mainnet_exit_root": { + "description": "Mainnet exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "metadata": { + "description": "Metadata associated with the claim", + "type": "string", + "example": "0xdeadbeef" + }, + "origin_address": { + "description": "Address initiating the claim on the origin network", + "type": "string", + "example": "0xabc1234567890abcdef1234567890abcdef1234" + }, + "origin_network": { + "description": "Origin network ID where the claim was initiated", + "type": "integer", + "example": 10 + }, + "proof_local_exit_root": { + "description": "Proof local exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x1", + " 0x2", + " 0x3...]" + ] + }, + "proof_rollup_exit_root": { + "description": "Proof rollup exit root associated with the claim (optional)", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[0x4", + " 0x5", + " 0x6...]" + ] + }, + "rollup_exit_root": { + "description": "Rollup exit root associated with the claim", + "type": "string", + "example": "0x27ae5ba08d7291c96c8cbddcc148bf48a6d68c7974b94356f53754ef6171d757" + }, + "tx_hash": { + "description": "Transaction hash associated with the claim", + "type": "string", + "example": "0xdef4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + } + }, "types.ErrorData": { "type": "object", "properties": { diff --git a/docs/bridgetracker.md b/docs/bridgetracker.md index 1569a36aa..7a66eb1a1 100644 --- a/docs/bridgetracker.md +++ b/docs/bridgetracker.md @@ -73,6 +73,12 @@ RegisterResolveTimeout = "3s" L1BlockFinality = "LatestBlock" L2BlockFinality = "LatestBlock" MaxTrackedBridges = 100000 +L2InjectionLookbackBlocks = 1000 + +# Workaround only: uncomment for a destination network whose bridge-service instance does not +# report the L2 block a covering GER was injected at. +# [Tracker.L2GlobalExitRootAddress] +# 1 = "0x..." [Tracker.AgglayerClient] Cached = true @@ -104,6 +110,15 @@ UseTLS = false - `MaxTrackedBridges`: caps the in-memory supervised list; a request beyond it fails instead of registering the bridge — reaching the cap never evicts an existing entry to make room, so `RetentionPeriod` and `IdleTimeout` are what keep the registry under it during normal operation. +- `L2GlobalExitRootAddress`: **workaround only** — a networkID → `GlobalExitRootManagerL2` contract + address map, used solely as a fallback for a destination network whose bridge-service instance + does not report the L2 block a covering GER was actually injected at. For a network present + here, the tracker scans that network's own L2 for the `UpdateHashChainValue` event instead of + leaving it absent. A network absent from this map (the default, empty map) never gets this + fallback attempted; it should not be set otherwise. +- `L2InjectionLookbackBlocks`: bounds how many blocks that same fallback scans backwards from the + destination network's head before giving up, instead of continuing all the way back to genesis. + Defaults to 1,000 blocks when unset or `<= 0`. - `AgglayerClient`: the client used to resolve an L2-originated bridge's covering certificate and its status (`PendingInclusion`/`CertificatePending`/`WaitL1SettledGER`). `Cached` is the master switch for `ConfigurationCache`'s per-method policy (`false` ignores it entirely). Each method diff --git a/docs/bridgetracker/API.md b/docs/bridgetracker/API.md index d6361eb7e..091a8e6d6 100644 --- a/docs/bridgetracker/API.md +++ b/docs/bridgetracker/API.md @@ -1,6 +1,9 @@ # API The API is going to be an API REST: GET /tracker/v1/network/{network_id}/tx/{tx_hash} +GET /tracker/v1/activity/from/{from_address} +GET /tracker/v1/bridge-address +GET /tracker/v1/bridge-address/{network_id} GET /tracker/v1/health In addition to the REST endpoint, a WebSocket endpoint is provided to receive bridge status updates as they happen (see [WebSocket](#websocket)). @@ -209,10 +212,10 @@ Carried in the `result` field of a [BridgeStepPath](#bridgesteppath). Its shape | WaitingGERUpdate | `l1_info_tree_index` (uint32), `ger` (Hash), `mer` (Hash), `rer` (Hash), `block_number` (uint64), `block_timestamp` (uint64), `log_index` (uint) | GER resulting from the update on L1, the L1 info tree leaf index it landed at, and the block where it was updated | | WaitingLERUpdate | `network_id` (uint32), `ler` (Hash), `block_number` (uint64) | LER resulting from the update on the origin L2 and the block where it was updated | | PendingInclusion | `certificate_id` (Hash), `new_ler` (Hash), `previous_ler` (*Hash) | the certificate that first includes the bridge and the LER transition it produced; `previous_ler` is nil for a network's first certificate | -| CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data once `status` is `done` | -| WaitL1SettledGER | `tx_hash` (Hash), `block_number` (uint64), `ger` (Hash), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | -| WaitingGERInjection | `ger` (Hash) | GER injected on the destination network that covers the bridge; no block number, the injection source does not expose it | -| WaitingClaim | `claim_tx` (Hash), `block_number` (uint64) | claim transaction on the destination network and its block | +| CertificatePending | [CertificateData](#certificatedata) | the certificate's current data; set as soon as a certificate exists, updated as its status changes (Pending, Proven, Candidate, InError), and reflects the final settled data — including `block_number`/`block_timestamp` — once `status` is `done` | +| WaitL1SettledGER | `tx_hash` (Hash), `settlement_block_number` (uint64), `settlement_block_timestamp` (uint64), `settlement_log_index` (uint), `ger` (Hash), `ger_block_number` (uint64), `ger_block_timestamp` (uint64), `ger_log_index` (uint), `l1_info_tree_index` (*uint32), `has_verify_batches_trusted_aggregator` (bool), `has_update_l1_info_tree` (bool), `has_update_l1_info_tree_v2` (bool) | evidence, read off the certificate's settlement tx receipt once it reaches L1 finality, that the settlement propagated to the L1 Global Exit Root; `ger` is computed from `UpdateL1InfoTree`'s mainnet/rollup exit roots, and `ger_block_number`/`ger_block_timestamp`/`ger_log_index` locate the event it was computed from — normally the same block as the settlement, but the closest earlier one on L1 when the settlement tx's own receipt didn't move the GER itself. `l1_info_tree_index` is the leaf `ger` landed at — populated straight from `UpdateL1InfoTreeV2`'s `LeafCount` when that (optional) event fires, otherwise resolved with one extra GER->leaf lookup before the step can complete; it is never `null` once the step is `done`. The two `has_*` booleans besides `has_update_l1_info_tree_v2` are required for the step to complete, that third one is informational only | +| WaitingGERInjection | [InjectedGERResult](#injectedgerresult) | GER covering the bridge: the L1 Info Tree leaf it resolves to, and — once known — the actual L2 block/timestamp it was injected at | +| Claimed | `claim_tx` (Hash), `block_number` (uint64), `block_timestamp` (uint64) | claim transaction on the destination network, its block and that block's timestamp | | any other step | — | no result: always `nil` | ## ErrorStep @@ -247,6 +250,68 @@ tracker endpoint or WebSocket message currently serializes it. | ler_type | LERType (int) | 0->NA, 1->Mainnet , 2-> Local | ler_type_string | string | string representation of ler_type (e.g. "Mainnet") +## InjectedGERResult + +The result of `WaitingGERInjection` once it completes: the GER covering the bridge, split into +where it comes from on each side. `l1_info_tree_leaf`'s `block_number`/`block_timestamp` are +always the **L1** `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` event that produced the leaf — never the +block it was actually injected at on the destination network. `l2_injected_ger` carries that +separately, and is the fix for a bug where the L1 block was returned in its place (making the +result useless for calculating L2-side injection timing). + +`l2_injected_ger` is resolved two ways, in order: + +1. Straight from the destination's bridge-service instance (`injected_l2_block_num`/ + `injected_l2_block_timestamp` on `GET /bridge/v1/injected-l1-info-leaf`, see + [REFERENCE_API.md](REFERENCE_API.md)) — the common case. +2. If that instance predates those fields, and the destination network's + `GlobalExitRootManagerL2` contract address is configured (`Tracker.L2GlobalExitRootAddress`, + `bridgetracker/config.go`), the tracker falls back to scanning that network's own + `UpdateHashChainValue` logs backwards from latest until it finds the one that injected this + GER (`GERSource.findL2InjectionBlockBackwards`, `bridgetracker/sources/ger.go`). + +`l2_injected_ger` is **omitted** (no key), not `null`, when neither resolves it — the bridge-service +doesn't report it and either no `L2GlobalExitRootAddress` entry is configured for the network or the +backward scan itself failed or found nothing (logged as a warning, never fails the step). + +| field | type | desc | +| ------|------|------| +| l1_info_tree_leaf.ger | Hash | Global Exit Root covering the bridge | +| l1_info_tree_leaf.block_number | uint64 | L1 block of the event that produced the leaf | +| l1_info_tree_leaf.block_timestamp | uint64 | that L1 block's timestamp | +| l2_injected_ger | *object | **omitted** (no key) while neither resolution path above produces a value | +| l2_injected_ger.block_number | uint64 | L2 block where the GER was actually injected on the destination network | +| l2_injected_ger.block_timestamp | *uint64 | that L2 block's timestamp; **omitted** (no key) — separately from `l2_injected_ger` itself — if it could not yet be resolved (e.g. the bridge-service's own RPC backfill, see [REFERENCE_API.md](REFERENCE_API.md), hasn't succeeded yet), resolving on a later request | + +Example (fully resolved): + +```json +{ + "l1_info_tree_leaf": { + "ger": "0x330d1f1546dc784aa465fdf83fb9d88e0a3778064d74e182c1dfb803ef155c1", + "block_number": 11606405, + "block_timestamp": 1788188124 + }, + "l2_injected_ger": { + "block_number": 11606512, + "block_timestamp": 1788188250 + } +} +``` + +Example (destination bridge-service instance not yet upgraded, or `l2_injected_ger.block_timestamp` +still resolving): + +```json +{ + "l1_info_tree_leaf": { + "ger": "0x330d1f1546dc784aa465fdf83fb9d88e0a3778064d74e182c1dfb803ef155c1", + "block_number": 11606405, + "block_timestamp": 1788188124 + } +} +``` + ## CertificateData This is one of the two response fields that **does** follow the numeric+`_string` convention @@ -260,6 +325,8 @@ bare string, see the note at the top of [Response types](#response-types). | status_string | string | string representation of status (e.g. "Settled") | error | string | Only set if the proto carries `Error.Message` (relevant for `InError` certs); **omitted** (no key) otherwise | | settlement_tx_hash | *Hash | Set once the certificate has a settlement tx (normally only from `Settled` onward); **omitted** (no key), not `null`, before that | +| block_number | *uint64 | The L1 block `settlement_tx_hash` was mined in; **omitted** (no key), not `null`, until it is visible there — which can lag a tick behind `status` turning `Settled` | +| block_timestamp | *uint64 | `block_number`'s timestamp; same omit/lag rules as `block_number` | Example (settled certificate, as it appears in `all_steps[i].result` for `CertificatePending`): @@ -268,7 +335,9 @@ Example (settled certificate, as it appears in `all_steps[i].result` for `Certif "certificate_id": "0x0000000000000000000000000000000000000000000000000000000000000001", "status": 4, "status_string": "Settled", - "settlement_tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002" + "settlement_tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002", + "block_number": 400, + "block_timestamp": 1700000400 } ``` @@ -331,6 +400,262 @@ Example: } ``` +## Activity + +GET /tracker/v1/activity/from/{from_address} + +Answers "what bridges has this address sent, and what is their claim state" across **every +bridge service the tracker currently knows about** (via the bridge service finder), instead of +one network/tx at a time like the main endpoint. Results are cached per `from_address` (see +[Caching and eviction](#caching-and-eviction) below). + +Request: + +| param | location | type | mandatory | desc | +| ------|----------|------|-----------|------| +| from_address | path | Address | yes | address that sent the bridges to look up | +| includeTracking | query | bool | no | `true` additionally registers every still-unclaimed bridge in the result with the bridge tracker (same effect as calling the main endpoint for it) and includes its current [TrackingData](#trackingdata) snapshot. Default `false` | +| filterBridges | query | string | no | one of `"all"` (default), `"claimed"`, `"pending"`, `"error"` — restricts the result to bridges with only that `claimed` state | + +### Behavior + +- `200 OK` — the body is an [ActivityResponse](#activityresponse). +- `400 Bad Request` — invalid `from_address`, or an unrecognized `filterBridges` value: the body is an [ErrorData](#errordata). +- `500 Internal Server Error` — scanning the configured bridge services failed: the body is an [ErrorData](#errordata). +- **This endpoint is opt-in**: it only exists if the binary is configured with both an activity bridge scanner and claim checker (`Config.ActivityScanner`/`ActivityClaims`); otherwise the route is not registered at all (plain `404`). +- Requesting `filterBridges=pending` or `filterBridges=error` **skips fetching the claim record** of a bridge found to be claimed, since it would be filtered out of that result anyway — its cache entry simply has no `claim` yet, and is fetched normally the next time `filterBridges=all`/`claimed` is used for that address. +- A network whose bridge service could not be scanned **never fails the request**: it is skipped and reported in `warnings` instead, so `bridges` is still whatever every other network reported (possibly incomplete for the networks listed in `warnings`). + +### ActivityResponse + +| field | type | desc | +| ------|------|------| +| from_address | Address | the address requested | +| bridges | ActivityItem [] | every bridge found for `from_address`, across every configured bridge service, matching `filterBridges` | +| warnings | ActivityWarningItem [] | every network whose bridge service could not be scanned this call; **omitted** (no key) when every configured network was scanned successfully | + +### ActivityWarningItem + +| field | type | desc | +| ------|------|------| +| network_id | uint32 | the network whose bridge service could not be scanned | +| message | string | the error encountered while scanning `network_id` | + +### ActivityItem + +`bridge` and `claim` are the bridge service's own response shapes, reported **exactly as-is** +(see [BridgeResponse](#bridgeresponse) / [ClaimResponse](#claimresponse) below) — this endpoint +is a cache over that data, not a reinterpretation of it. `bridge_network_id`/`claim_network_id` +sit alongside them (not nested inside) so the caller knows which bridge service produced each one. + +| field | type | desc | +| ------|------|------| +| bridge | BridgeResponse | raw bridge event, exactly as returned by the bridge service that reported it | +| bridge_network_id | uint32 | the network whose bridge service returned `bridge` — i.e. the network the bridge-creating tx was actually sent to. **Not** the same as `bridge.origin_network`, which is the origin network of the bridged *asset* and can differ when re-bridging an asset that itself originated on a third network | +| claimed | string | bare string, tri-state result of the destination bridge contract's `isClaimed()` call the last time it was checked: `"false"` (confirmed unclaimed), `"true"` (claimed), or `"error"` if the check itself failed (e.g. no bridge contract address configured for the destination network) — callers must **not** read `"error"` as `"false"` | +| claim_network_id | uint32 | network whose bridge service reported `claim` (the bridge's destination network); **omitted** (no key) until `claim` is present | +| claim | ClaimResponse | raw claim record, exactly as returned by the destination network's bridge service, once `claimed` is `"true"` and the indexer has recorded it; **omitted** (no key) until then | +| creation_timestamp | uint64 | unix seconds; when this bridge was first cached by this endpoint — never changes after that | +| last_updated_timestamp | uint64 | unix seconds; when this item's claim/tracking state was last (re)checked, whether or not anything about it actually changed. Stops advancing once the bridge is claimed with its claim record fetched, since it is never rechecked again from that point on | +| tracking | TrackingData | the bridge tracker's current status for this bridge (see [TrackingData](#trackingdata)); **omitted** (no key) unless the request set `includeTracking=true` and the bridge is still unclaimed | +| errors | map[string]string | message of whatever check failed the last time this item was refreshed, keyed by which check it was — currently only `"claim"`, present only when `claimed` is `"error"`. **Omitted** (no key) while nothing has failed | + +### BridgeResponse + +Exactly as returned by the origin network's own bridge service (`GET /bridge/v1/bridges`); not reinterpreted. + +| field | type | desc | +| ------|------|------| +| block_num | uint64 | block number where the bridge event was recorded | +| block_pos | uint64 | position of the bridge event within the block | +| from_address | Address | address that initiated the transaction on the bridge contract; may be absent | +| tx_hash | Hash | hash of the transaction that included the bridge event | +| global_index | string | global index of the bridge event (mainnet flag + rollup id + deposit count), serialized as a decimal string | +| block_timestamp | uint64 | timestamp of the block containing the bridge event | +| leaf_type | uint8 | 0 -> asset, 1 -> message | +| origin_network | uint32 | network where the bridge transaction originated | +| origin_address | Address | address of the token/sender on the origin network | +| destination_network | uint32 | network the bridge transaction is destined to | +| destination_address | Address | address of the receiver on the destination network | +| amount | string | amount being bridged, as a decimal string | +| metadata | string | optional metadata attached to the bridge event | +| deposit_count | uint32 | deposit index in the origin exit tree | +| bridge_hash | Hash | unique hash identifying the bridge event | +| txn_sender | Address | address that sent the transaction | +| to_address | Address | recipient contract of the transaction (may differ from the bridge contract) | + +### ClaimResponse + +Exactly as returned by the destination network's own bridge service (`GET /bridge/v1/claims`); not reinterpreted. + +| field | type | desc | +| ------|------|------| +| block_num | uint64 | block number where the claim was processed | +| block_timestamp | uint64 | timestamp of the block containing the claim | +| tx_hash | Hash | transaction hash of the claim | +| global_index | string | global index of the claim, as a decimal string | +| origin_address | Address | address initiating the claim on the origin network | +| origin_network | uint32 | origin network id | +| destination_address | Address | address receiving the claim on the destination network | +| destination_network | uint32 | destination network id | +| amount | string | amount claimed, as a decimal string | +| from_address | Address | address the claim originated from | +| mainnet_exit_root | Hash | mainnet exit root associated with the claim | +| rollup_exit_root | Hash | rollup exit root associated with the claim | +| global_exit_root | Hash | global exit root associated with the claim | +| proof_local_exit_root | Proof | local exit root proof; **omitted** (no key) unless the bridge service was asked to include proofs | +| proof_rollup_exit_root | Proof | rollup exit root proof; **omitted** (no key) unless the bridge service was asked to include proofs | +| metadata | string | metadata associated with the claim | +| is_message | bool | `true` for a message claim (leaf type 1), `false` for an asset claim | + +Example (one claimed bridge, one still-pending bridge with `?includeTracking=true`): + +```json +{ + "from_address": "0x1111111111111111111111111111111111111111", + "bridges": [ + { + "bridge": { + "block_num": 1000, + "block_pos": 0, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "global_index": "4294967296", + "block_timestamp": 1700000000, + "leaf_type": 0, + "origin_network": 1, + "origin_address": "0x0000000000000000000000000000000000000020", + "destination_network": 2, + "destination_address": "0x0000000000000000000000000000000000000030", + "amount": "100", + "metadata": "0x", + "deposit_count": 7, + "bridge_hash": "0x0000000000000000000000000000000000000000000000000000000000000abc", + "txn_sender": "0x1111111111111111111111111111111111111111", + "to_address": "0x0000000000000000000000000000000000000030" + }, + "bridge_network_id": 1, + "claimed": "true", + "claim_network_id": 2, + "claim": { + "block_num": 1050, + "block_timestamp": 1700003600, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000002", + "global_index": "4294967296", + "origin_address": "0x0000000000000000000000000000000000000020", + "origin_network": 1, + "destination_address": "0x0000000000000000000000000000000000000030", + "destination_network": 2, + "amount": "100", + "from_address": "0x1111111111111111111111111111111111111111", + "mainnet_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000010", + "rollup_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000011", + "global_exit_root": "0x0000000000000000000000000000000000000000000000000000000000000012", + "metadata": "0x", + "is_message": false + }, + "creation_timestamp": 1700000100, + "last_updated_timestamp": 1700003700 + }, + { + "bridge": { + "block_num": 1200, + "block_pos": 1, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000003", + "global_index": "4294967297", + "block_timestamp": 1700010000, + "leaf_type": 0, + "origin_network": 1, + "origin_address": "0x0000000000000000000000000000000000000020", + "destination_network": 2, + "destination_address": "0x0000000000000000000000000000000000000030", + "amount": "50", + "metadata": "0x", + "deposit_count": 8, + "bridge_hash": "0x0000000000000000000000000000000000000000000000000000000000000def", + "txn_sender": "0x1111111111111111111111111111111111111111", + "to_address": "0x0000000000000000000000000000000000000030" + }, + "bridge_network_id": 1, + "claimed": "false", + "creation_timestamp": 1700010100, + "last_updated_timestamp": 1700010100, + "tracking": { + "tracking_status": "running", + "network_id": 1, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000003", + "bridge_status": null, + "step_index": null, + "all_steps": null, + "error": null + } + } + ] +} +``` + +### Caching and eviction + +- **Opt-in and always-on caching**: the endpoint only exists when configured (see [Behavior](#behavior-1) above); when it does, results for a given `from_address` are cached in memory across calls: a bridge already confirmed claimed, with its claim record already fetched, is never re-verified again. Every other bridge (new, still unclaimed, claimed but not yet indexed, or errored) is rechecked on every call — without re-walking already-scanned pages of the underlying bridge services, since each network's scan stops as soon as it reaches a bridge already in the cache. +- **Idle eviction**: a `from_address` nobody has asked about in `Tracker.ActivityIdleTimeout` (default 30 minutes, same idea as the main endpoint's `IdleTimeout`) is forgotten entirely on the next request for it — everything cached for it (bridges, claim state) is freed, and it starts fresh exactly as if it were being queried for the first time. +- **`includeTracking=true` registers, it does not wait**: unlike the main tracker endpoint, this does not wait for the tracking engine's first resolution attempt — it registers the bridge (if not already registered) and reports whatever `TrackingData` snapshot is available right away, which may still be the bare `"registered"` state. + +## Bridge Address + +GET /tracker/v1/bridge-address + +GET /tracker/v1/bridge-address/{network_id} + +Reports the bridge contract address of one network, or of **every network the tracker currently +knows about** (via the bridge service finder), without needing a fixed config list. With no +`network_id` the body is a [BridgeAddressResponse](#bridgeaddressresponse); with `network_id` the +body is a single [BridgeAddressItem](#bridgeaddressitem). + +Request: + +| param | location | type | mandatory | desc | +| ------|----------|------|-----------|------| +| network_id | path | uint32 | no | network to look up; omit to get every network | + +### Behavior + +- `200 OK` — the body is a [BridgeAddressResponse](#bridgeaddressresponse) (no `network_id`) or a [BridgeAddressItem](#bridgeaddressitem) (`network_id` given). +- `400 Bad Request` — `network_id` is not a uint32: the body is an [ErrorData](#errordata). +- `500 Internal Server Error` — resolving the bridge contract address failed (e.g. the on-chain rollup manager lookup failed): the body is an [ErrorData](#errordata). +- **This endpoint is opt-in**: it only exists if the binary is configured with a bridge address resolver (`Config.BridgeAddressResolver`); otherwise both routes are not registered at all (plain `404`). + +### BridgeAddressResponse + +| field | type | desc | +| ------|------|------| +| bridges | BridgeAddressItem [] | the bridge contract address of every network the tracker currently knows about | + +### BridgeAddressItem + +| field | type | desc | +| ------|------|------| +| network_id | uint32 | the network `bridge_address` belongs to | +| bridge_address | Address | the bridge contract address on `network_id` | + +Example, `GET /bridge-address`: + +```json +{ + "bridges": [ + { "network_id": 0, "bridge_address": "0x1111111111111111111111111111111111111111" }, + { "network_id": 1, "bridge_address": "0x2222222222222222222222222222222222222222" } + ] +} +``` + +Example, `GET /bridge-address/1`: + +```json +{ + "network_id": 1, + "bridge_address": "0x2222222222222222222222222222222222222222" +} +``` + ## WebSocket Endpoint to subscribe to a bridge and receive its status updates as they happen, instead of polling the REST endpoint. diff --git a/proxy/cmd/run.go b/proxy/cmd/run.go index 21d586373..60dde7154 100644 --- a/proxy/cmd/run.go +++ b/proxy/cmd/run.go @@ -166,7 +166,6 @@ func runTracker( // served alongside (see aggkitcommon.CORSConfig.OriginAllowed for why it can't just reuse // the REST CORS headers). trackerCfg.CORS = cfg.REST.CORS - tracker := bridgetracker.New(&trackerCfg) if err := trackerCfg.AgglayerClient.Validate(); err != nil { log.Fatalf("invalid agglayer client config: %v", err) @@ -187,7 +186,24 @@ func runTracker( log.Fatalf("failed to create bridge event source: %v", err) } gerSource := sources.NewGERSource(finder, rpcClients, trackerCfg.L1GlobalExitRootAddress, - trackerCfg.L1BlockFinality, log.WithFields("module", "bridgetracker-gersource")) + trackerCfg.L1BlockFinality, trackerCfg.L2GlobalExitRootAddress, trackerCfg.L2InjectionLookbackBlocks, + log.WithFields("module", "bridgetracker-gersource")) + + // GET /activity/from/{from_address} scans every network the finder knows about (via + // finder.NetworkIDs) for bridges sent by an address, and resolves their claim state through + // the same per-network JSON-RPC clients plus the finder's own BridgeAddress resolution + // (see BridgeServiceFinder.BridgeAddress, distinct from Tracker.BridgeAddrs above) + activitySource := sources.NewActivitySource( + finder, rpcClients, log.WithFields("module", "bridgetracker-activitysource")) + trackerCfg.ActivityScanner = activitySource + trackerCfg.ActivityClaims = activitySource + + // GET /bridge-address[/{network_id}] resolves the bridge contract address of one network, + // or every network the finder currently knows about; finder satisfies + // bridgetracker.BridgeAddressResolver directly (NetworkIDs/BridgeAddress) + trackerCfg.BridgeAddressResolver = finder + + tracker := bridgetracker.New(&trackerCfg) engine, err := bridgetracker.NewEngine( bridgetracker.EngineConfig{ @@ -199,12 +215,14 @@ func runTracker( bridgetracker.EngineSources{ Bridges: bridgeEvents, Certificates: sources.NewCertificateSource( - agglayerClient, finder, log.WithFields("module", "bridgetracker-certificatesource")), + agglayerClient, finder, rpcClients, log.WithFields("module", "bridgetracker-certificatesource")), GERs: gerSource, WaitingGERUpdateSource: gerSource, LERs: sources.NewLERSource(rpcClients), + ClaimChecker: sources.NewClaimChecker(finder, rpcClients), Claims: sources.NewClaimSource(finder), - Settlement: sources.NewSettlementSource(rpcClients, trackerCfg.L1BlockFinality), + Settlement: sources.NewSettlementSource( + rpcClients, trackerCfg.L1BlockFinality, trackerCfg.L1GlobalExitRootAddress), }, ) if err != nil { diff --git a/proxy/config/config_test.go b/proxy/config/config_test.go index 45e4b0de3..5cf7a1be8 100644 --- a/proxy/config/config_test.go +++ b/proxy/config/config_test.go @@ -35,6 +35,7 @@ func TestLoadFilesDefaults(t *testing.T) { require.Equal(t, aggkittypes.LatestBlock, cfg.Tracker.L1BlockFinality) require.Equal(t, aggkittypes.LatestBlock, cfg.Tracker.L2BlockFinality) require.Equal(t, 100000, cfg.Tracker.MaxTrackedBridges) + require.Equal(t, uint64(1000), cfg.Tracker.L2InjectionLookbackBlocks) // URL is left unset by default: it must be supplied per-environment (see proxy/scripts) require.Empty(t, cfg.Tracker.AgglayerClient.GRPC.URL) require.True(t, cfg.Tracker.AgglayerClient.Cached) diff --git a/proxy/config/default.go b/proxy/config/default.go index 1370dc38a..465d1af8b 100644 --- a/proxy/config/default.go +++ b/proxy/config/default.go @@ -27,6 +27,8 @@ IgnoreNetworkIDs = [] [BridgeServiceFinder.RPCURLs] +[BridgeServiceFinder.BridgeAddress] + [REST] Host = "0.0.0.0" Port = 8080 @@ -57,6 +59,11 @@ RetentionPeriod = "10m" # not stay in memory forever. IdleTimeout = "30m" +# ActivityIdleTimeout: how long a from_address's activity cache (GET /activity/from/{address}) +# stays in memory with no request for it, before being forgotten entirely -- same idea as +# IdleTimeout, a separate knob because it governs a different cache. +ActivityIdleTimeout = "30m" + # RegisterResolveTimeout: how long the first request for a freshly registered tx waits for the # engine's immediate resolution attempt before answering, so it has a shot at real progress # instead of the bare "registered" state; a lookup of an already-registered tx never waits. @@ -73,6 +80,11 @@ L2BlockFinality = "LatestBlock" # RetentionPeriod and IdleTimeout are what keep the registry under it during normal operation. MaxTrackedBridges = 100000 +# L2InjectionLookbackBlocks: how many blocks the L2GlobalExitRootAddress fallback scans backwards +# from the destination network's head before giving up, instead of continuing all the way back +# to genesis. +L2InjectionLookbackBlocks = 1000 + [Tracker.AgglayerClient] Cached = true [Tracker.AgglayerClient.ConfigurationCache]