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") +}