diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index 128be2421..e0a0a414b 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -81,6 +81,7 @@
- [Debug Loki errors](./libs/wasp/how-to/debug_loki_errors.md)
- [Havoc](./libs/havoc.md)
- [K8s Test Runner](k8s-test-runner/k8s-test-runner.md)
+ - [Sentinel](./libs/sentinel.md)
---
diff --git a/book/src/libs/sentinel.md b/book/src/libs/sentinel.md
new file mode 100644
index 000000000..82a8e1a69
--- /dev/null
+++ b/book/src/libs/sentinel.md
@@ -0,0 +1,274 @@
+# Sentinel
+
+**🚧 Beta Notice:** Sentinel is currently in **Beta** mode. The API is **subject to change**, and users are advised to stay updated with the latest releases and documentation.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Key Features](#key-features)
+- [System Architecture](#system-architecture)
+ - [How Components Interact](#how-components-interact)
+ - [Core Components](#core-components)
+- [Usage](#usage)
+ - [Initialize Sentinel](#initialize-sentinel)
+ - [Add a Chain](#add-a-chain)
+ - [Subscribe to Events](#subscribe-to-events)
+ - [Unsubscribe](#unsubscribe)
+ - [Remove a Chain](#remove-a-chain)
+- [API Reference](#api-reference)
+ - [Sentinel](#sentinel)
+- [Testing](#testing)
+ - [Run Tests](#run-tests)
+
+## Overview
+
+Sentinel is a centralized orchestrator that manages multiple blockchain poller services, each responsible for a specific blockchain network (e.g., Ethereum, Optimism, Arbitrum). It provides a unified interface for subscribing to blockchain events, ensuring efficient log polling and event broadcasting to subscribers.
+
+## Key Features
+
+- **Multi-Chain Support**: Manage multiple blockchain networks concurrently.
+- **Event Broadcasting**: Relay blockchain events to subscribers via a thread-safe subscription system.
+- **Flexible Subscriptions**: Dynamically subscribe and unsubscribe to events based on addresses and topics.
+- **Graceful Lifecycle Management**: Start, stop, and clean up resources across services effortlessly.
+- **Comprehensive Testing**: Ensures reliability through extensive unit and integration tests.
+- **Scalable Architecture**: Designed to handle polling multiple chains with multiple users subscribed to multiple events.
+
+## System Architecture
+
+### How Components Interact
+
+```mermaid
+graph TD
+ Sentinel["Sentinel
(Coordinator)"]
+
+ subgraph Ethereum
+ ChainPollerSvc_Ethereum["ChainPollerSvc
(Ethereum)"]
+ ChainPoller_Ethereum["ChainPoller
(Log Fetching)"]
+ SubscriptionManager_Ethereum["Subscription Manager"]
+ ChainPollerSvc_Ethereum --> ChainPoller_Ethereum
+ ChainPollerSvc_Ethereum --> SubscriptionManager_Ethereum
+ ChainPoller_Ethereum --> Blockchain_Ethereum["Blockchain
(Ethereum)"]
+ end
+
+ subgraph Polygon
+ ChainPollerSvc_Polygon["ChainPollerSvc
(Polygon)"]
+ ChainPoller_Polygon["ChainPoller
(Log Fetching)"]
+ SubscriptionManager_Polygon["Subscription Manager"]
+ ChainPollerSvc_Polygon --> ChainPoller_Polygon
+ ChainPollerSvc_Polygon --> SubscriptionManager_Polygon
+ ChainPoller_Polygon --> Blockchain_Polygon["Blockchain
(Polygon)"]
+ end
+
+ subgraph Arbitrum
+ ChainPollerSvc_Arbitrum["ChainPollerSvc
(Arbitrum)"]
+ ChainPoller_Arbitrum["ChainPoller
(Log Fetching)"]
+ SubscriptionManager_Arbitrum["Subscription Manager"]
+ ChainPollerSvc_Arbitrum --> ChainPoller_Arbitrum
+ ChainPollerSvc_Arbitrum --> SubscriptionManager_Arbitrum
+ ChainPoller_Arbitrum --> Blockchain_Arbitrum["Blockchain
(Arbitrum)"]
+ end
+
+ Sentinel --> Ethereum
+ Sentinel --> Polygon
+ Sentinel --> Arbitrum
+```
+
+### Core Components
+
+1. **Sentinel**:
+ - **Role**: Central coordinator managing multiple `ChainPollerService` instances.
+ - **Visibility**: External
+ - **Responsibilities**:
+ - Handles adding and removing blockchain chains.
+ - Manages global subscriptions.
+ - Orchestrates communication between components.
+
+2. **ChainPollerService**:
+ - **Role**: Manages the polling process for a specific blockchain.
+ - **Visibility**: Internal
+ - **Responsibilities**:
+ - Polls blockchain logs based on filter queries.
+ - Integrates internal `ChainPoller` and `SubscriptionManager`.
+ - Broadcasts fetched logs to relevant subscribers.
+
+3. **ChainPoller**:
+ - **Role**: Fetches logs from blockchain networks.
+ - **Visibility**: Internal
+ - **Responsibilities**:
+ - Interacts with the blockchain client to retrieve logs.
+ - Processes filter queries to fetch relevant logs.
+
+4. **SubscriptionManager**:
+ - **Role**: Manages event subscriptions for a specific chain.
+ - **Visibility**: Internal
+ - **Responsibilities**:
+ - Tracks subscriptions to blockchain events.
+ - Ensures thread-safe management of subscribers.
+ - Broadcasts logs to all relevant subscribers.
+
+## Usage
+
+### Initialize Sentinel
+
+Set up a `Sentinel` instance:
+
+```go
+package main
+
+import (
+ "github.com/rs/zerolog"
+ "os"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel"
+)
+
+func main() {
+ // Initialize logger
+ logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
+
+ // Initialize Sentinel
+ sentinelCoordinator := sentinel.NewSentinel(sentinel.SentinelConfig{
+ Logger: &logger,
+ })
+ defer sentinelCoordinator.Close()
+}
+```
+
+### Add a Chain
+
+Add a blockchain to monitor:
+
+```go
+package main
+
+import (
+ "time"
+
+ "github.com/ethereum/go-ethereum/ethclient"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/blockchain_client_wrapper"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/sentinel"
+)
+
+func main() {
+ // Initialize logger and Sentinel as shown above
+
+ // Setup blockchain client (e.g., Geth)
+ client, err := ethclient.Dial("https://mainnet.infura.io/v3/YOUR-PROJECT-ID")
+ if err != nil {
+ panic("Failed to connect to blockchain client: " + err.Error())
+ }
+ wrappedClient := blockchain_client_wrapper.NewGethClientWrapper(client)
+
+ // Add a new chain to Sentinel
+ err = sentinelCoordinator.AddChain(sentinel.AddChainConfig{
+ ChainID: 1, // Ethereum Mainnet
+ PollInterval: 10 * time.Second,
+ BlockchainClient: wrappedClient,
+ })
+ if err != nil {
+ panic("Failed to add chain: " + err.Error())
+ }
+}
+```
+
+### Subscribe to Events
+
+Subscribe to blockchain events:
+
+```go
+package main
+
+import (
+ "fmt"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+)
+
+func main() {
+ // Initialize logger, Sentinel, and add a chain as shown above
+
+ // Define the address and topic to subscribe to
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+
+ // Subscribe to the event
+ logCh, err := sentinelCoordinator.Subscribe(1, address, topic)
+ if err != nil {
+ panic("Failed to subscribe: " + err.Error())
+ }
+ defer sentinelCoordinator.Unsubscribe(1, address, topic, logCh)
+
+ // Listen for logs in a separate goroutine
+ go func() {
+ for log := range logCh {
+ fmt.Printf("Received log: %+v\n", log)
+ }
+ }()
+}
+```
+
+### Unsubscribe
+
+Unsubscribe from events:
+
+```go
+package main
+
+func main() {
+ // Initialize logger, Sentinel, add a chain, and subscribe as shown above
+
+ // Assume logCh is the channel obtained from Subscribe
+ err = sentinelCoordinator.Unsubscribe(1, address, topic, logCh)
+ if err != nil {
+ panic("Failed to unsubscribe: " + err.Error())
+ }
+}
+```
+
+### Remove a Chain
+
+Remove a blockchain from monitoring:
+
+```go
+package main
+
+func main() {
+ // Initialize logger, Sentinel, add a chain, and subscribe as shown above
+
+ // Remove the chain
+ err = sentinelCoordinator.RemoveChain(1)
+ if err != nil {
+ panic("Failed to remove chain: " + err.Error())
+ }
+}
+```
+
+## API Reference
+
+### Sentinel
+
+- **`NewSentinel(config SentinelConfig) *Sentinel`**
+ Initializes a new Sentinel instance.
+
+- **`AddChain(config AddChainConfig) error`**
+ Adds a new blockchain chain to Sentinel.
+
+- **`RemoveChain(chainID int64) error`**
+ Removes an existing chain from Sentinel.
+
+- **`Subscribe(chainID int64, address common.Address, topic common.Hash) (chan api.Log, error)`**
+ Subscribes to a specific event on a given chain.
+
+- **`Unsubscribe(chainID int64, address common.Address, topic common.Hash, ch chan api.Log) error`**
+ Unsubscribes from a specific event.
+
+## Testing
+
+### Run Tests
+
+Run the comprehensive test suite using:
+
+```bash
+go test -race ./... -v
+```
diff --git a/go.md b/go.md
index 9034971b9..eec58ec61 100644
--- a/go.md
+++ b/go.md
@@ -22,6 +22,8 @@ flowchart LR
click chainlink-testing-framework/lib href "https://github.com/smartcontractkit/chainlink-testing-framework"
chainlink-testing-framework/lib/grafana
click chainlink-testing-framework/lib/grafana href "https://github.com/smartcontractkit/chainlink-testing-framework"
+ chainlink-testing-framework/sentinel --> chainlink-testing-framework/lib
+ click chainlink-testing-framework/sentinel href "https://github.com/smartcontractkit/chainlink-testing-framework"
chainlink-testing-framework/seth --> seth
click chainlink-testing-framework/seth href "https://github.com/smartcontractkit/chainlink-testing-framework"
chainlink-testing-framework/tools/citool --> chainlink-testing-framework/lib
@@ -47,6 +49,7 @@ flowchart LR
chainlink-testing-framework/havoc
chainlink-testing-framework/lib
chainlink-testing-framework/lib/grafana
+ chainlink-testing-framework/sentinel
chainlink-testing-framework/seth
chainlink-testing-framework/tools/citool
chainlink-testing-framework/tools/envresolve
diff --git a/sentinel/.changeset/.gitkeep b/sentinel/.changeset/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/sentinel/.changeset/v0.1.0.md b/sentinel/.changeset/v0.1.0.md
new file mode 100644
index 000000000..48086d70e
--- /dev/null
+++ b/sentinel/.changeset/v0.1.0.md
@@ -0,0 +1 @@
+- Sentinel init
\ No newline at end of file
diff --git a/sentinel/README.md b/sentinel/README.md
new file mode 100644
index 000000000..cbd9ed79f
--- /dev/null
+++ b/sentinel/README.md
@@ -0,0 +1,5 @@
+# Sentinel
+
+Sentinel is a centralized orchestrator that manages multiple blockchain poller services, each responsible for a specific blockchain network (e.g., Ethereum, Polygon, Arbitrum). It provides a unified interface for subscribing to blockchain events, ensuring efficient log polling and event broadcasting to subscribers.
+
+[![Documentation](https://img.shields.io/badge/Documentation-MDBook-blue?style=for-the-badge)](https://smartcontractkit.github.io/chainlink-testing-framework/libs/sentinel.html)
\ No newline at end of file
diff --git a/sentinel/api/blockchain_client.go b/sentinel/api/blockchain_client.go
new file mode 100644
index 000000000..dcab561fa
--- /dev/null
+++ b/sentinel/api/blockchain_client.go
@@ -0,0 +1,12 @@
+// File: api/blockchain_client.go
+package api
+
+import (
+ "context"
+)
+
+// BlockchainClient defines the required methods for interacting with a blockchain.
+type BlockchainClient interface {
+ BlockNumber(ctx context.Context) (uint64, error)
+ FilterLogs(ctx context.Context, query FilterQuery) ([]Log, error)
+}
diff --git a/sentinel/api/types.go b/sentinel/api/types.go
new file mode 100644
index 000000000..89a262cf4
--- /dev/null
+++ b/sentinel/api/types.go
@@ -0,0 +1,22 @@
+// File: api/types.go
+package api
+
+import "github.com/ethereum/go-ethereum/common"
+
+// FilterQuery represents the parameters to filter logs/events.
+type FilterQuery struct {
+ FromBlock uint64
+ ToBlock uint64
+ Topics [][]common.Hash
+ Addresses []common.Address
+}
+
+// Log represents a single log event fetched from the blockchain.
+type Log struct {
+ Address common.Address
+ Topics []common.Hash
+ Data []byte
+ BlockNumber uint64
+ TxHash common.Hash
+ Index uint
+}
diff --git a/sentinel/blockchain_client_wrapper/geth_wrapper.go b/sentinel/blockchain_client_wrapper/geth_wrapper.go
new file mode 100644
index 000000000..397056a53
--- /dev/null
+++ b/sentinel/blockchain_client_wrapper/geth_wrapper.go
@@ -0,0 +1,61 @@
+// File: blockchain_client_wrapper/geth_wrapper.go
+package blockchain_client_wrapper
+
+import (
+ "context"
+ "math/big"
+
+ "github.com/ethereum/go-ethereum"
+ "github.com/ethereum/go-ethereum/ethclient"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+)
+
+// GethWrapper wraps a Geth client to implement the BlockchainClient interface.
+type GethWrapper struct {
+ client *ethclient.Client
+}
+
+// NewGethClientWrapper wraps an existing Geth client.
+func NewGethClientWrapper(client *ethclient.Client) api.BlockchainClient {
+ return &GethWrapper{client: client}
+}
+
+// BlockNumber retrieves the latest block number.
+func (g *GethWrapper) BlockNumber(ctx context.Context) (uint64, error) {
+ return g.client.BlockNumber(ctx)
+}
+
+// BlockByNumber retrieves a block by number.
+func (g *GethWrapper) FilterLogs(ctx context.Context, query api.FilterQuery) ([]api.Log, error) {
+ fromBlock := new(big.Int).SetUint64(query.FromBlock)
+ toBlock := new(big.Int).SetUint64(query.ToBlock)
+
+ // Convert FilterQuery to ethereum.FilterQuery
+ ethQuery := ethereum.FilterQuery{
+ FromBlock: fromBlock,
+ ToBlock: toBlock,
+ Addresses: query.Addresses,
+ Topics: query.Topics,
+ }
+
+ // Fetch logs using geth client
+ ethLogs, err := g.client.FilterLogs(ctx, ethQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert []types.Log to []Log
+ internalLogs := make([]api.Log, len(ethLogs))
+ for i, ethLog := range ethLogs {
+ internalLogs[i] = api.Log{
+ Address: ethLog.Address,
+ Topics: ethLog.Topics,
+ Data: ethLog.Data,
+ BlockNumber: ethLog.BlockNumber,
+ TxHash: ethLog.TxHash,
+ Index: ethLog.Index,
+ }
+ }
+
+ return internalLogs, nil
+}
diff --git a/sentinel/go.mod b/sentinel/go.mod
new file mode 100644
index 000000000..fccb4f1f8
--- /dev/null
+++ b/sentinel/go.mod
@@ -0,0 +1,88 @@
+module github.com/smartcontractkit/chainlink-testing-framework/sentinel
+
+go 1.23
+
+toolchain go1.23.3
+
+require (
+ github.com/ethereum/go-ethereum v1.14.11
+ github.com/rs/zerolog v1.33.0
+ github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.18
+ github.com/stretchr/testify v1.9.0
+)
+
+require (
+ dario.cat/mergo v1.0.1 // indirect
+ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/bits-and-blooms/bitset v1.13.0 // indirect
+ github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/consensys/bavard v0.1.13 // indirect
+ github.com/consensys/gnark-crypto v0.12.1 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/containerd/platforms v0.2.1 // indirect
+ github.com/cpuguy83/dockercfg v0.3.2 // indirect
+ github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c // indirect
+ github.com/crate-crypto/go-kzg-4844 v1.0.0 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/deckarep/golang-set/v2 v2.6.0 // indirect
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/docker v27.3.1+incompatible // indirect
+ github.com/docker/go-connections v0.5.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
+ github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/holiman/uint256 v1.3.1 // indirect
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mmcloughlin/addchain v0.4.0 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/patternmatcher v0.6.0 // indirect
+ github.com/moby/sys/sequential v0.5.0 // indirect
+ github.com/moby/sys/user v0.1.0 // indirect
+ github.com/moby/sys/userns v0.1.0 // indirect
+ github.com/moby/term v0.5.0 // indirect
+ github.com/morikuni/aec v1.0.0 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/shirou/gopsutil v3.21.11+incompatible // indirect
+ github.com/shirou/gopsutil/v3 v3.23.12 // indirect
+ github.com/shoenig/go-m1cpu v0.1.6 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ github.com/supranational/blst v0.3.13 // indirect
+ github.com/testcontainers/testcontainers-go v0.34.0 // indirect
+ github.com/tklauser/go-sysconf v0.3.12 // indirect
+ github.com/tklauser/numcpus v0.6.1 // indirect
+ github.com/urfave/cli/v2 v2.27.5 // indirect
+ github.com/yusufpapurcu/wmi v1.2.3 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
+ go.opentelemetry.io/otel v1.28.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
+ go.opentelemetry.io/otel/metric v1.28.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.19.0 // indirect
+ go.opentelemetry.io/otel/trace v1.28.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.0.0 // indirect
+ golang.org/x/crypto v0.28.0 // indirect
+ golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
+ golang.org/x/sync v0.8.0 // indirect
+ golang.org/x/sys v0.26.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ rsc.io/tmplfunc v0.0.3 // indirect
+)
diff --git a/sentinel/go.sum b/sentinel/go.sum
new file mode 100644
index 000000000..412b784d3
--- /dev/null
+++ b/sentinel/go.sum
@@ -0,0 +1,343 @@
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8=
+github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=
+github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
+github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
+github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
+github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
+github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
+github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
+github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
+github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
+github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
+github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
+github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA=
+github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU=
+github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
+github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
+github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
+github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
+github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ=
+github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI=
+github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M=
+github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
+github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c h1:uQYC5Z1mdLRPrZhHjHxufI8+2UG/i25QG92j0Er9p6I=
+github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJZH3SzKCqnz5VT0q/DyIG/tvu/dZk+VIfXicupJs=
+github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI=
+github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
+github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
+github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
+github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
+github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA=
+github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0=
+github.com/ethereum/go-ethereum v1.14.11 h1:8nFDCUUE67rPc6AKxFj7JKaOa2W/W1Rse3oS6LvvxEY=
+github.com/ethereum/go-ethereum v1.14.11/go.mod h1:+l/fr42Mma+xBnhefL/+z11/hcmJ2egl+ScIVPjhc7E=
+github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ60YRB8ChToFTUzl8awsc3cJ8CbLjGIl/A=
+github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI=
+github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww=
+github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
+github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
+github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk=
+github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
+github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
+github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
+github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4=
+github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc=
+github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
+github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
+github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs=
+github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
+github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
+github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
+github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
+github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c=
+github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
+github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
+github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY=
+github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU=
+github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
+github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
+github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
+github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
+github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
+github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
+github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
+github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
+github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
+github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
+github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
+github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
+github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
+github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
+github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.18 h1:a3xetGZh2nFO1iX5xd9OuqiCkgbWLvW6fTN6fgVubPo=
+github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.18/go.mod h1:NwmlNKqrb02v4Sci4b5KW644nfH2BW+FrKbWwTN5r6M=
+github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA=
+github.com/status-im/keycard-go v0.2.0/go.mod h1:wlp8ZLbsmrF6g6WjugPAx+IzoLrkdf9+mHxBEeo3Hbg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKtmTjk=
+github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
+github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs=
+github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
+github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo=
+github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
+github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
+github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
+github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
+github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
+github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
+github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
+go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
+go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
+go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
+go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
+go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
+go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
+go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
+go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
+go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
+go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
+golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
+golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
+golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
+golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
+google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
+google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU=
+rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA=
diff --git a/sentinel/helper.go b/sentinel/helper.go
new file mode 100644
index 000000000..9ea4fef6a
--- /dev/null
+++ b/sentinel/helper.go
@@ -0,0 +1,37 @@
+// File: helper.go
+package sentinel
+
+import (
+ "fmt"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+)
+
+// ConvertAPILogToTypesLog maps an api.Log to a types.Log.
+// Returns a pointer to types.Log and an error if mapping fails.
+func ConvertAPILogToTypesLog(log api.Log) (*types.Log, error) {
+ // Validate required fields
+ if log.Address == (common.Address{}) {
+ return nil, fmt.Errorf("api.Log Address is empty")
+ }
+ if len(log.Topics) == 0 {
+ return nil, fmt.Errorf("api.Log Topics are empty")
+ }
+ if log.BlockNumber == 0 {
+ return nil, fmt.Errorf("api.Log BlockNumber is zero")
+ }
+
+ // Map fields
+ mappedLog := &types.Log{
+ Address: log.Address,
+ Topics: log.Topics,
+ Data: log.Data,
+ BlockNumber: log.BlockNumber,
+ TxHash: log.TxHash,
+ Index: log.Index,
+ }
+
+ return mappedLog, nil
+}
diff --git a/sentinel/internal/chain_poller/chain_poller.go b/sentinel/internal/chain_poller/chain_poller.go
new file mode 100644
index 000000000..e9e23f091
--- /dev/null
+++ b/sentinel/internal/chain_poller/chain_poller.go
@@ -0,0 +1,67 @@
+// File: internal/chain_poller/chain_poller.go
+package chain_poller
+
+import (
+ "context"
+ "errors"
+
+ "github.com/rs/zerolog"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+)
+
+// ChainPollerConfig holds the configuration for the ChainPoller.
+type ChainPollerConfig struct {
+ BlockchainClient api.BlockchainClient
+ Logger *zerolog.Logger
+ ChainID int64
+}
+
+// ChainPoller is responsible for polling logs from the blockchain.
+type ChainPoller struct {
+ blockchainClient api.BlockchainClient
+ logger zerolog.Logger
+ chainID int64
+}
+
+// NewChainPoller initializes a new ChainPoller.
+func NewChainPoller(cfg ChainPollerConfig) (*ChainPoller, error) {
+ if cfg.BlockchainClient == nil {
+ return nil, errors.New("blockchain client cannot be nil")
+ }
+ if cfg.Logger == nil {
+ return nil, errors.New("no logger passed")
+ }
+ if cfg.ChainID < 1 {
+ return nil, errors.New("chain ID not set")
+ }
+
+ logger := cfg.Logger.With().Str("Component", "ChainPoller").Logger().With().Int64("ChainID", cfg.ChainID).Logger()
+
+ return &ChainPoller{
+ blockchainClient: cfg.BlockchainClient,
+ logger: logger,
+ chainID: cfg.ChainID,
+ }, nil
+}
+
+// Poll fetches logs from the blockchain based on the provided filter queries.
+func (cp *ChainPoller) FilterLogs(ctx context.Context, filterQueries []api.FilterQuery) ([]api.Log, error) {
+ var allLogs []api.Log
+
+ for _, query := range filterQueries {
+ logs, err := cp.blockchainClient.FilterLogs(ctx, query)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ cp.logger.Debug().
+ Int64("ChainID", cp.chainID).
+ Msg("Log filtering canceled due to shutdown")
+ return allLogs, nil
+ }
+ cp.logger.Error().Err(err).Interface("query", query).Msg("Failed to filter logs")
+ continue
+ }
+ allLogs = append(allLogs, logs...)
+ }
+
+ return allLogs, nil
+}
diff --git a/sentinel/internal/chain_poller/chain_poller_interface.go b/sentinel/internal/chain_poller/chain_poller_interface.go
new file mode 100644
index 000000000..2989abade
--- /dev/null
+++ b/sentinel/internal/chain_poller/chain_poller_interface.go
@@ -0,0 +1,12 @@
+// File: internal/chain_poller/chain_poller_interface.go
+package chain_poller
+
+import (
+ "context"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+)
+
+type ChainPollerInterface interface {
+ FilterLogs(ctx context.Context, filterQueries []api.FilterQuery) ([]api.Log, error)
+}
diff --git a/sentinel/internal/chain_poller/chain_poller_test.go b/sentinel/internal/chain_poller/chain_poller_test.go
new file mode 100644
index 000000000..fe592af11
--- /dev/null
+++ b/sentinel/internal/chain_poller/chain_poller_test.go
@@ -0,0 +1,349 @@
+// File: internal/chain_poller/chain_poller_test.go
+package chain_poller
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/rs/zerolog"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+)
+
+// Helper function to initialize a new ChainPoller for testing
+func setupChainPoller(t *testing.T, blockchainClient *internal.MockBlockchainClient, logger *zerolog.Logger, chainID int64) *ChainPoller {
+ t.Helper()
+ config := ChainPollerConfig{
+ BlockchainClient: blockchainClient,
+ Logger: logger,
+ ChainID: chainID,
+ }
+
+ chainPoller, err := NewChainPoller(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPoller)
+ return chainPoller
+}
+
+// Helper function to create a FilterQuery
+func createFilterQuery(fromBlock, toBlock uint64, addresses []common.Address, topics [][]common.Hash) api.FilterQuery {
+ return api.FilterQuery{
+ FromBlock: fromBlock,
+ ToBlock: toBlock,
+ Addresses: addresses,
+ Topics: topics,
+ }
+}
+
+// Helper function to create mock logs
+func createMockLogs(blockNumbers []uint64, txHashes []common.Hash, addresses []common.Address, topics [][]common.Hash, data [][]byte, indexes []uint) []api.Log {
+ logs := make([]api.Log, len(blockNumbers))
+ for i := range logs {
+ logs[i] = api.Log{
+ BlockNumber: blockNumbers[i],
+ TxHash: txHashes[i],
+ Address: addresses[i],
+ Topics: topics[i],
+ Data: data[i],
+ Index: indexes[i],
+ }
+ }
+ return logs
+}
+
+func TestNewChainPoller(t *testing.T) {
+ testLogger := logging.GetTestLogger(t)
+
+ tests := []struct {
+ name string
+ config ChainPollerConfig
+ expectedError string
+ expectChainPoller bool
+ }{
+ {
+ name: "Success",
+ config: ChainPollerConfig{
+ BlockchainClient: new(internal.MockBlockchainClient),
+ Logger: &testLogger,
+ ChainID: 1,
+ },
+ expectedError: "",
+ expectChainPoller: true,
+ },
+ {
+ name: "NilBlockchainClient",
+ config: ChainPollerConfig{
+ BlockchainClient: nil,
+ Logger: &testLogger,
+ ChainID: 1,
+ },
+ expectedError: "blockchain client cannot be nil",
+ expectChainPoller: false,
+ },
+ {
+ name: "NoLoggerPassed",
+ config: ChainPollerConfig{
+ BlockchainClient: new(internal.MockBlockchainClient),
+ Logger: nil,
+ ChainID: 1,
+ },
+ expectedError: "no logger passed",
+ expectChainPoller: false,
+ },
+ {
+ name: "ChainIDNotSet",
+ config: ChainPollerConfig{
+ BlockchainClient: new(internal.MockBlockchainClient),
+ Logger: &testLogger,
+ ChainID: 0, // Assuming 0 is invalid
+ },
+ expectedError: "chain ID not set",
+ expectChainPoller: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ chainPoller, err := NewChainPoller(tt.config)
+ if tt.expectedError != "" {
+ require.Error(t, err)
+ assert.Nil(t, chainPoller)
+ assert.Equal(t, tt.expectedError, err.Error())
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, chainPoller)
+ }
+ })
+ }
+}
+
+func TestChainPoller_FilterLogs(t *testing.T) {
+ mockBlockchainClient := new(internal.MockBlockchainClient)
+ testLogger := logging.GetTestLogger(t)
+ chainID := int64(1)
+
+ chainPoller := setupChainPoller(t, mockBlockchainClient, &testLogger, chainID)
+
+ tests := []struct {
+ name string
+ filterQueries []api.FilterQuery
+ mockReturnLogs [][]api.Log
+ mockReturnError []error
+ expectedLogs []api.Log
+ expectedError bool
+ }{
+ {
+ name: "SingleFilterQueryWithLogs",
+ filterQueries: []api.FilterQuery{
+ createFilterQuery(
+ 101,
+ 110,
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {
+ common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"),
+ },
+ },
+ ),
+ },
+ mockReturnLogs: [][]api.Log{
+ createMockLogs(
+ []uint64{105, 107},
+ []common.Hash{common.HexToHash("0x1234"), common.HexToHash("0x5678")},
+ []common.Address{
+ common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ },
+ [][]common.Hash{
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ },
+ [][]byte{
+ []byte("test data 1"),
+ []byte("test data 2"),
+ },
+ []uint{0, 1},
+ ),
+ },
+ mockReturnError: []error{nil},
+ expectedLogs: createMockLogs(
+ []uint64{105, 107},
+ []common.Hash{common.HexToHash("0x1234"), common.HexToHash("0x5678")},
+ []common.Address{
+ common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ },
+ [][]common.Hash{
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ },
+ [][]byte{
+ []byte("test data 1"),
+ []byte("test data 2"),
+ },
+ []uint{0, 1},
+ ),
+ expectedError: false,
+ },
+ {
+ name: "MultipleFilterQueries",
+ filterQueries: []api.FilterQuery{
+ createFilterQuery(
+ 101,
+ 110,
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {
+ common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"),
+ },
+ },
+ ),
+ createFilterQuery(
+ 101,
+ 110,
+ []common.Address{common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")},
+ [][]common.Hash{
+ {
+ common.HexToHash("0xfeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface"),
+ },
+ },
+ ),
+ },
+ mockReturnLogs: [][]api.Log{
+ createMockLogs(
+ []uint64{103},
+ []common.Hash{common.HexToHash("0x1111")},
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ },
+ [][]byte{[]byte("test data 1")},
+ []uint{0},
+ ),
+ createMockLogs(
+ []uint64{104},
+ []common.Hash{common.HexToHash("0x2222")},
+ []common.Address{common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")},
+ [][]common.Hash{
+ {common.HexToHash("0xfeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface")},
+ },
+ [][]byte{[]byte("test data 2")},
+ []uint{1},
+ ),
+ },
+ mockReturnError: []error{nil, nil},
+ expectedLogs: append(
+ createMockLogs(
+ []uint64{103},
+ []common.Hash{common.HexToHash("0x1111")},
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")},
+ },
+ [][]byte{[]byte("test data 1")},
+ []uint{0},
+ ),
+ createMockLogs(
+ []uint64{104},
+ []common.Hash{common.HexToHash("0x2222")},
+ []common.Address{common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")},
+ [][]common.Hash{
+ {common.HexToHash("0xfeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface")},
+ },
+ [][]byte{[]byte("test data 2")},
+ []uint{1},
+ )...,
+ ),
+ expectedError: false,
+ },
+ {
+ name: "NoLogs",
+ filterQueries: []api.FilterQuery{
+ createFilterQuery(
+ 101,
+ 110,
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {
+ common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"),
+ },
+ },
+ ),
+ },
+ mockReturnLogs: [][]api.Log{
+ {}, // No logs returned
+ },
+ mockReturnError: []error{nil},
+ expectedLogs: []api.Log{}, // Expecting an empty slice
+ expectedError: false,
+ },
+ {
+ name: "FilterLogsError",
+ filterQueries: []api.FilterQuery{
+ createFilterQuery(
+ 101,
+ 110,
+ []common.Address{common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")},
+ [][]common.Hash{
+ {
+ common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"),
+ },
+ },
+ ),
+ },
+ mockReturnLogs: [][]api.Log{
+ {}, // No logs returned due to error
+ },
+ mockReturnError: []error{errors.New("FilterLogs error")},
+ expectedLogs: []api.Log{}, // Expecting an empty slice
+ expectedError: false, // According to original test, no error is propagated
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Setup mock expectations
+ for i, fq := range tt.filterQueries {
+ var logs []api.Log
+ if i < len(tt.mockReturnLogs) {
+ logs = tt.mockReturnLogs[i]
+ } else {
+ logs = []api.Log{}
+ }
+ var err error
+ if i < len(tt.mockReturnError) {
+ err = tt.mockReturnError[i]
+ } else {
+ err = nil
+ }
+ mockBlockchainClient.On("FilterLogs", mock.Anything, fq).Return(logs, err).Once()
+ }
+
+ // Execute the method under test
+ logs, err := chainPoller.FilterLogs(context.Background(), tt.filterQueries)
+
+ // Assertions
+ if tt.expectedError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ if len(tt.expectedLogs) == 0 {
+ assert.Empty(t, logs, "Expected logs to be empty")
+ } else {
+ assert.Equal(t, tt.expectedLogs, logs, "Logs should match expected logs")
+ }
+
+ // Assert that all expectations were met
+ mockBlockchainClient.AssertExpectations(t)
+ })
+ }
+}
diff --git a/sentinel/internal/chain_poller_service/chain_poller_service.go b/sentinel/internal/chain_poller_service/chain_poller_service.go
new file mode 100644
index 000000000..d4e86acc1
--- /dev/null
+++ b/sentinel/internal/chain_poller_service/chain_poller_service.go
@@ -0,0 +1,231 @@
+// File: internal/chain_poller_service/chain_poller_service.go
+package chain_poller_service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math/big"
+ "sync"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/rs/zerolog"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal/chain_poller"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal/subscription_manager"
+)
+
+// ChainPollerServiceConfig holds the configuration for the ChainPollerService.
+type ChainPollerServiceConfig struct {
+ PollInterval time.Duration
+ Logger *zerolog.Logger
+ BlockchainClient api.BlockchainClient
+ ChainID int64
+}
+
+// ChainPollerService orchestrates the polling process and log broadcasting.
+type ChainPollerService struct {
+ config ChainPollerServiceConfig
+ SubscriptionMgr *subscription_manager.SubscriptionManager
+ ChainPoller chain_poller.ChainPollerInterface
+ ChainID int64
+ LastBlock *big.Int
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+ started bool
+ mu sync.Mutex
+}
+
+func (eps *ChainPollerService) SubscriptionManager() *subscription_manager.SubscriptionManager {
+ return eps.SubscriptionMgr
+}
+
+// NewChainPollerService initializes a new ChainPollerService.
+func NewChainPollerService(cfg ChainPollerServiceConfig) (*ChainPollerService, error) {
+ if cfg.PollInterval <= 0 {
+ return nil, fmt.Errorf("poll interval must be positive")
+ }
+ if cfg.BlockchainClient == nil {
+ return nil, fmt.Errorf("blockchain client cannot be nil")
+ }
+ if cfg.ChainID < 1 {
+ return nil, fmt.Errorf("chainid missing")
+ }
+ if cfg.Logger == nil {
+ return nil, fmt.Errorf("logger cannot be nil")
+ }
+
+ // Create a subscrition manager
+ subscription_manager := subscription_manager.NewSubscriptionManager(subscription_manager.SubscriptionManagerConfig{Logger: cfg.Logger, ChainID: cfg.ChainID})
+ chain_poller, err := chain_poller.NewChainPoller(chain_poller.ChainPollerConfig{
+ BlockchainClient: cfg.BlockchainClient,
+ Logger: cfg.Logger,
+ ChainID: cfg.ChainID})
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to initialize ChainPoller: %w", err)
+ }
+
+ l := cfg.Logger.With().Str("Component", "ChainPollerService").Logger().With().Int64("ChainID", cfg.ChainID).Logger()
+
+ cfg.Logger = &l
+
+ // Initialize lastBlock as the latest block at startup
+ latestBlock, err := cfg.BlockchainClient.BlockNumber(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get latest block: %w", err)
+ }
+
+ if latestBlock == 0 {
+ return nil, errors.New("blockchain has no blocks")
+ }
+ lastBlock := new(big.Int).Sub(new(big.Int).SetUint64(latestBlock), big.NewInt(1))
+
+ return &ChainPollerService{
+ config: cfg,
+ SubscriptionMgr: subscription_manager,
+ ChainPoller: chain_poller,
+ ChainID: cfg.ChainID,
+ LastBlock: lastBlock,
+ }, nil
+}
+
+// Start begins the polling loop.
+func (eps *ChainPollerService) Start() {
+ eps.mu.Lock()
+ defer eps.mu.Unlock()
+
+ if eps.started {
+ eps.config.Logger.Warn().Msg("ChainPollerService already started")
+ return
+ }
+
+ eps.ctx, eps.cancel = context.WithCancel(context.Background())
+ eps.started = true
+ eps.wg.Add(1)
+ go eps.pollingLoop()
+ eps.config.Logger.Info().Dur("Poll interval", eps.config.PollInterval).Msg("ChainPollerService started")
+}
+
+// Stop gracefully stops the polling loop.
+func (eps *ChainPollerService) Stop() {
+ eps.mu.Lock()
+ defer eps.mu.Unlock()
+
+ if !eps.started {
+ return
+ }
+ eps.SubscriptionMgr.Close()
+ eps.cancel()
+ eps.wg.Wait()
+ eps.started = false
+
+ eps.config.Logger.Info().Msg("ChainPollerService stopped")
+}
+
+// pollingLoop runs the periodic polling process.
+func (eps *ChainPollerService) pollingLoop() {
+ defer eps.wg.Done()
+
+ ticker := time.NewTicker(eps.config.PollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-eps.ctx.Done():
+ eps.config.Logger.Info().Msg("Polling loop terminating")
+ return
+ case <-ticker.C:
+ eps.pollCycle()
+ }
+ }
+}
+
+// pollCycle performs a single polling cycle: fetching logs and broadcasting them.
+func (eps *ChainPollerService) pollCycle() {
+ startTime := time.Now()
+ eps.config.Logger.Debug().Msg("Starting polling cycle")
+
+ // Fetch the latest block number
+ latestBlock, err := eps.config.BlockchainClient.BlockNumber(eps.ctx)
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ eps.config.Logger.Debug().Msg("Fetching latest block number canceled due to shutdown")
+ } else {
+ eps.config.Logger.Error().Err(err).Msg("Failed to get latest block")
+ }
+
+ return
+ }
+
+ toBlock := latestBlock
+ fromBlock := new(big.Int).Add(eps.LastBlock, big.NewInt(1))
+
+ // Ensure fromBlock is not greater than toBlock
+ if fromBlock.Cmp(new(big.Int).SetUint64(toBlock)) > 0 {
+ eps.config.Logger.Warn().Msg(fmt.Sprintf("fromBlock (%s) is greater than toBlock (%s), skipping poll", fromBlock.String(), new(big.Int).SetUint64(toBlock).String()))
+ return
+ }
+
+ // Get current subscriptions
+ subscriptions := eps.SubscriptionMgr.GetAddressesAndTopics()
+
+ if len(subscriptions) == 0 {
+ // Update the last processed block to toBlock
+ eps.LastBlock = new(big.Int).SetUint64(toBlock)
+ eps.config.Logger.Debug().Msg("No active subscriptions, skipping polling cycle")
+ return
+ }
+
+ // Construct filter queries with the same fromBlock and toBlock
+ var filterQueries []api.FilterQuery
+ for _, eventKey := range subscriptions {
+ filterQueries = append(filterQueries, api.FilterQuery{
+ FromBlock: fromBlock.Uint64(),
+ ToBlock: toBlock,
+ Addresses: []common.Address{eventKey.Address},
+ Topics: [][]common.Hash{{eventKey.Topic}},
+ })
+ }
+
+ // Fetch logs using the Chain Poller
+ ctx, cancel := context.WithTimeout(eps.ctx, 10*time.Second)
+ defer cancel()
+
+ logs, err := eps.ChainPoller.FilterLogs(ctx, filterQueries)
+ if err != nil {
+ eps.config.Logger.Error().Err(err).Msg("Error during polling")
+ return
+ }
+
+ eps.config.Logger.Debug().
+ Int("Number of fetched logs", len(logs)).
+ Uint64("FromBlock", fromBlock.Uint64()).
+ Uint64("ToBlock", toBlock).
+ Uint64("Number of blocks", toBlock-fromBlock.Uint64()).
+ Msg(("Fetched logs from blockchain"))
+
+ // Broadcast each log to subscribers
+ for _, log := range logs {
+ if len(log.Topics) == 0 {
+ continue // Skip logs without topics
+ }
+
+ for _, topic := range log.Topics {
+ eventKey := internal.EventKey{
+ Address: log.Address,
+ Topic: topic,
+ }
+ eps.SubscriptionMgr.BroadcastLog(eventKey, log)
+ }
+ }
+
+ // Update the last processed block to toBlock
+ eps.LastBlock = new(big.Int).SetUint64(toBlock)
+
+ duration := time.Since(startTime)
+ eps.config.Logger.Debug().Dur("Duration", duration).Msg("Completed polling cycle")
+}
diff --git a/sentinel/internal/chain_poller_service/chain_poller_service_test.go b/sentinel/internal/chain_poller_service/chain_poller_service_test.go
new file mode 100644
index 000000000..9e66e9f94
--- /dev/null
+++ b/sentinel/internal/chain_poller_service/chain_poller_service_test.go
@@ -0,0 +1,206 @@
+// File: internal/chain_poller_service/chain_poller_service_test.go
+package chain_poller_service_test
+
+import (
+ "context"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal/chain_poller_service"
+)
+
+// MockChainPoller implements the ChainPollerInterface for testing.
+type MockChainPoller struct {
+ mock.Mock
+}
+
+func (m *MockChainPoller) FilterLogs(ctx context.Context, filterQueries []api.FilterQuery) ([]api.Log, error) {
+ args := m.Called(ctx, filterQueries)
+ if logs, ok := args.Get(0).([]api.Log); ok {
+ return logs, args.Error(1)
+ }
+ return nil, args.Error(1)
+}
+
+func setup(t *testing.T) (chain_poller_service.ChainPollerServiceConfig, *internal.MockBlockchainClient) {
+ mockBlockchainClient := new(internal.MockBlockchainClient)
+ testLogger := logging.GetTestLogger(t)
+
+ // Mock BlockchainClient.BlockNumber during initialization
+ initialLastBlockNum := uint64(100)
+ mockBlockchainClient.On("BlockNumber", mock.Anything).Return(initialLastBlockNum, nil).Once()
+
+ config := chain_poller_service.ChainPollerServiceConfig{
+ PollInterval: 100 * time.Millisecond,
+ Logger: &testLogger,
+ ChainID: 1,
+ BlockchainClient: mockBlockchainClient,
+ }
+
+ return config, mockBlockchainClient
+}
+
+func TestChainPollerService_Initialization(t *testing.T) {
+ config, mockBlockchainClient := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Verify initial LastBlock is set correctly
+ assert.Equal(t, big.NewInt(99), chainPollerService.LastBlock)
+
+ // Assert that BlockNumber was called once
+ mockBlockchainClient.AssertCalled(t, "BlockNumber", mock.Anything)
+}
+
+func TestChainPollerService_Initialization_InvalidBlockchainClient(t *testing.T) {
+ testLogger := logging.GetTestLogger(t)
+
+ config := chain_poller_service.ChainPollerServiceConfig{
+ PollInterval: 100 * time.Millisecond,
+ Logger: &testLogger,
+ ChainID: 1,
+ BlockchainClient: nil,
+ }
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.Error(t, err)
+ assert.Nil(t, chainPollerService)
+ assert.Equal(t, "blockchain client cannot be nil", err.Error())
+}
+
+func TestChainPollerService_StartAndStop(t *testing.T) {
+ config, _ := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Start the service
+ chainPollerService.Start()
+
+ // Allow some time for polling loop to start
+ time.Sleep(10 * time.Millisecond)
+
+ // Stop the service
+ chainPollerService.Stop()
+}
+
+func TestChainPollerService_PollCycle_FetchAndBroadcast(t *testing.T) {
+ config, mockBlockchainClient := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Setup a subscriber
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ logCh, err := chainPollerService.SubscriptionMgr.Subscribe(address, topic)
+ require.NoError(t, err)
+ defer chainPollerService.SubscriptionMgr.Unsubscribe(address, topic, logCh)
+
+ // Define the expected toBlock
+ toBlock := uint64(110)
+
+ // Define the expected filter query
+ filterQuery := api.FilterQuery{
+ FromBlock: chainPollerService.LastBlock.Uint64() + 1,
+ ToBlock: toBlock,
+ Addresses: []common.Address{address},
+ Topics: [][]common.Hash{{topic}},
+ }
+
+ // Define fetched logs
+ fetchedLogs := []api.Log{
+ {
+ BlockNumber: 105,
+ TxHash: common.HexToHash("0xdeadbeef"),
+ Address: address,
+ Topics: []common.Hash{topic},
+ Data: []byte("test log data"),
+ Index: 0,
+ },
+ }
+
+ mockBlockchainClient.On("FilterLogs", mock.Anything, filterQuery).Return(fetchedLogs, nil).Once()
+
+ // Mock BlockchainClient.BlockNumber for the next poll
+ mockBlockchainClient.On("BlockNumber", mock.Anything).Return(toBlock, nil).Once()
+
+ // Start the polling service
+ chainPollerService.Start()
+
+ // Allow some time for polling cycle to execute
+ time.Sleep(150 * time.Millisecond)
+
+ // Stop the polling service
+ chainPollerService.Stop()
+
+ //Assert that the fetched log was broadcasted to the subscriber
+ select {
+ case receivedLog := <-logCh:
+ assert.Equal(t, fetchedLogs[0], receivedLog, "Received log should match the fetched log")
+ default:
+ t.Fatal("Did not receive the expected log")
+ }
+}
+
+func TestChainPollerService_PollCycle_NoSubscriptions(t *testing.T) {
+ config, mockBlockchainClient := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Mock BlockchainClient.BlockNumber for the next poll
+ toBlock := uint64(110)
+ mockBlockchainClient.On("BlockNumber", mock.Anything).Return(toBlock, nil).Once()
+
+ // Start the polling service
+ chainPollerService.Start()
+
+ // Allow some time for polling cycle to execute
+ time.Sleep(150 * time.Millisecond)
+
+ // Stop the polling service
+ chainPollerService.Stop()
+}
+
+func TestChainPollerService_StopWithoutStart(t *testing.T) {
+ config, _ := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Attempt to stop without starting
+ chainPollerService.Stop()
+}
+
+func TestChainPollerService_MultipleStartCalls(t *testing.T) {
+ config, _ := setup(t)
+
+ chainPollerService, err := chain_poller_service.NewChainPollerService(config)
+ require.NoError(t, err)
+ require.NotNil(t, chainPollerService)
+
+ // Start the service first time
+ chainPollerService.Start()
+
+ // Start the service second time
+ chainPollerService.Start()
+
+ // Stop the service
+ chainPollerService.Stop()
+}
diff --git a/sentinel/internal/mock_blockchain_client.go b/sentinel/internal/mock_blockchain_client.go
new file mode 100644
index 000000000..a6913f2db
--- /dev/null
+++ b/sentinel/internal/mock_blockchain_client.go
@@ -0,0 +1,24 @@
+// File: internal/mock_blockchain_client.go
+package internal
+
+import (
+ "context"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/stretchr/testify/mock"
+)
+
+// MockBlockchainClient implements the internal.BlockchainClient interface for testing.
+type MockBlockchainClient struct {
+ mock.Mock
+}
+
+func (m *MockBlockchainClient) BlockNumber(ctx context.Context) (uint64, error) {
+ args := m.Called(ctx)
+ return args.Get(0).(uint64), args.Error(1)
+}
+
+func (m *MockBlockchainClient) FilterLogs(ctx context.Context, query api.FilterQuery) ([]api.Log, error) {
+ args := m.Called(ctx, query)
+ return args.Get(0).([]api.Log), args.Error(1)
+}
diff --git a/sentinel/internal/mock_chain_poller.go b/sentinel/internal/mock_chain_poller.go
new file mode 100644
index 000000000..8b88ab868
--- /dev/null
+++ b/sentinel/internal/mock_chain_poller.go
@@ -0,0 +1,22 @@
+// File: internal/mock_chain_poller.go
+package internal
+
+import (
+ "context"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/stretchr/testify/mock"
+)
+
+// MockChainPoller implements the ChainPollerInterface for testing.
+type MockChainPoller struct {
+ mock.Mock
+}
+
+func (m *MockChainPoller) FilterLogs(ctx context.Context, filterQueries []api.FilterQuery) ([]api.Log, error) {
+ args := m.Called(ctx, filterQueries)
+ if logs, ok := args.Get(0).([]api.Log); ok {
+ return logs, args.Error(1)
+ }
+ return nil, args.Error(1)
+}
diff --git a/sentinel/internal/subscription_manager/subscription_manager.go b/sentinel/internal/subscription_manager/subscription_manager.go
new file mode 100644
index 000000000..ae30a47b1
--- /dev/null
+++ b/sentinel/internal/subscription_manager/subscription_manager.go
@@ -0,0 +1,246 @@
+// File: internal/subscription_manager/subscription_manager.go
+package subscription_manager
+
+import (
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/rs/zerolog"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+
+ "github.com/ethereum/go-ethereum/common"
+)
+
+type SubscriptionManagerConfig struct {
+ Logger *zerolog.Logger
+ ChainID int64
+}
+
+// SubscriptionManager manages subscriptions for a specific chain.
+type SubscriptionManager struct {
+ mu sync.RWMutex // Protects all shared fields below
+ registry map[internal.EventKey][]chan api.Log
+ logger zerolog.Logger
+ chainID int64
+ cachedEventKeys []internal.EventKey
+ cacheInitialized bool
+ channelBufferSize int
+ closing bool // Indicates if the manager is shutting down
+ wg sync.WaitGroup
+}
+
+// NewSubscriptionManager initializes a new SubscriptionManager.
+func NewSubscriptionManager(cfg SubscriptionManagerConfig) *SubscriptionManager {
+ subscriptionManagerLogger := cfg.Logger.With().Str("Component", "SubscriptionManager").Logger()
+
+ return &SubscriptionManager{
+ registry: make(map[internal.EventKey][]chan api.Log),
+ logger: subscriptionManagerLogger,
+ chainID: cfg.ChainID,
+ channelBufferSize: 3,
+ }
+}
+
+// Subscribe registers a new subscription and returns a channel for events.
+func (sm *SubscriptionManager) Subscribe(address common.Address, topic common.Hash) (chan api.Log, error) {
+ if address == (common.Address{}) {
+ sm.logger.Warn().Msg("Attempted to subscribe with an empty address")
+ return nil, errors.New("address cannot be empty")
+ }
+ if topic == (common.Hash{}) {
+ sm.logger.Warn().Msg("Attempted to subscribe with an empty topic")
+ return nil, errors.New("topic cannot be empty")
+ }
+
+ sm.invalidateCache()
+
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ eventKey := internal.EventKey{Address: address, Topic: topic}
+ newChan := make(chan api.Log, sm.channelBufferSize)
+ sm.registry[eventKey] = append(sm.registry[eventKey], newChan)
+
+ sm.logger.Info().
+ Int64("ChainID", sm.chainID).
+ Hex("Address", []byte(address.Hex())).
+ Hex("Topic", []byte(topic.Hex())).
+ Int64("SubscriberCount", int64(len(sm.registry[eventKey]))).
+ Msg("New subscription added")
+
+ return newChan, nil
+}
+
+// Unsubscribe removes a subscription and closes the channel.
+func (sm *SubscriptionManager) Unsubscribe(address common.Address, topic common.Hash, ch chan api.Log) error {
+ eventKey := internal.EventKey{Address: address, Topic: topic}
+ sm.mu.RLock()
+ subscribers, exists := sm.registry[eventKey]
+ sm.mu.RUnlock()
+ if !exists {
+ sm.logger.Warn().
+ Int64("ChainID", sm.chainID).
+ Hex("Address", []byte(address.Hex())).
+ Hex("Topic", []byte(topic.Hex())).
+ Msg("Attempted to unsubscribe from a non-existent EventKey")
+ return errors.New("event key does not exist")
+ }
+
+ found := false // Flag to track if the subscriber was found
+
+ for i, subscriber := range subscribers {
+ if subscriber == ch {
+ sm.invalidateCache()
+ // Remove the subscriber from the list
+ sm.mu.Lock()
+ sm.registry[eventKey] = append(subscribers[:i], subscribers[i+1:]...)
+ sm.mu.Unlock()
+ sm.logger.Info().
+ Int64("ChainID", sm.chainID).
+ Hex("Address", []byte(address.Hex())).
+ Hex("Topic", []byte(topic.Hex())).
+ Int64("RemainingSubscribers", int64(len(sm.registry[eventKey]))).
+ Msg("Subscription removed")
+ found = true
+ break
+ }
+ }
+
+ if !found {
+ // Subscriber channel was not found in the registry
+ sm.logger.Warn().
+ Int64("ChainID", sm.chainID).
+ Hex("Address", []byte(address.Hex())).
+ Hex("Topic", []byte(topic.Hex())).
+ Msg("Attempted to unsubscribe a non-existent subscriber")
+ return errors.New("subscriber channel not found")
+ }
+
+ sm.mu.Lock()
+ if len(sm.registry[eventKey]) == 0 {
+ // Clean up the map if there are no more subscribers
+ delete(sm.registry, eventKey)
+ sm.logger.Debug().
+ Int64("ChainID", sm.chainID).
+ Hex("Address", []byte(address.Hex())).
+ Hex("Topic", []byte(topic.Hex())).
+ Msg("No remaining subscribers, removing EventKey from registry")
+ }
+ sm.mu.Unlock()
+ sm.wg.Wait() // Wait for all sends to complete before closing the channel
+
+ close(ch) // Safely close the channel
+ return nil
+}
+
+// BroadcastLog sends the log event to all relevant subscribers.
+func (sm *SubscriptionManager) BroadcastLog(eventKey internal.EventKey, log api.Log) {
+ // Check if the manager is closing
+ sm.mu.RLock()
+ if sm.closing {
+ defer sm.mu.RUnlock()
+ sm.logger.Debug().
+ Interface("EventKey", eventKey).
+ Msg("SubscriptionManager is closing, skipping broadcast")
+ return
+ }
+ // Retrieve subscribers
+ subscribers, exists := sm.registry[eventKey]
+ sm.mu.RUnlock()
+
+ if !exists {
+ sm.logger.Debug().
+ Interface("EventKey", eventKey).
+ Msg("EventKey not found in registry")
+ return
+ }
+
+ for _, ch := range subscribers {
+ sm.wg.Add(1)
+ go func(ch chan api.Log) {
+ defer sm.wg.Done()
+ select {
+ case ch <- log:
+ case <-time.After(100 * time.Millisecond): // Prevent blocking forever
+ sm.logger.Warn().
+ Int64("ChainID", sm.chainID).
+ Msg("Log broadcast to channel timed out")
+ }
+ }(ch)
+ }
+ sm.logger.Debug().
+ Int64("ChainID", sm.chainID).
+ Int("Subscribers", len(subscribers)).
+ Hex("Address", []byte(eventKey.Address.Hex())).
+ Hex("Topic", []byte(eventKey.Topic.Hex())).
+ Msg("Log broadcasted to all subscribers")
+}
+
+// GetAddressesAndTopics retrieves all unique EventKeys.
+// Implements caching: caches the result after the first call and invalidates it upon subscription changes.
+// Returns a slice of EventKeys, each containing a unique address-topic pair.
+func (sm *SubscriptionManager) GetAddressesAndTopics() []internal.EventKey {
+ sm.mu.RLock()
+ if sm.cacheInitialized {
+ defer sm.mu.RUnlock()
+ return sm.cachedEventKeys
+ }
+ sm.mu.RUnlock()
+
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ eventKeys := make([]internal.EventKey, 0, len(sm.registry))
+ for eventKey := range sm.registry {
+ eventKeys = append(eventKeys, eventKey)
+ }
+
+ // Update the cache
+ sm.cachedEventKeys = eventKeys
+ sm.cacheInitialized = true
+
+ sm.logger.Debug().
+ Int64("ChainID", sm.chainID).
+ Int("UniqueEventKeys", len(sm.cachedEventKeys)).
+ Msg("Cached EventKeys")
+
+ return sm.cachedEventKeys
+}
+
+// invalidateCache invalidates the cached addresses and topics.
+func (sm *SubscriptionManager) invalidateCache() {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.cacheInitialized = false
+ sm.cachedEventKeys = nil
+
+ sm.logger.Debug().
+ Int64("ChainID", sm.chainID).
+ Msg("Cache invalidated due to subscription change")
+}
+
+// Close gracefully shuts down the SubscriptionManager by closing all subscriber channels.
+func (sm *SubscriptionManager) Close() {
+ // Set the closing flag under sendMutex
+ sm.mu.Lock()
+ sm.closing = true // Signal that the manager is closing
+ sm.mu.Unlock()
+
+ sm.wg.Wait() // Wait for all sends to complete before closing the channels
+
+ sm.mu.Lock()
+ for eventKey, subscribers := range sm.registry {
+ for _, ch := range subscribers {
+ close(ch)
+ }
+ delete(sm.registry, eventKey)
+ }
+ sm.mu.Unlock()
+ sm.invalidateCache()
+
+ sm.logger.Info().
+ Int64("ChainID", sm.chainID).
+ Msg("SubscriptionManager closed, all subscriber channels have been closed")
+}
diff --git a/sentinel/internal/subscription_manager/subscription_manager_test.go b/sentinel/internal/subscription_manager/subscription_manager_test.go
new file mode 100644
index 000000000..03f8ee73f
--- /dev/null
+++ b/sentinel/internal/subscription_manager/subscription_manager_test.go
@@ -0,0 +1,438 @@
+// File: internal/subscription_manager/subscription_manager_test.go
+package subscription_manager
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Helper function to initialize a SubscriptionManager for testing
+func setupSubscriptionManager(t *testing.T) *SubscriptionManager {
+ t.Helper()
+ testLogger := logging.GetTestLogger(t)
+ return NewSubscriptionManager(SubscriptionManagerConfig{Logger: &testLogger, ChainID: 1})
+}
+
+// Helper function to create an EventKey
+func createEventKey(address common.Address, topic common.Hash) internal.EventKey {
+ return internal.EventKey{Address: address, Topic: topic}
+}
+
+// Helper function to create a log event
+func createLog(blockNumber uint64, txHash common.Hash, address common.Address, topics []common.Hash, data []byte, index uint) api.Log {
+ return api.Log{
+ BlockNumber: blockNumber,
+ TxHash: txHash,
+ Address: address,
+ Topics: topics,
+ Data: data,
+ Index: index,
+ }
+}
+
+// Helper function to assert registry state
+func assertRegistryState(t *testing.T, manager *SubscriptionManager, expectedLength int, expectedEventKeys map[internal.EventKey]int) {
+ t.Helper()
+ manager.mu.RLock()
+ defer manager.mu.RUnlock()
+ assert.Len(t, manager.registry, expectedLength, "Registry should have the expected number of EventKeys")
+ for ek, expectedCount := range expectedEventKeys {
+ actualCount, exists := manager.registry[ek]
+ assert.True(t, exists, "Expected EventKey %v to exist", ek)
+ assert.Len(t, actualCount, expectedCount, "Expected %d subscribers for EventKey %v, got %d", expectedCount, ek, len(actualCount))
+ }
+}
+
+func TestSubscriptionManager_Subscribe(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ tests := []struct {
+ name string
+ address common.Address
+ topic common.Hash
+ expectErr bool
+ }{
+ {
+ name: "Valid subscription",
+ address: common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"),
+ topic: common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ expectErr: false,
+ },
+ {
+ name: "Invalid subscription with empty address",
+ address: common.Address{},
+ topic: common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"),
+ expectErr: true,
+ },
+ {
+ name: "Invalid subscription with empty topic",
+ address: common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"),
+ topic: common.Hash{},
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ch, err := manager.Subscribe(tt.address, tt.topic)
+ if tt.expectErr {
+ require.Error(t, err)
+ assert.Nil(t, ch)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, ch)
+ }
+ })
+ }
+
+ // After subscriptions, check registry state
+ expectedEventKeys := map[internal.EventKey]int{
+ createEventKey(common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678"), common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")): 1,
+ }
+ assertRegistryState(t, manager, 1, expectedEventKeys)
+}
+
+func TestSubscriptionManager_MultipleSubscribers(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ eventKey := createEventKey(address, topic)
+
+ // Subscribe first consumer
+ ch1, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+
+ // Subscribe second consumer
+ ch2, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+
+ // Verify that the list of channels grows
+ expectedEventKeys := map[internal.EventKey]int{
+ eventKey: 2,
+ }
+ assertRegistryState(t, manager, 1, expectedEventKeys)
+
+ // Broadcast a log and ensure both channels receive it
+ logEvent := createLog(
+ 1,
+ common.HexToHash("0x1234"),
+ address,
+ []common.Hash{topic},
+ []byte("log data"),
+ 0,
+ )
+
+ manager.BroadcastLog(eventKey, logEvent)
+
+ receivedLog1 := <-ch1
+ receivedLog2 := <-ch2
+
+ assert.Equal(t, logEvent, receivedLog1, "Subscriber 1 should receive the log")
+ assert.Equal(t, logEvent, receivedLog2, "Subscriber 2 should receive the log")
+}
+
+func TestSubscriptionManager_Unsubscribe(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+
+ // Subscribe to an event
+ ch, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+ assert.NotNil(t, ch)
+
+ // Unsubscribe existing channel
+ err = manager.Unsubscribe(address, topic, ch)
+ assert.NoError(t, err)
+
+ // Try unsubscribing again (should fail)
+ err = manager.Unsubscribe(address, topic, ch)
+ assert.Error(t, err)
+
+ // Unsubscribe non-existent event key
+ otherCh := make(chan api.Log)
+ err = manager.Unsubscribe(address, topic, otherCh)
+ assert.Error(t, err)
+
+ // Check registry state
+ expectedEventKeys := map[internal.EventKey]int{}
+ assertRegistryState(t, manager, 0, expectedEventKeys)
+}
+
+func TestSubscriptionManager_UnsubscribeSelective(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ eventKey := createEventKey(address, topic)
+
+ ch1, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+
+ ch2, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+
+ // Unsubscribe one consumer and ensure the other remains
+ err = manager.Unsubscribe(address, topic, ch1)
+ require.NoError(t, err)
+
+ // Check registry state
+ expectedEventKeys := map[internal.EventKey]int{
+ eventKey: 1,
+ }
+ assertRegistryState(t, manager, 1, expectedEventKeys)
+
+ // Unsubscribe the last consumer and ensure the registry is cleaned up
+ err = manager.Unsubscribe(address, topic, ch2)
+ require.NoError(t, err)
+
+ expectedEventKeys = map[internal.EventKey]int{}
+ assertRegistryState(t, manager, 0, expectedEventKeys)
+}
+
+func TestSubscriptionManager_BroadcastLog(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ eventKey := createEventKey(address, topic)
+
+ // Subscribe to an event
+ ch, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+ assert.NotNil(t, ch)
+
+ logEvent := createLog(
+ 1,
+ common.HexToHash("0x1234"),
+ address,
+ []common.Hash{topic},
+ []byte("log data"),
+ 0,
+ )
+
+ // Broadcast log event
+ manager.BroadcastLog(eventKey, logEvent)
+
+ // Verify the channel received the event
+ receivedLog := <-ch
+ assert.Equal(t, logEvent, receivedLog, "Subscriber should receive the broadcasted log")
+}
+
+func TestSubscriptionManager_BroadcastToAllSubscribers(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address1 := common.HexToAddress("0x9999567890abcdef1234567890abcdef12345678")
+ topic1 := common.HexToHash("0xaaadefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ eventKey1 := createEventKey(address1, topic1)
+
+ address2 := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic2 := common.HexToHash("0xaaadefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ eventKey2 := createEventKey(address2, topic2)
+
+ ch1, err := manager.Subscribe(address1, topic1)
+ require.NoError(t, err)
+
+ ch2, err := manager.Subscribe(address2, topic2)
+ require.NoError(t, err)
+
+ ch3, err := manager.Subscribe(address1, topic1)
+ require.NoError(t, err)
+
+ // Broadcast a log and ensure all channels receive it
+ logEvent1 := createLog(
+ 2,
+ common.HexToHash("0x5678"),
+ address1,
+ []common.Hash{topic1},
+ []byte("another log data"),
+ 0,
+ )
+
+ logEvent2 := createLog(
+ 3,
+ common.HexToHash("0x2345"),
+ address2,
+ []common.Hash{topic2},
+ []byte("another log data 2"),
+ 0,
+ )
+
+ manager.BroadcastLog(eventKey1, logEvent1)
+ manager.BroadcastLog(eventKey2, logEvent2)
+
+ receivedLog1 := <-ch1
+ receivedLog2 := <-ch2
+ receivedLog3 := <-ch3
+
+ assert.Equal(t, logEvent1, receivedLog1, "Subscriber 1 should receive the log")
+ assert.Equal(t, logEvent2, receivedLog2, "Subscriber 2 should receive the log")
+ assert.Equal(t, logEvent1, receivedLog3, "Subscriber 3 should receive the log")
+}
+
+func TestSubscriptionManager_GetAddressesAndTopics(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address1 := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic1 := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+
+ address2 := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+ topic2 := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
+
+ ek1 := createEventKey(address1, topic1)
+ ek2 := createEventKey(address2, topic2)
+
+ _, err := manager.Subscribe(address1, topic1)
+ require.NoError(t, err)
+
+ _, err = manager.Subscribe(address2, topic2)
+ require.NoError(t, err)
+
+ // Fetch addresses and topics as EventKeys
+ result := manager.GetAddressesAndTopics()
+
+ // Verify the slice contains the expected EventKeys
+ assert.Contains(t, result, ek1, "EventKey1 should be in the result")
+ assert.Contains(t, result, ek2, "EventKey2 should be in the result")
+ assert.Len(t, result, 2, "There should be two unique EventKeys")
+}
+
+func TestSubscriptionManager_Cache(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+ assert.False(t, manager.cacheInitialized, "Cache should not be initialized when Subscription Manager is initialized.")
+
+ address1 := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic1 := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+
+ address2 := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+ topic2 := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
+
+ ek1 := createEventKey(address1, topic1)
+ ek2 := createEventKey(address2, topic2)
+
+ // Initialize expected slice of EventKeys
+ expectedCache := []internal.EventKey{}
+
+ // Step 1: Subscribe to an event
+ _, err := manager.Subscribe(address1, topic1)
+ require.NoError(t, err)
+ assert.False(t, manager.cacheInitialized, "Cache should not be initialized after Subscribe.")
+
+ // Update expected slice
+ expectedCache = append(expectedCache, ek1)
+
+ // Verify cache matches expected slice
+ cache := manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should be initialized after GetAddressesAndTopics() is called.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should match the expected slice of EventKeys.")
+
+ // Step 2: Add another subscription
+ _, err = manager.Subscribe(address2, topic2)
+ require.NoError(t, err)
+ assert.False(t, manager.cacheInitialized, "Cache should be invalidated after Subscribe.")
+
+ // Update expected slice
+ expectedCache = append(expectedCache, ek2)
+
+ // Verify cache matches updated slice
+ cache = manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should be reinitialized after GetAddressesAndTopics() is called.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should match the updated slice of EventKeys.")
+
+ // Step 3: Add a duplicate subscription for address1/topic1
+ _, err = manager.Subscribe(address1, topic1)
+ require.NoError(t, err)
+ assert.False(t, manager.cacheInitialized, "Cache should be invalidated after Subscribe.")
+
+ // No change to expected slice since it's a duplicate subscription
+
+ cache = manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should be reinitialized after GetAddressesAndTopics() is called.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should remain unchanged for duplicate subscriptions.")
+
+ // Step 4: Unsubscribe from address2/topic2
+ // Retrieve the subscriber channel for ek2
+ manager.mu.RLock()
+ ch := manager.registry[ek2][0]
+ manager.mu.RUnlock()
+
+ err = manager.Unsubscribe(address2, topic2, ch)
+ require.NoError(t, err)
+ assert.False(t, manager.cacheInitialized, "Cache should be invalidated after Unsubscribe.")
+
+ // Update expected slice
+ // Remove ek2 from expectedCache
+ for i, ek := range expectedCache {
+ if ek.Address == ek2.Address && ek.Topic == ek2.Topic {
+ expectedCache = append(expectedCache[:i], expectedCache[i+1:]...)
+ break
+ }
+ }
+
+ // Verify cache matches updated slice
+ cache = manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should be reinitialized after GetAddressesAndTopics() is called.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should match the updated slice of EventKeys after unsubscription.")
+
+ // Step 5: Unsubscribe from non-existent subscription
+ err = manager.Unsubscribe(address2, topic2, ch)
+ assert.Error(t, err, "Unsubscribing a non-existent subscription should return an error.")
+
+ // Ensure expected slice remains unchanged
+ cache = manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should remain initialized after an invalid unsubscribe attempt.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should remain unchanged for invalid unsubscribe attempts.")
+ manager.mu.RLock()
+ assert.Len(t, manager.registry[ek1], 2, "EventKey should have two subscribers")
+ manager.mu.RUnlock()
+
+ // Step 6: Unsubscribe from address1, topic1, ch2
+ // Retrieve the second subscriber's channel for ek1
+ manager.mu.RLock()
+ ch2 := manager.registry[ek1][1]
+ manager.mu.RUnlock()
+
+ err = manager.Unsubscribe(address1, topic1, ch2)
+ require.NoError(t, err)
+ assert.False(t, manager.cacheInitialized, "Cache should be invalidated after Unsubscribe.")
+
+ // Verify cache remains unchanged for the remaining subscriber
+ cache = manager.GetAddressesAndTopics()
+ assert.True(t, manager.cacheInitialized, "Cache should be reinitialized after GetAddressesAndTopics() is called.")
+ assert.ElementsMatch(t, expectedCache, cache, "Cache should remain unchanged for duplicate subscriptions.")
+ manager.mu.RLock()
+ assert.Len(t, manager.registry[ek1], 1, "EventKey should have one remaining subscriber")
+ manager.mu.RUnlock()
+}
+
+func TestSubscriptionManager_Close(t *testing.T) {
+ manager := setupSubscriptionManager(t)
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+
+ // Subscribe to an event
+ ch, err := manager.Subscribe(address, topic)
+ require.NoError(t, err)
+ assert.NotNil(t, ch)
+
+ // Close the SubscriptionManager
+ manager.Close()
+
+ // Verify channel is closed
+ _, open := <-ch
+ assert.False(t, open, "Channel should be closed after Close()")
+
+ // Verify registry is empty
+ manager.mu.RLock()
+ defer manager.mu.RUnlock()
+ assert.Len(t, manager.registry, 0, "Registry should be empty after Close()")
+}
diff --git a/sentinel/internal/types.go b/sentinel/internal/types.go
new file mode 100644
index 000000000..af585fda7
--- /dev/null
+++ b/sentinel/internal/types.go
@@ -0,0 +1,10 @@
+// File: internal/tools.go
+package internal
+
+import "github.com/ethereum/go-ethereum/common"
+
+// EventKey uniquely identifies an event subscription based on address and topic.
+type EventKey struct {
+ Address common.Address
+ Topic common.Hash
+}
diff --git a/sentinel/log.go b/sentinel/log.go
new file mode 100644
index 000000000..525bd18a9
--- /dev/null
+++ b/sentinel/log.go
@@ -0,0 +1,18 @@
+// File: log.go
+package sentinel
+
+import (
+ "testing"
+
+ "github.com/rs/zerolog"
+ "github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
+)
+
+const (
+ LogLevelEnvVar = "SENTINEL_LOG_LEVEL"
+)
+
+// GetLogger instantiates a logger that takes into account the test context and the log level
+func GetLogger(t *testing.T, componentName string) zerolog.Logger {
+ return logging.GetLogger(t, LogLevelEnvVar).With().Str("Component", componentName).Logger()
+}
diff --git a/sentinel/sentinel.go b/sentinel/sentinel.go
new file mode 100644
index 000000000..e26bf9a05
--- /dev/null
+++ b/sentinel/sentinel.go
@@ -0,0 +1,122 @@
+// File: sentinel.go
+package sentinel
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/rs/zerolog"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal/chain_poller_service"
+)
+
+// SentinelConfig holds configuration for the Sentinel.
+type SentinelConfig struct {
+ t *testing.T
+}
+
+type AddChainConfig struct {
+ ChainID int64
+ PollInterval time.Duration
+ BlockchainClient api.BlockchainClient
+}
+
+type Sentinel struct {
+ l *zerolog.Logger
+ mu sync.RWMutex
+ services map[int64]*chain_poller_service.ChainPollerService // Map of chainID to ChianPollerService
+}
+
+// NewSentinel initializes and returns a new Sentinel instance.
+func NewSentinel(cfg SentinelConfig) *Sentinel {
+ logger := GetLogger(cfg.t, "Sentinel")
+ logger.Info().Msg("Initializing Sentinel")
+ logger.Debug().Msg("Initializing Sentinel")
+ logger.Info().Str("Level", logger.GetLevel().String()).Msg("Initializing Sentinel")
+ return &Sentinel{
+ services: make(map[int64]*chain_poller_service.ChainPollerService),
+ l: &logger,
+ }
+}
+
+// AddChain adds a new chain to Sentinel.
+func (s *Sentinel) AddChain(acc AddChainConfig) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, exists := s.services[acc.ChainID]; exists {
+ return fmt.Errorf("chain with ID %d already exists", acc.ChainID)
+ }
+
+ cfg := chain_poller_service.ChainPollerServiceConfig{
+ PollInterval: acc.PollInterval,
+ ChainID: acc.ChainID,
+ Logger: s.l,
+ BlockchainClient: acc.BlockchainClient,
+ }
+
+ eps, err := chain_poller_service.NewChainPollerService(cfg)
+ if err != nil {
+ return fmt.Errorf("failed to initialize ChainPollerService: %w", err)
+ }
+ s.services[cfg.ChainID] = eps
+ s.l.Info().Int64("ChainID", cfg.ChainID).Msg("Added new chain")
+ eps.Start()
+ return nil
+}
+
+// RemoveChain removes a chain from Sentinel.
+func (s *Sentinel) RemoveChain(chainID int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ eps, exists := s.services[chainID]
+ if !exists {
+ return fmt.Errorf("chain with ID %d does not exist", chainID)
+ }
+
+ eps.Stop()
+ delete(s.services, chainID)
+ s.l.Info().Msg("Removed chain")
+ return nil
+}
+
+// Subscribe subscribes to events for a specific chain.
+func (s *Sentinel) Subscribe(chainID int64, address common.Address, topic common.Hash) (chan api.Log, error) {
+ s.mu.RLock()
+ eps, exists := s.services[chainID]
+ s.mu.RUnlock()
+
+ if !exists {
+ return nil, fmt.Errorf("chain with ID %d does not exist", chainID)
+ }
+
+ return eps.SubscriptionManager().Subscribe(address, topic)
+}
+
+// Unsubscribe unsubscribes from events for a specific chain.
+func (s *Sentinel) Unsubscribe(chainID int64, address common.Address, topic common.Hash, ch chan api.Log) error {
+ s.mu.RLock()
+ eps, exists := s.services[chainID]
+ s.mu.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("chain with ID %d does not exist", chainID)
+ }
+
+ return eps.SubscriptionManager().Unsubscribe(address, topic, ch)
+}
+
+// Close shuts down all chains and the global registry.
+func (s *Sentinel) Close() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for _, eps := range s.services {
+ eps.Stop()
+ delete(s.services, eps.ChainID)
+ }
+}
diff --git a/sentinel/sentinel_test.go b/sentinel/sentinel_test.go
new file mode 100644
index 000000000..b54c70065
--- /dev/null
+++ b/sentinel/sentinel_test.go
@@ -0,0 +1,259 @@
+// File: sentinel_test.go
+package sentinel
+
+import (
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/api"
+ "github.com/smartcontractkit/chainlink-testing-framework/sentinel/internal"
+)
+
+// Helper function to initialize a Sentinel instance for testing.
+func setupSentinel(t *testing.T) *Sentinel {
+ t.Helper()
+ s := NewSentinel(SentinelConfig{t: t})
+
+ // Ensure Sentinel is closed after the test.
+ t.Cleanup(func() {
+ s.Close()
+ })
+
+ return s
+}
+
+// Helper function to set up a chain with a mock blockchain client.
+func setupChain(t *testing.T, chainID int64) (*AddChainConfig, *internal.MockBlockchainClient) {
+ t.Helper()
+ mockClient := new(internal.MockBlockchainClient)
+
+ config := &AddChainConfig{
+ BlockchainClient: mockClient,
+ PollInterval: 100 * time.Millisecond,
+ ChainID: chainID,
+ }
+
+ return config, mockClient
+}
+
+// Helper function to create an EventKey.
+func createEventKey(address common.Address, topic common.Hash) internal.EventKey {
+ return internal.EventKey{Address: address, Topic: topic}
+}
+
+// Helper function to create a log event.
+func createLog(blockNumber uint64, txHash common.Hash, address common.Address, topics []common.Hash, data []byte, index uint) api.Log {
+ return api.Log{
+ BlockNumber: blockNumber,
+ TxHash: txHash,
+ Address: address,
+ Topics: topics,
+ Data: data,
+ Index: index,
+ }
+}
+
+func TestNewSentinel_NoErrors(t *testing.T) {
+ s := setupSentinel(t)
+ require.NotNil(t, s, "Sentinel should not be nil")
+}
+
+func TestAddRemoveChain(t *testing.T) {
+ s := setupSentinel(t)
+
+ // Setup two chains with the same ChainID to test removal.
+ config1, mockClient1 := setupChain(t, 1)
+ mockClient1.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ config2, mockClient2 := setupChain(t, 2)
+ mockClient2.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ // Add first chain.
+ require.NoError(t, s.AddChain(*config1), "Should add chain without error")
+
+ // Add second chain.
+ require.NoError(t, s.AddChain(*config2), "Should add another chain without error")
+
+ // Remove first chain.
+ require.NoError(t, s.RemoveChain(1), "Should remove chain without error")
+
+ // Attempt to add a chain with the same ChainID again.
+ require.Error(t, s.AddChain(*config2), "chain with ID 2 already exists")
+}
+
+func TestAddChain_SubscribeUnsubscribeEvent(t *testing.T) {
+ s := setupSentinel(t)
+
+ config, mockClient := setupChain(t, 1)
+ mockClient.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ // Add chain.
+ require.NoError(t, s.AddChain(*config), "Should add chain without error")
+
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+
+ // Subscribe to an event.
+ ch, err := s.Subscribe(1, address, topic)
+ require.NoError(t, err, "Should subscribe without error")
+ defer s.Unsubscribe(1, address, topic, ch)
+
+ // Wait briefly to ensure subscription is registered.
+ time.Sleep(50 * time.Millisecond)
+
+ // Simulate log broadcast.
+ eventKey := createEventKey(address, topic)
+ log := createLog(1, common.HexToHash("0x1234"), address, []common.Hash{topic}, []byte("event data"), 0)
+
+ // Retrieve the chain service to access the Subscription Manager.
+ chainService, exists := s.services[1]
+ require.True(t, exists, "Chain service should exist")
+
+ // Broadcast the log.
+ chainService.SubscriptionMgr.BroadcastLog(eventKey, log)
+
+ // Verify the subscriber receives the log.
+ select {
+ case receivedLog := <-ch:
+ assert.Equal(t, log, receivedLog, "Received log should match the broadcasted log")
+ case <-time.After(1 * time.Second):
+ t.Fatal("Subscriber did not receive the log")
+ }
+
+ // Unsubscribe and ensure no errors.
+ require.NoError(t, s.Unsubscribe(1, address, topic, ch), "Should unsubscribe without error")
+}
+
+func TestAddChains_MultipleConsumers(t *testing.T) {
+ s := setupSentinel(t)
+
+ // Setup two different chains.
+ config1, mockClient1 := setupChain(t, 1)
+ mockClient1.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ config2, mockClient2 := setupChain(t, 2)
+ mockClient2.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ // Add both chains.
+ require.NoError(t, s.AddChain(*config1), "Should add chain 1 without error")
+ require.NoError(t, s.AddChain(*config2), "Should add chain 2 without error")
+
+ // Subscribe to events on chain 1.
+ address1 := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic1 := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+
+ ch1, err := s.Subscribe(1, address1, topic1)
+ require.NoError(t, err, "Subscriber 1 should subscribe without error")
+ defer s.Unsubscribe(1, address1, topic1, ch1)
+
+ address2 := common.HexToAddress("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")
+ topic2 := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
+
+ ch2, err := s.Subscribe(1, address2, topic2)
+ require.NoError(t, err, "Subscriber 2 should subscribe without error")
+ defer s.Unsubscribe(1, address2, topic2, ch2)
+
+ // Subscribe to an event on chain 2.
+ address3 := common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+ topic3 := common.HexToHash("0xcafebabecafebabecafebabecafebabecafebabe")
+
+ ch3, err := s.Subscribe(2, address3, topic3)
+ require.NoError(t, err, "Subscriber 3 should subscribe without error")
+ defer s.Unsubscribe(2, address3, topic3, ch3)
+
+ // Broadcast logs to both chains.
+ logEvent1 := createLog(2, common.HexToHash("0x5678"), address1, []common.Hash{topic1}, []byte("another log data"), 0)
+ logEvent2 := createLog(3, common.HexToHash("0x2345"), address2, []common.Hash{topic2}, []byte("another log data 2"), 0)
+ logEvent3 := createLog(4, common.HexToHash("0x3456"), address3, []common.Hash{topic3}, []byte("another log data 3"), 0)
+
+ chainService1, exists1 := s.services[1]
+ require.True(t, exists1, "Chain service 1 should exist")
+ chainService2, exists2 := s.services[2]
+ require.True(t, exists2, "Chain service 2 should exist")
+
+ chainService1.SubscriptionMgr.BroadcastLog(createEventKey(address1, topic1), logEvent1)
+ chainService1.SubscriptionMgr.BroadcastLog(createEventKey(address2, topic2), logEvent2)
+ chainService2.SubscriptionMgr.BroadcastLog(createEventKey(address3, topic3), logEvent3)
+
+ // Verify subscribers receive their respective logs.
+ select {
+ case receivedLog := <-ch1:
+ assert.Equal(t, logEvent1, receivedLog, "Subscriber 1 should receive the correct log")
+ case <-time.After(1 * time.Second):
+ t.Fatal("Subscriber 1 did not receive the log")
+ }
+
+ select {
+ case receivedLog := <-ch2:
+ assert.Equal(t, logEvent2, receivedLog, "Subscriber 2 should receive the correct log")
+ case <-time.After(1 * time.Second):
+ t.Fatal("Subscriber 2 did not receive the log")
+ }
+
+ select {
+ case receivedLog := <-ch3:
+ assert.Equal(t, logEvent3, receivedLog, "Subscriber 3 should receive the correct log")
+ case <-time.After(1 * time.Second):
+ t.Fatal("Subscriber 3 did not receive the log")
+ }
+}
+
+func TestAddChains_RemoveAndValidate(t *testing.T) {
+ s := setupSentinel(t)
+
+ // Setup two chains.
+ config1, mockClient1 := setupChain(t, 1)
+ mockClient1.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ config2, mockClient2 := setupChain(t, 2)
+ mockClient2.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ // Add both chains.
+ require.NoError(t, s.AddChain(*config1), "Should add chain 1 without error")
+ require.NoError(t, s.AddChain(*config2), "Should add chain 2 without error")
+
+ // Subscribe to an event on chain 1.
+ address := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678")
+ topic := common.HexToHash("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef")
+
+ ch, err := s.Subscribe(1, address, topic)
+ require.NoError(t, err, "Should subscribe without error")
+ defer s.Unsubscribe(1, address, topic, ch)
+
+ // Remove chain 1.
+ require.NoError(t, s.RemoveChain(1), "Should remove chain 1 without error")
+
+ // Verify that the subscriber's channel is closed.
+ select {
+ case _, open := <-ch:
+ assert.False(t, open, "Channel should be closed after chain removal")
+ default:
+ t.Fatal("Channel was not closed after chain removal")
+ }
+}
+
+func TestAddMultipleChains_CloseSentinel(t *testing.T) {
+ s := setupSentinel(t)
+
+ // Setup two chains.
+ config1, mockClient1 := setupChain(t, 1)
+ mockClient1.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ config2, mockClient2 := setupChain(t, 2)
+ mockClient2.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Once()
+
+ // Add both chains.
+ require.NoError(t, s.AddChain(*config1), "Should add chain 1 without error")
+ require.NoError(t, s.AddChain(*config2), "Should add chain 2 without error")
+
+ // Close Sentinel.
+ s.Close()
+
+ // Verify that all chains are cleaned up.
+ assert.False(t, len(s.services) > 0, "All chains should be cleaned up after Close")
+}