From 66d4588273a4f3bbb811ce5cc8a238df379c54e5 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Fri, 25 Jul 2025 14:02:15 +0200 Subject: [PATCH 01/11] implement notifier and dappmanager adapters --- cmd/main.go | 7 +- internal/adapters/beacon/beacon.go | 293 ++++++++++++++++++ internal/adapters/beaconattestant_adapter.go | 68 +++- internal/adapters/dappmanager/dappmanager.go | 79 +++++ internal/adapters/notifier/notifier.go | 204 ++++++++++++ .../web3signer.go} | 3 +- internal/application/domain/notification.go | 11 + internal/config/config_loader.go | 29 +- 8 files changed, 671 insertions(+), 23 deletions(-) create mode 100644 internal/adapters/beacon/beacon.go create mode 100644 internal/adapters/dappmanager/dappmanager.go create mode 100644 internal/adapters/notifier/notifier.go rename internal/adapters/{web3signer_adapter.go => web3signer/web3signer.go} (94%) create mode 100644 internal/application/domain/notification.go diff --git a/cmd/main.go b/cmd/main.go index fbf13c0..33b49cf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,7 +8,8 @@ import ( "syscall" "time" - "github.com/dappnode/validator-tracker/internal/adapters" + "github.com/dappnode/validator-tracker/internal/adapters/beacon" + "github.com/dappnode/validator-tracker/internal/adapters/web3signer" "github.com/dappnode/validator-tracker/internal/application/domain" "github.com/dappnode/validator-tracker/internal/application/services" "github.com/dappnode/validator-tracker/internal/config" @@ -22,7 +23,7 @@ func main() { cfg.Network, cfg.BeaconEndpoint, cfg.Web3SignerEndpoint) // Fetch validator pubkeys - web3Signer := adapters.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) + web3Signer := web3signer.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) pubkeys, err := web3Signer.GetValidatorPubkeys() if err != nil { logger.Fatal("Failed to get validator pubkeys from web3signer: %v", err) @@ -30,7 +31,7 @@ func main() { logger.Info("Fetched %d pubkeys from web3signer", len(pubkeys)) // Initialize beacon chain adapter - adapter, err := adapters.NewBeaconAttestantAdapter(cfg.BeaconEndpoint) + adapter, err := beacon.NewBeaconAdapter(cfg.BeaconEndpoint) if err != nil { logger.Fatal("Failed to initialize beacon adapter: %v", err) } diff --git a/internal/adapters/beacon/beacon.go b/internal/adapters/beacon/beacon.go new file mode 100644 index 0000000..3db6b4d --- /dev/null +++ b/internal/adapters/beacon/beacon.go @@ -0,0 +1,293 @@ +package beacon + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "net/http" + "time" + + v1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/dappnode/validator-tracker/internal/application/domain" + "github.com/dappnode/validator-tracker/internal/application/ports" + "github.com/rs/zerolog" + + "github.com/attestantio/go-eth2-client/api" + _http "github.com/attestantio/go-eth2-client/http" + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +type beaconAttestantClient struct { + client *_http.Service +} + +func NewBeaconAdapter(endpoint string) (ports.BeaconChainAdapter, error) { + zerolog.SetGlobalLevel(zerolog.WarnLevel) + + customHttpClient := &http.Client{ + Timeout: 2000 * time.Second, + } + + client, err := _http.New(context.Background(), + _http.WithAddress(endpoint), + _http.WithHTTPClient(customHttpClient), + _http.WithTimeout(20*time.Second), // important as attestant API overrides my timeout TODO: investigate how + ) + if err != nil { + return nil, err + } + + return &beaconAttestantClient{client: client.(*_http.Service)}, nil +} + +// GetFinalizedEpoch retrieves the latest finalized epoch from the beacon chain. +func (b *beaconAttestantClient) GetFinalizedEpoch(ctx context.Context) (domain.Epoch, error) { + finality, err := b.client.Finality(ctx, &api.FinalityOpts{State: "head"}) + if err != nil { + return 0, err + } + return domain.Epoch(finality.Data.Finalized.Epoch), nil +} + +// GetJustifiedEpoch retrieves the latest finalized epoch from the beacon chain. +func (b *beaconAttestantClient) GetJustifiedEpoch(ctx context.Context) (domain.Epoch, error) { + finality, err := b.client.Finality(ctx, &api.FinalityOpts{State: "head"}) + if err != nil { + return 0, err + } + return domain.Epoch(finality.Data.Justified.Epoch), nil +} + +// internal/adapters/beaconchain_adapter.go +func (b *beaconAttestantClient) GetValidatorDutiesBatch(ctx context.Context, epoch domain.Epoch, validatorIndices []domain.ValidatorIndex) ([]domain.ValidatorDuty, error) { + // Convert to phase0.ValidatorIndex + var indices []phase0.ValidatorIndex + for _, idx := range validatorIndices { + indices = append(indices, phase0.ValidatorIndex(idx)) + } + + duties, err := b.client.AttesterDuties(ctx, &api.AttesterDutiesOpts{ + Epoch: phase0.Epoch(epoch), + Indices: indices, + }) + if err != nil { + return nil, err + } + + // Map the response to domain.ValidatorDuty + var domainDuties []domain.ValidatorDuty + for _, d := range duties.Data { + domainDuties = append(domainDuties, domain.ValidatorDuty{ + Slot: domain.Slot(d.Slot), + CommitteeIndex: domain.CommitteeIndex(d.CommitteeIndex), + ValidatorCommitteeIdx: d.ValidatorCommitteeIndex, + ValidatorIndex: domain.ValidatorIndex(d.ValidatorIndex), // new field + }) + } + + return domainDuties, nil +} + +func (b *beaconAttestantClient) GetValidatorDuties(ctx context.Context, epoch domain.Epoch, validatorIndex domain.ValidatorIndex) (domain.ValidatorDuty, error) { + duties, err := b.client.AttesterDuties(ctx, &api.AttesterDutiesOpts{ + Epoch: phase0.Epoch(epoch), + Indices: []phase0.ValidatorIndex{phase0.ValidatorIndex(validatorIndex)}, + }) + if err != nil { + return domain.ValidatorDuty{}, err + } + + // 🚨 TODO: how to log this here? needed for validators loaded into web3signer but exited (no duties) + if len(duties.Data) == 0 { + return domain.ValidatorDuty{}, fmt.Errorf("no duties found for validator %d at epoch %d", validatorIndex, epoch) + } + + duty := duties.Data[0] + return domain.ValidatorDuty{ + Slot: domain.Slot(duty.Slot), + CommitteeIndex: domain.CommitteeIndex(duty.CommitteeIndex), + ValidatorCommitteeIdx: duty.ValidatorCommitteeIndex, + }, nil +} + +// GetCommitteeSizeMap retrieves the size of each attestation committee for a specific slot. +// This is very expensive and take a long time to execute, so it should be used sparingly. +// TODO: can we get rid of this? +func (b *beaconAttestantClient) GetCommitteeSizeMap(ctx context.Context, slot domain.Slot) (domain.CommitteeSizeMap, error) { + committees, err := b.client.BeaconCommittees(ctx, &api.BeaconCommitteesOpts{ + State: fmt.Sprintf("%d", slot), + }) + if err != nil { + return nil, err + } + sizeMap := make(domain.CommitteeSizeMap) + for _, committee := range committees.Data { + if domain.Slot(committee.Slot) != slot { + continue + } + sizeMap[domain.CommitteeIndex(committee.Index)] = len(committee.Validators) + } + return sizeMap, nil +} + +// GetBlockAttestations retrieves all attestations include in a slot +func (b *beaconAttestantClient) GetBlockAttestations(ctx context.Context, slot domain.Slot) ([]domain.Attestation, error) { + block, err := b.client.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ + Block: fmt.Sprintf("%d", slot), + }) + if err != nil { + return nil, err + } + + var attestations []domain.Attestation + for _, att := range block.Data.Electra.Message.Body.Attestations { + attestations = append(attestations, domain.Attestation{ + DataSlot: domain.Slot(att.Data.Slot), + CommitteeBits: att.CommitteeBits, + AggregationBits: att.AggregationBits, + }) + } + return attestations, nil +} + +func (b *beaconAttestantClient) GetValidatorIndicesByPubkeys(ctx context.Context, pubkeys []string) ([]domain.ValidatorIndex, error) { + var beaconPubkeys []phase0.BLSPubKey + + // Convert hex pubkeys to BLS pubkeys + for _, hexPubkey := range pubkeys { + // Remove "0x" prefix if present + if len(hexPubkey) >= 2 && hexPubkey[:2] == "0x" { + hexPubkey = hexPubkey[2:] + } + bytes, err := hex.DecodeString(hexPubkey) + if err != nil { + return nil, errors.New("failed to decode pubkey: " + hexPubkey) + } + if len(bytes) != 48 { + return nil, errors.New("invalid pubkey length for: " + hexPubkey) + } + var blsPubkey phase0.BLSPubKey + copy(blsPubkey[:], bytes) + beaconPubkeys = append(beaconPubkeys, blsPubkey) + } + + // Only get validators in active states + // TODO: why do I need apiv1 for this struct? is there something newer? + validators, err := b.client.Validators(ctx, &api.ValidatorsOpts{ + State: "head", + PubKeys: beaconPubkeys, + ValidatorStates: []v1.ValidatorState{ + v1.ValidatorStateActiveOngoing, + v1.ValidatorStateActiveExiting, + v1.ValidatorStateActiveSlashed, + }, + }) + if err != nil { + return nil, err + } + + var indices []domain.ValidatorIndex + for _, v := range validators.Data { + indices = append(indices, domain.ValidatorIndex(v.Index)) + } + return indices, nil +} + +// GetProposerDuties retrieves proposer duties for the given epoch and validator indices. +func (b *beaconAttestantClient) GetProposerDuties(ctx context.Context, epoch domain.Epoch, indices []domain.ValidatorIndex) ([]domain.ProposerDuty, error) { + var beaconIndices []phase0.ValidatorIndex + for _, idx := range indices { + beaconIndices = append(beaconIndices, phase0.ValidatorIndex(idx)) + } + + resp, err := b.client.ProposerDuties(ctx, &api.ProposerDutiesOpts{ + Epoch: phase0.Epoch(epoch), + Indices: beaconIndices, + }) + if err != nil { + return nil, err + } + + var duties []domain.ProposerDuty + for _, d := range resp.Data { + duties = append(duties, domain.ProposerDuty{ + Slot: domain.Slot(d.Slot), + ValidatorIndex: domain.ValidatorIndex(d.ValidatorIndex), + }) + } + return duties, nil +} + +// DidProposeBlock checks a given slot includes a block proposed +func (b *beaconAttestantClient) DidProposeBlock(ctx context.Context, slot domain.Slot) (bool, error) { + block, err := b.client.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ + Block: fmt.Sprintf("%d", slot), + }) + if err != nil { + // TODO: are we sure we can assume that a 404 means the block was not proposed? + // What error code is returned in all consensus if the block is not in their state? + if apiErr, ok := err.(*api.Error); ok && apiErr.StatusCode == 404 { + return false, nil // Block was not proposed + } + return false, err // Real error + } + return block != nil && block.Data != nil, nil +} + +func (b *beaconAttestantClient) GetValidatorsLiveness(ctx context.Context, epoch domain.Epoch, indices []domain.ValidatorIndex) (map[domain.ValidatorIndex]bool, error) { + // Convert to phase0.ValidatorIndex + var beaconIndices []phase0.ValidatorIndex + for _, idx := range indices { + beaconIndices = append(beaconIndices, phase0.ValidatorIndex(idx)) + } + + liveness, err := b.client.ValidatorLiveness(ctx, &api.ValidatorLivenessOpts{ + Epoch: phase0.Epoch(epoch), + Indices: beaconIndices, + }) + if err != nil { + return nil, err + } + + livenessMap := make(map[domain.ValidatorIndex]bool) + for _, v := range liveness.Data { + livenessMap[domain.ValidatorIndex(v.Index)] = v.IsLive + } + return livenessMap, nil +} + +// enum for consensus client +type ConsensusClient string + +const ( + Unknown ConsensusClient = "unknown" + Nimbus ConsensusClient = "nimbus" + Lighthouse ConsensusClient = "lighthouse" + Teku ConsensusClient = "teku" + Prysm ConsensusClient = "prysm" + Lodestar ConsensusClient = "lodestar" +) + +// GetConsensusClient see https://ethereum.github.io/beacon-APIs/#/Node/getNodeVersion. Does not throw an error if the client is not available +func (b *beaconAttestantClient) GetConsensusClient(ctx context.Context) ConsensusClient { + resp, err := b.client.NodeClient(ctx) + if err != nil || resp == nil { + return Unknown + } + + switch resp.Data { + case "nimbus": + return Nimbus + case "lighthouse": + return Lighthouse + case "teku": + return Teku + case "prysm": + return Prysm + case "lodestar": + return Lodestar + default: + return Unknown + } +} diff --git a/internal/adapters/beaconattestant_adapter.go b/internal/adapters/beaconattestant_adapter.go index 07477b2..5336cfd 100644 --- a/internal/adapters/beaconattestant_adapter.go +++ b/internal/adapters/beaconattestant_adapter.go @@ -1,45 +1,44 @@ -// internal/adapters/beaconchain_adapter.go -package adapters +package beacon import ( "context" "encoding/hex" "errors" "fmt" - nethttp "net/http" + "net/http" "time" - apiv1 "github.com/attestantio/go-eth2-client/api/v1" + v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/dappnode/validator-tracker/internal/application/domain" "github.com/dappnode/validator-tracker/internal/application/ports" "github.com/rs/zerolog" "github.com/attestantio/go-eth2-client/api" - "github.com/attestantio/go-eth2-client/http" + _http "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec/phase0" ) type beaconAttestantClient struct { - client *http.Service + client *_http.Service } -func NewBeaconAttestantAdapter(endpoint string) (ports.BeaconChainAdapter, error) { +func NewBeaconAdapter(endpoint string) (ports.BeaconChainAdapter, error) { zerolog.SetGlobalLevel(zerolog.WarnLevel) - customHttpClient := &nethttp.Client{ + customHttpClient := &http.Client{ Timeout: 2000 * time.Second, } - client, err := http.New(context.Background(), - http.WithAddress(endpoint), - http.WithHTTPClient(customHttpClient), - http.WithTimeout(20*time.Second), // important as attestant API overrides my timeout TODO: investigate how + client, err := _http.New(context.Background(), + _http.WithAddress(endpoint), + _http.WithHTTPClient(customHttpClient), + _http.WithTimeout(20*time.Second), // important as attestant API overrides my timeout TODO: investigate how ) if err != nil { return nil, err } - return &beaconAttestantClient{client: client.(*http.Service)}, nil + return &beaconAttestantClient{client: client.(*_http.Service)}, nil } // GetFinalizedEpoch retrieves the latest finalized epoch from the beacon chain. @@ -178,10 +177,10 @@ func (b *beaconAttestantClient) GetValidatorIndicesByPubkeys(ctx context.Context validators, err := b.client.Validators(ctx, &api.ValidatorsOpts{ State: "head", PubKeys: beaconPubkeys, - ValidatorStates: []apiv1.ValidatorState{ - apiv1.ValidatorStateActiveOngoing, - apiv1.ValidatorStateActiveExiting, - apiv1.ValidatorStateActiveSlashed, + ValidatorStates: []v1.ValidatorState{ + v1.ValidatorStateActiveOngoing, + v1.ValidatorStateActiveExiting, + v1.ValidatorStateActiveSlashed, }, }) if err != nil { @@ -236,6 +235,7 @@ func (b *beaconAttestantClient) DidProposeBlock(ctx context.Context, slot domain return block != nil && block.Data != nil, nil } + func (b *beaconAttestantClient) GetValidatorsLiveness(ctx context.Context, epoch domain.Epoch, indices []domain.ValidatorIndex) (map[domain.ValidatorIndex]bool, error) { // Convert to phase0.ValidatorIndex var beaconIndices []phase0.ValidatorIndex @@ -256,4 +256,38 @@ func (b *beaconAttestantClient) GetValidatorsLiveness(ctx context.Context, epoch livenessMap[domain.ValidatorIndex(v.Index)] = v.IsLive } return livenessMap, nil + +// enum for consensus client +type ConsensusClient string + +const ( + Unknown ConsensusClient = "unknown" + Nimbus ConsensusClient = "nimbus" + Lighthouse ConsensusClient = "lighthouse" + Teku ConsensusClient = "teku" + Prysm ConsensusClient = "prysm" + Lodestar ConsensusClient = "lodestar" +) + +// GetConsensusClient see https://ethereum.github.io/beacon-APIs/#/Node/getNodeVersion. Does not throw an error if the client is not available +func (b *beaconAttestantClient) GetConsensusClient(ctx context.Context) ConsensusClient { + resp, err := b.client.NodeClient(ctx) + if err != nil || resp == nil { + return Unknown + } + + switch resp.Data { + case "nimbus": + return Nimbus + case "lighthouse": + return Lighthouse + case "teku": + return Teku + case "prysm": + return Prysm + case "lodestar": + return Lodestar + default: + return Unknown + } } diff --git a/internal/adapters/dappmanager/dappmanager.go b/internal/adapters/dappmanager/dappmanager.go new file mode 100644 index 0000000..deebca6 --- /dev/null +++ b/internal/adapters/dappmanager/dappmanager.go @@ -0,0 +1,79 @@ +package dappmanager + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// DappManagerAdapter is the adapter to interact with the DappManager API +type DappManagerAdapter struct { + baseURL string + signerDnpName string + client *http.Client +} + +// NewDappManagerAdapter creates a new DappManagerAdapter +func NewDappManagerAdapter(baseURL string, dnpName string) *DappManagerAdapter { + return &DappManagerAdapter{ + baseURL: baseURL, + signerDnpName: dnpName, + client: &http.Client{}, + } +} + +// Manifest represents the manifest of a package +type manifest struct { + Notifications struct { + CustomEndpoints []CustomEndpoint `json:"customEndpoints"` + } `json:"notifications"` +} + +type CustomEndpoint struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Description string `json:"description"` + IsBanner bool `json:"isBanner"` + CorrelationId string `json:"correlationId"` + Metric *struct { + Treshold float64 `json:"treshold"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Unit string `json:"unit"` + } `json:"metric,omitempty"` +} + +// Notifications: +// - Missed attestations +// - Missed block proposals +// - Missed sync committee duties (TODO) +// - Validator slashed + +// getSignerManifestNotifications gets the notifications from the Signer package manifest +func (d *DappManagerAdapter) getSignerManifestNotifications(ctx context.Context) ([]CustomEndpoint, error) { + url := d.baseURL + "/package-manifest/" + d.signerDnpName + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for package %s: %w", d.signerDnpName, err) + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest for package %s: %w", d.signerDnpName, err) + } + defer resp.Body.Close() + + // This covers all 2xx status codes. If its not 2xx, we dont bother parsing the manifest and return an error + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code %d for package %s", resp.StatusCode, d.signerDnpName) + } + + var manifest manifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to decode manifest for package %s: %w", d.signerDnpName, err) + } + + return manifest.Notifications.CustomEndpoints, nil +} diff --git a/internal/adapters/notifier/notifier.go b/internal/adapters/notifier/notifier.go new file mode 100644 index 0000000..aedbcc7 --- /dev/null +++ b/internal/adapters/notifier/notifier.go @@ -0,0 +1,204 @@ +package notifier + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/dappnode/validator-tracker/internal/application/domain" +) + +type Notifier struct { + BaseURL string + BeaconchaUrl string + Network string + Category Category + SignerDnpName string + HTTPClient *http.Client +} + +func NewNotifier(baseURL, beaconchaUrl, network, signerDnpName string) *Notifier { + category := Category(strings.ToLower(network)) + if network == "mainnet" { + category = Ethereum + } + return &Notifier{ + BaseURL: baseURL, + BeaconchaUrl: beaconchaUrl, + Network: network, + Category: category, + SignerDnpName: signerDnpName, + HTTPClient: &http.Client{Timeout: 3 * time.Second}, + } +} + +type CallToAction struct { + Title string `json:"title"` + URL string `json:"url"` +} + +type Category string + +const ( + Ethereum Category = "ethereum" + Hoodi Category = "hoodi" + Holesky Category = "holesky" + Gnosis Category = "gnosis" + Lukso Category = "lukso" +) + +type Priority string + +const ( + Low Priority = "low" + Medium Priority = "medium" + High Priority = "high" + Critical Priority = "critical" + Info Priority = "info" +) + +type Status string + +const ( + Triggered Status = "triggered" + Resolved Status = "resolved" +) + +type NotificationPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Category *Category `json:"category,omitempty"` + Status *Status `json:"status,omitempty"` + IsBanner *bool `json:"isBanner,omitempty"` + Priority *Priority `json:"priority,omitempty"` + CorrelationId *string `json:"correlationId,omitempty"` + DnpName *string `json:"dnpName,omitempty"` + CallToAction *CallToAction `json:"callToAction,omitempty"` +} + +func (n *Notifier) sendNotification(payload NotificationPayload) error { + url := fmt.Sprintf("%s/api/v1/notifications", n.BaseURL) + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := n.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("notification failed with status: %s", resp.Status) + } + return nil +} + +// SendValidatorsOffNot sends a notification when one or more validators go offline. +func (n *Notifier) SendValidatorsOffNot(validators []domain.ValidatorIndex) error { + title := fmt.Sprintf("Validator(s) Offline: %s", indexesToString(validators)) + url := n.buildBeaconchaURL(validators) + body := fmt.Sprintf("Validator(s) %s are offline on %s. View: %s", indexesToString(validators), n.Network, url) + priority := High + status := Triggered + payload := NotificationPayload{ + Title: title, + Body: body, + Category: &n.Category, + Priority: &priority, + DnpName: &n.SignerDnpName, + Status: &status, + CallToAction: nil, + } + return n.sendNotification(payload) +} + +// SendValidatorsOnNot sends a notification when one or more validators come back online. +func (n *Notifier) SendValidatorsOnNot(validators []domain.ValidatorIndex) error { + title := fmt.Sprintf("Validator(s) Online: %s", indexesToString(validators)) + url := n.buildBeaconchaURL(validators) + body := fmt.Sprintf("Validator(s) %s are back online on %s. View: %s", indexesToString(validators), n.Network, url) + priority := Info + status := Resolved + payload := NotificationPayload{ + Title: title, + Body: body, + Category: &n.Category, + Priority: &priority, + DnpName: &n.SignerDnpName, + Status: &status, + CallToAction: nil, + } + return n.sendNotification(payload) +} + +// SendValidatorsSlashedNot sends a notification when one or more validators are slashed. +func (n *Notifier) SendValidatorsSlashedNot(validators []domain.ValidatorIndex) error { + title := fmt.Sprintf("Validator(s) Slashed: %s", indexesToString(validators)) + url := n.buildBeaconchaURL(validators) + body := fmt.Sprintf("Validator(s) %s have been slashed on %s! View: %s", indexesToString(validators), n.Network, url) + priority := Critical + status := Triggered + isBanner := true + payload := NotificationPayload{ + Title: title, + Body: body, + Category: &n.Category, + Priority: &priority, + IsBanner: &isBanner, + DnpName: &n.SignerDnpName, + Status: &status, + CallToAction: nil, + } + return n.sendNotification(payload) +} + +// SendBlockProposedNot sends a notification when a block is proposed by one or more validators. +func (n *Notifier) SendBlockProposedNot(validators []domain.ValidatorIndex, epoch int) error { + title := fmt.Sprintf("Block Proposed: %s", indexesToString(validators)) + url := n.buildBeaconchaURL(validators) + body := fmt.Sprintf("Validator(s) %s proposed a block at epoch %d on %s. View: %s", indexesToString(validators), epoch, n.Network, url) + priority := Info + status := Triggered + isBanner := true + payload := NotificationPayload{ + Title: title, + Body: body, + Category: &n.Category, + Priority: &priority, + IsBanner: &isBanner, + DnpName: &n.SignerDnpName, + Status: &status, + CallToAction: nil, + } + return n.sendNotification(payload) +} + +// Helper to join validator indexes as comma-separated string +func indexesToString(indexes []domain.ValidatorIndex) string { + var s []string + for _, idx := range indexes { + s = append(s, fmt.Sprintf("%d", idx)) + } + return strings.Join(s, ",") +} + +// Helper to build beaconcha URL for multiple validators +func (n *Notifier) buildBeaconchaURL(indexes []domain.ValidatorIndex) string { + if len(indexes) == 0 || n.BeaconchaUrl == "" { + return "" + } + // If only one validator, link directly to it + if len(indexes) == 1 { + return fmt.Sprintf("%s/validator/%d", n.BeaconchaUrl, indexes[0]) + } + // Otherwise, link to the validators search page with comma-separated indexes + return fmt.Sprintf("%s/validators?search=%s", n.BeaconchaUrl, indexesToString(indexes)) +} diff --git a/internal/adapters/web3signer_adapter.go b/internal/adapters/web3signer/web3signer.go similarity index 94% rename from internal/adapters/web3signer_adapter.go rename to internal/adapters/web3signer/web3signer.go index 0255a0d..c8cbf18 100644 --- a/internal/adapters/web3signer_adapter.go +++ b/internal/adapters/web3signer/web3signer.go @@ -1,5 +1,4 @@ -// internal/adapters/web3signer_adapter.go -package adapters +package web3signer import ( "encoding/json" diff --git a/internal/application/domain/notification.go b/internal/application/domain/notification.go new file mode 100644 index 0000000..bce48e9 --- /dev/null +++ b/internal/application/domain/notification.go @@ -0,0 +1,11 @@ +package domain + +// create a enum with the validator notifications +type ValidatorNotification string + +const ( + ValidatorOffline ValidatorNotification = "validator-offline" + ValidatorOnline ValidatorNotification = "validator-online" + ValidatorSlashed ValidatorNotification = "validator-slashed" + BlockProposed ValidatorNotification = "block-proposed" +) diff --git a/internal/config/config_loader.go b/internal/config/config_loader.go index 0d59495..ed64ad1 100644 --- a/internal/config/config_loader.go +++ b/internal/config/config_loader.go @@ -12,6 +12,8 @@ type Config struct { BeaconEndpoint string Web3SignerEndpoint string Network string + DnpName string + BeaconchaUrl string } func LoadConfig() Config { @@ -34,13 +36,38 @@ func LoadConfig() Config { // Normalize network name for logs network = strings.ToLower(network) - if network != "hoodi" && network != "holesky" && network != "mainnet" { + if network != "hoodi" && network != "holesky" && network != "mainnet" && network != "gnosis" && network != "lukso" { logger.Fatal("Unknown network: %s", network) } + var dnpName string + if network == "mainnet" { + dnpName = "web3signer.dnp.dappnode.eth" + } else { + dnpName = fmt.Sprintf("web3signer-%s.dnp.dappnode.eth", network) + } + + var beaconchaUrl string + switch network { + case "mainnet": + beaconchaUrl = "https://beaconcha.in" + case "holesky": + beaconchaUrl = "https://holesky.beaconcha.in" + case "hoodi": + beaconchaUrl = "https://hoodi.beaconcha.in" + case "gnosis": + beaconchaUrl = "https://gnosischa.in" + case "lukso": + beaconchaUrl = "https://explorer.consensus.mainnet.lukso.network" + default: + logger.Fatal("Unsupported network for beaconcha URL: %s", network) + } + return Config{ BeaconEndpoint: beaconEndpoint, Web3SignerEndpoint: web3SignerEndpoint, Network: network, + DnpName: dnpName, + BeaconchaUrl: beaconchaUrl, } } From 8fab212497645f7647e94e34561ad4d82918caa7 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Fri, 25 Jul 2025 14:07:23 +0200 Subject: [PATCH 02/11] fix dappmanager adapter --- internal/adapters/dappmanager/dappmanager.go | 30 ++++++++++++++++---- internal/application/domain/notification.go | 7 ++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/internal/adapters/dappmanager/dappmanager.go b/internal/adapters/dappmanager/dappmanager.go index deebca6..f995151 100644 --- a/internal/adapters/dappmanager/dappmanager.go +++ b/internal/adapters/dappmanager/dappmanager.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + + "github.com/dappnode/validator-tracker/internal/application/domain" ) // DappManagerAdapter is the adapter to interact with the DappManager API @@ -44,11 +46,29 @@ type CustomEndpoint struct { } `json:"metric,omitempty"` } -// Notifications: -// - Missed attestations -// - Missed block proposals -// - Missed sync committee duties (TODO) -// - Validator slashed +// GetNotificationsEnabled retrieves the notifications from the DappManager API +func (d *DappManagerAdapter) GetNotificationsEnabled(ctx context.Context) (map[string]bool, error) { + customEndpoints, err := d.getSignerManifestNotifications(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get notifications from signer manifest: %w", err) + } + + // Build a set of valid correlation IDs from domain.ValidatorNotification + validCorrelationIDs := map[string]struct{}{ + string(domain.ValidatorLiveness): {}, + string(domain.ValidatorSlashed): {}, + string(domain.BlockProposed): {}, + } + + notifications := make(map[string]bool) + for _, endpoint := range customEndpoints { + if _, ok := validCorrelationIDs[endpoint.CorrelationId]; ok { + notifications[endpoint.CorrelationId] = endpoint.Enabled + } + } + + return notifications, nil +} // getSignerManifestNotifications gets the notifications from the Signer package manifest func (d *DappManagerAdapter) getSignerManifestNotifications(ctx context.Context) ([]CustomEndpoint, error) { diff --git a/internal/application/domain/notification.go b/internal/application/domain/notification.go index bce48e9..3243c03 100644 --- a/internal/application/domain/notification.go +++ b/internal/application/domain/notification.go @@ -4,8 +4,7 @@ package domain type ValidatorNotification string const ( - ValidatorOffline ValidatorNotification = "validator-offline" - ValidatorOnline ValidatorNotification = "validator-online" - ValidatorSlashed ValidatorNotification = "validator-slashed" - BlockProposed ValidatorNotification = "block-proposed" + ValidatorLiveness ValidatorNotification = "validator-liveness" // online/offline + ValidatorSlashed ValidatorNotification = "validator-slashed" + BlockProposed ValidatorNotification = "block-proposed" ) From 7d5d1b52a164114bd378142a7bb54056a6e9b0ec Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Fri, 25 Jul 2025 14:10:55 +0200 Subject: [PATCH 03/11] add ports --- internal/application/ports/dappmanager.go | 9 +++++++++ internal/application/ports/notifier.go | 10 ++++++++++ 2 files changed, 19 insertions(+) create mode 100644 internal/application/ports/dappmanager.go create mode 100644 internal/application/ports/notifier.go diff --git a/internal/application/ports/dappmanager.go b/internal/application/ports/dappmanager.go new file mode 100644 index 0000000..5a94307 --- /dev/null +++ b/internal/application/ports/dappmanager.go @@ -0,0 +1,9 @@ +package ports + +import ( + "context" +) + +type DappManagerPort interface { + GetNotificationsEnabled(ctx context.Context) (map[string]bool, error) +} diff --git a/internal/application/ports/notifier.go b/internal/application/ports/notifier.go new file mode 100644 index 0000000..83fa4e4 --- /dev/null +++ b/internal/application/ports/notifier.go @@ -0,0 +1,10 @@ +package ports + +import "github.com/dappnode/validator-tracker/internal/application/domain" + +type NotifierPort interface { + SendValidatorsOffNot(validators []domain.ValidatorIndex) error + SendValidatorsOnNot(validators []domain.ValidatorIndex) error + SendValidatorsSlashedNot(validators []domain.ValidatorIndex) error + SendBlockProposedNot(validators []domain.ValidatorIndex, epoch int) error +} From 65cf53f72e8a3fbea4c4e72476c768cc4a4761b2 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Fri, 25 Jul 2025 14:15:43 +0200 Subject: [PATCH 04/11] fix conflict --- internal/adapters/beaconattestant_adapter.go | 293 ------------------- 1 file changed, 293 deletions(-) delete mode 100644 internal/adapters/beaconattestant_adapter.go diff --git a/internal/adapters/beaconattestant_adapter.go b/internal/adapters/beaconattestant_adapter.go deleted file mode 100644 index 5336cfd..0000000 --- a/internal/adapters/beaconattestant_adapter.go +++ /dev/null @@ -1,293 +0,0 @@ -package beacon - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "net/http" - "time" - - v1 "github.com/attestantio/go-eth2-client/api/v1" - "github.com/dappnode/validator-tracker/internal/application/domain" - "github.com/dappnode/validator-tracker/internal/application/ports" - "github.com/rs/zerolog" - - "github.com/attestantio/go-eth2-client/api" - _http "github.com/attestantio/go-eth2-client/http" - "github.com/attestantio/go-eth2-client/spec/phase0" -) - -type beaconAttestantClient struct { - client *_http.Service -} - -func NewBeaconAdapter(endpoint string) (ports.BeaconChainAdapter, error) { - zerolog.SetGlobalLevel(zerolog.WarnLevel) - - customHttpClient := &http.Client{ - Timeout: 2000 * time.Second, - } - - client, err := _http.New(context.Background(), - _http.WithAddress(endpoint), - _http.WithHTTPClient(customHttpClient), - _http.WithTimeout(20*time.Second), // important as attestant API overrides my timeout TODO: investigate how - ) - if err != nil { - return nil, err - } - - return &beaconAttestantClient{client: client.(*_http.Service)}, nil -} - -// GetFinalizedEpoch retrieves the latest finalized epoch from the beacon chain. -func (b *beaconAttestantClient) GetFinalizedEpoch(ctx context.Context) (domain.Epoch, error) { - finality, err := b.client.Finality(ctx, &api.FinalityOpts{State: "head"}) - if err != nil { - return 0, err - } - return domain.Epoch(finality.Data.Finalized.Epoch), nil -} - -// GetJustifiedEpoch retrieves the latest finalized epoch from the beacon chain. -func (b *beaconAttestantClient) GetJustifiedEpoch(ctx context.Context) (domain.Epoch, error) { - finality, err := b.client.Finality(ctx, &api.FinalityOpts{State: "head"}) - if err != nil { - return 0, err - } - return domain.Epoch(finality.Data.Justified.Epoch), nil -} - -// internal/adapters/beaconchain_adapter.go -func (b *beaconAttestantClient) GetValidatorDutiesBatch(ctx context.Context, epoch domain.Epoch, validatorIndices []domain.ValidatorIndex) ([]domain.ValidatorDuty, error) { - // Convert to phase0.ValidatorIndex - var indices []phase0.ValidatorIndex - for _, idx := range validatorIndices { - indices = append(indices, phase0.ValidatorIndex(idx)) - } - - duties, err := b.client.AttesterDuties(ctx, &api.AttesterDutiesOpts{ - Epoch: phase0.Epoch(epoch), - Indices: indices, - }) - if err != nil { - return nil, err - } - - // Map the response to domain.ValidatorDuty - var domainDuties []domain.ValidatorDuty - for _, d := range duties.Data { - domainDuties = append(domainDuties, domain.ValidatorDuty{ - Slot: domain.Slot(d.Slot), - CommitteeIndex: domain.CommitteeIndex(d.CommitteeIndex), - ValidatorCommitteeIdx: d.ValidatorCommitteeIndex, - ValidatorIndex: domain.ValidatorIndex(d.ValidatorIndex), // new field - }) - } - - return domainDuties, nil -} - -func (b *beaconAttestantClient) GetValidatorDuties(ctx context.Context, epoch domain.Epoch, validatorIndex domain.ValidatorIndex) (domain.ValidatorDuty, error) { - duties, err := b.client.AttesterDuties(ctx, &api.AttesterDutiesOpts{ - Epoch: phase0.Epoch(epoch), - Indices: []phase0.ValidatorIndex{phase0.ValidatorIndex(validatorIndex)}, - }) - if err != nil { - return domain.ValidatorDuty{}, err - } - - // 🚨 TODO: how to log this here? needed for validators loaded into web3signer but exited (no duties) - if len(duties.Data) == 0 { - return domain.ValidatorDuty{}, fmt.Errorf("no duties found for validator %d at epoch %d", validatorIndex, epoch) - } - - duty := duties.Data[0] - return domain.ValidatorDuty{ - Slot: domain.Slot(duty.Slot), - CommitteeIndex: domain.CommitteeIndex(duty.CommitteeIndex), - ValidatorCommitteeIdx: duty.ValidatorCommitteeIndex, - }, nil -} - -// GetCommitteeSizeMap retrieves the size of each attestation committee for a specific slot. -// This is very expensive and take a long time to execute, so it should be used sparingly. -// TODO: can we get rid of this? -func (b *beaconAttestantClient) GetCommitteeSizeMap(ctx context.Context, slot domain.Slot) (domain.CommitteeSizeMap, error) { - committees, err := b.client.BeaconCommittees(ctx, &api.BeaconCommitteesOpts{ - State: fmt.Sprintf("%d", slot), - }) - if err != nil { - return nil, err - } - sizeMap := make(domain.CommitteeSizeMap) - for _, committee := range committees.Data { - if domain.Slot(committee.Slot) != slot { - continue - } - sizeMap[domain.CommitteeIndex(committee.Index)] = len(committee.Validators) - } - return sizeMap, nil -} - -// GetBlockAttestations retrieves all attestations include in a slot -func (b *beaconAttestantClient) GetBlockAttestations(ctx context.Context, slot domain.Slot) ([]domain.Attestation, error) { - block, err := b.client.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ - Block: fmt.Sprintf("%d", slot), - }) - if err != nil { - return nil, err - } - - var attestations []domain.Attestation - for _, att := range block.Data.Electra.Message.Body.Attestations { - attestations = append(attestations, domain.Attestation{ - DataSlot: domain.Slot(att.Data.Slot), - CommitteeBits: att.CommitteeBits, - AggregationBits: att.AggregationBits, - }) - } - return attestations, nil -} - -func (b *beaconAttestantClient) GetValidatorIndicesByPubkeys(ctx context.Context, pubkeys []string) ([]domain.ValidatorIndex, error) { - var beaconPubkeys []phase0.BLSPubKey - - // Convert hex pubkeys to BLS pubkeys - for _, hexPubkey := range pubkeys { - // Remove "0x" prefix if present - if len(hexPubkey) >= 2 && hexPubkey[:2] == "0x" { - hexPubkey = hexPubkey[2:] - } - bytes, err := hex.DecodeString(hexPubkey) - if err != nil { - return nil, errors.New("failed to decode pubkey: " + hexPubkey) - } - if len(bytes) != 48 { - return nil, errors.New("invalid pubkey length for: " + hexPubkey) - } - var blsPubkey phase0.BLSPubKey - copy(blsPubkey[:], bytes) - beaconPubkeys = append(beaconPubkeys, blsPubkey) - } - - // Only get validators in active states - // TODO: why do I need apiv1 for this struct? is there something newer? - validators, err := b.client.Validators(ctx, &api.ValidatorsOpts{ - State: "head", - PubKeys: beaconPubkeys, - ValidatorStates: []v1.ValidatorState{ - v1.ValidatorStateActiveOngoing, - v1.ValidatorStateActiveExiting, - v1.ValidatorStateActiveSlashed, - }, - }) - if err != nil { - return nil, err - } - - var indices []domain.ValidatorIndex - for _, v := range validators.Data { - indices = append(indices, domain.ValidatorIndex(v.Index)) - } - return indices, nil -} - -// GetProposerDuties retrieves proposer duties for the given epoch and validator indices. -func (b *beaconAttestantClient) GetProposerDuties(ctx context.Context, epoch domain.Epoch, indices []domain.ValidatorIndex) ([]domain.ProposerDuty, error) { - var beaconIndices []phase0.ValidatorIndex - for _, idx := range indices { - beaconIndices = append(beaconIndices, phase0.ValidatorIndex(idx)) - } - - resp, err := b.client.ProposerDuties(ctx, &api.ProposerDutiesOpts{ - Epoch: phase0.Epoch(epoch), - Indices: beaconIndices, - }) - if err != nil { - return nil, err - } - - var duties []domain.ProposerDuty - for _, d := range resp.Data { - duties = append(duties, domain.ProposerDuty{ - Slot: domain.Slot(d.Slot), - ValidatorIndex: domain.ValidatorIndex(d.ValidatorIndex), - }) - } - return duties, nil -} - -// DidProposeBlock checks a given slot includes a block proposed -func (b *beaconAttestantClient) DidProposeBlock(ctx context.Context, slot domain.Slot) (bool, error) { - block, err := b.client.SignedBeaconBlock(ctx, &api.SignedBeaconBlockOpts{ - Block: fmt.Sprintf("%d", slot), - }) - if err != nil { - // TODO: are we sure we can assume that a 404 means the block was not proposed? - // What error code is returned in all consensus if the block is not in their state? - if apiErr, ok := err.(*api.Error); ok && apiErr.StatusCode == 404 { - return false, nil // Block was not proposed - } - return false, err // Real error - } - return block != nil && block.Data != nil, nil -} - - -func (b *beaconAttestantClient) GetValidatorsLiveness(ctx context.Context, epoch domain.Epoch, indices []domain.ValidatorIndex) (map[domain.ValidatorIndex]bool, error) { - // Convert to phase0.ValidatorIndex - var beaconIndices []phase0.ValidatorIndex - for _, idx := range indices { - beaconIndices = append(beaconIndices, phase0.ValidatorIndex(idx)) - } - - liveness, err := b.client.ValidatorLiveness(ctx, &api.ValidatorLivenessOpts{ - Epoch: phase0.Epoch(epoch), - Indices: beaconIndices, - }) - if err != nil { - return nil, err - } - - livenessMap := make(map[domain.ValidatorIndex]bool) - for _, v := range liveness.Data { - livenessMap[domain.ValidatorIndex(v.Index)] = v.IsLive - } - return livenessMap, nil - -// enum for consensus client -type ConsensusClient string - -const ( - Unknown ConsensusClient = "unknown" - Nimbus ConsensusClient = "nimbus" - Lighthouse ConsensusClient = "lighthouse" - Teku ConsensusClient = "teku" - Prysm ConsensusClient = "prysm" - Lodestar ConsensusClient = "lodestar" -) - -// GetConsensusClient see https://ethereum.github.io/beacon-APIs/#/Node/getNodeVersion. Does not throw an error if the client is not available -func (b *beaconAttestantClient) GetConsensusClient(ctx context.Context) ConsensusClient { - resp, err := b.client.NodeClient(ctx) - if err != nil || resp == nil { - return Unknown - } - - switch resp.Data { - case "nimbus": - return Nimbus - case "lighthouse": - return Lighthouse - case "teku": - return Teku - case "prysm": - return Prysm - case "lodestar": - return Lodestar - default: - return Unknown - } -} From 1b05938688f9ad5e50f8b9dc614abc90524e3506 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 09:24:44 +0200 Subject: [PATCH 05/11] Implement notifications in the service --- cmd/main.go | 24 +++- internal/adapters/dappmanager/dappmanager.go | 8 +- internal/adapters/notifier/notifier.go | 61 +++++---- internal/application/domain/notification.go | 4 +- internal/application/ports/dappmanager.go | 4 +- internal/application/ports/notifier.go | 5 +- .../services/dutieschecker_service.go | 120 ++++++++++++------ internal/config/config_loader.go | 16 ++- 8 files changed, 152 insertions(+), 90 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 33b49cf..6ddb36b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,8 @@ import ( "time" "github.com/dappnode/validator-tracker/internal/adapters/beacon" + "github.com/dappnode/validator-tracker/internal/adapters/dappmanager" + "github.com/dappnode/validator-tracker/internal/adapters/notifier" "github.com/dappnode/validator-tracker/internal/adapters/web3signer" "github.com/dappnode/validator-tracker/internal/application/domain" "github.com/dappnode/validator-tracker/internal/application/services" @@ -22,6 +24,14 @@ func main() { logger.Info("Loaded config: network=%s, beaconEndpoint=%s, web3SignerEndpoint=%s", cfg.Network, cfg.BeaconEndpoint, cfg.Web3SignerEndpoint) + dappmanager := dappmanager.NewDappManagerAdapter(cfg.DappmanagerUrl, cfg.SignerDnpName) + notifier := notifier.NewNotifier( + cfg.NotifierUrl, + cfg.BeaconchaUrl, + cfg.Network, + cfg.SignerDnpName, + ) + // Fetch validator pubkeys web3Signer := web3signer.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) pubkeys, err := web3Signer.GetValidatorPubkeys() @@ -31,13 +41,13 @@ func main() { logger.Info("Fetched %d pubkeys from web3signer", len(pubkeys)) // Initialize beacon chain adapter - adapter, err := beacon.NewBeaconAdapter(cfg.BeaconEndpoint) + beacon, err := beacon.NewBeaconAdapter(cfg.BeaconEndpoint) if err != nil { logger.Fatal("Failed to initialize beacon adapter: %v", err) } // Get validator indices from pubkeys - indices, err := adapter.GetValidatorIndicesByPubkeys(context.Background(), pubkeys) + indices, err := beacon.GetValidatorIndicesByPubkeys(context.Background(), pubkeys) if err != nil { logger.Fatal("Failed to get validator indices: %v", err) } @@ -51,10 +61,12 @@ func main() { // Start the duties checker service in a goroutine logger.Info("Starting duties checker for %d validators", len(indices)) dutiesChecker := &services.DutiesChecker{ - BeaconAdapter: adapter, - Web3SignerAdapter: web3Signer, - PollInterval: 1 * time.Minute, - CheckedEpochs: make(map[domain.ValidatorIndex]domain.Epoch), + Beacon: beacon, + Signer: web3Signer, + Notifier: notifier, + Dappmanager: dappmanager, + PollInterval: 1 * time.Minute, + CheckedEpochs: make(map[domain.ValidatorIndex]domain.Epoch), } wg.Add(1) go func() { diff --git a/internal/adapters/dappmanager/dappmanager.go b/internal/adapters/dappmanager/dappmanager.go index f995151..693dc9a 100644 --- a/internal/adapters/dappmanager/dappmanager.go +++ b/internal/adapters/dappmanager/dappmanager.go @@ -47,7 +47,7 @@ type CustomEndpoint struct { } // GetNotificationsEnabled retrieves the notifications from the DappManager API -func (d *DappManagerAdapter) GetNotificationsEnabled(ctx context.Context) (map[string]bool, error) { +func (d *DappManagerAdapter) GetNotificationsEnabled(ctx context.Context) (domain.ValidatorNotificationsEnabled, error) { customEndpoints, err := d.getSignerManifestNotifications(ctx) if err != nil { return nil, fmt.Errorf("failed to get notifications from signer manifest: %w", err) @@ -57,13 +57,13 @@ func (d *DappManagerAdapter) GetNotificationsEnabled(ctx context.Context) (map[s validCorrelationIDs := map[string]struct{}{ string(domain.ValidatorLiveness): {}, string(domain.ValidatorSlashed): {}, - string(domain.BlockProposed): {}, + string(domain.BlockProposal): {}, } - notifications := make(map[string]bool) + notifications := make(domain.ValidatorNotificationsEnabled) for _, endpoint := range customEndpoints { if _, ok := validCorrelationIDs[endpoint.CorrelationId]; ok { - notifications[endpoint.CorrelationId] = endpoint.Enabled + notifications[domain.ValidatorNotification(endpoint.CorrelationId)] = endpoint.Enabled } } diff --git a/internal/adapters/notifier/notifier.go b/internal/adapters/notifier/notifier.go index aedbcc7..b4629eb 100644 --- a/internal/adapters/notifier/notifier.go +++ b/internal/adapters/notifier/notifier.go @@ -101,32 +101,22 @@ func (n *Notifier) sendNotification(payload NotificationPayload) error { return nil } -// SendValidatorsOffNot sends a notification when one or more validators go offline. -func (n *Notifier) SendValidatorsOffNot(validators []domain.ValidatorIndex) error { - title := fmt.Sprintf("Validator(s) Offline: %s", indexesToString(validators)) - url := n.buildBeaconchaURL(validators) - body := fmt.Sprintf("Validator(s) %s are offline on %s. View: %s", indexesToString(validators), n.Network, url) - priority := High - status := Triggered - payload := NotificationPayload{ - Title: title, - Body: body, - Category: &n.Category, - Priority: &priority, - DnpName: &n.SignerDnpName, - Status: &status, - CallToAction: nil, +// SendValidatorLivenessNot sends a notification when one or more validators go offline or online. +func (n *Notifier) SendValidatorLivenessNot(validators []domain.ValidatorIndex, live bool) error { + var title, body string + var priority Priority + var status Status + if live { + title = fmt.Sprintf("Validator(s) Online: %s", indexesToString(validators)) + body = fmt.Sprintf("Validator(s) %s are back online on %s. View: %s", indexesToString(validators), n.Network, n.buildBeaconchaURL(validators)) + priority = Info + status = Resolved + } else { + title = fmt.Sprintf("Validator(s) Offline: %s", indexesToString(validators)) + body = fmt.Sprintf("Validator(s) %s are offline on %s. View: %s", indexesToString(validators), n.Network, n.buildBeaconchaURL(validators)) + priority = High + status = Triggered } - return n.sendNotification(payload) -} - -// SendValidatorsOnNot sends a notification when one or more validators come back online. -func (n *Notifier) SendValidatorsOnNot(validators []domain.ValidatorIndex) error { - title := fmt.Sprintf("Validator(s) Online: %s", indexesToString(validators)) - url := n.buildBeaconchaURL(validators) - body := fmt.Sprintf("Validator(s) %s are back online on %s. View: %s", indexesToString(validators), n.Network, url) - priority := Info - status := Resolved payload := NotificationPayload{ Title: title, Body: body, @@ -160,14 +150,21 @@ func (n *Notifier) SendValidatorsSlashedNot(validators []domain.ValidatorIndex) return n.sendNotification(payload) } -// SendBlockProposedNot sends a notification when a block is proposed by one or more validators. -func (n *Notifier) SendBlockProposedNot(validators []domain.ValidatorIndex, epoch int) error { - title := fmt.Sprintf("Block Proposed: %s", indexesToString(validators)) - url := n.buildBeaconchaURL(validators) - body := fmt.Sprintf("Validator(s) %s proposed a block at epoch %d on %s. View: %s", indexesToString(validators), epoch, n.Network, url) - priority := Info - status := Triggered +// SendBlockProposalNot sends a notification when a block is proposed or missed by one or more validators. +func (n *Notifier) SendBlockProposalNot(validators []domain.ValidatorIndex, epoch int, proposed bool) error { + var title, body string + var priority Priority + var status Status = Triggered isBanner := true + if proposed { + title = fmt.Sprintf("Block Proposed: %s", indexesToString(validators)) + body = fmt.Sprintf("Validator(s) %s proposed a block at epoch %d on %s. View: %s", indexesToString(validators), epoch, n.Network, n.buildBeaconchaURL(validators)) + priority = Info + } else { + title = fmt.Sprintf("Block Missed: %s", indexesToString(validators)) + body = fmt.Sprintf("Validator(s) %s missed a block proposal at epoch %d on %s. View: %s", indexesToString(validators), epoch, n.Network, n.buildBeaconchaURL(validators)) + priority = High + } payload := NotificationPayload{ Title: title, Body: body, diff --git a/internal/application/domain/notification.go b/internal/application/domain/notification.go index 3243c03..a46710b 100644 --- a/internal/application/domain/notification.go +++ b/internal/application/domain/notification.go @@ -1,10 +1,12 @@ package domain +type ValidatorNotificationsEnabled map[ValidatorNotification]bool + // create a enum with the validator notifications type ValidatorNotification string const ( ValidatorLiveness ValidatorNotification = "validator-liveness" // online/offline ValidatorSlashed ValidatorNotification = "validator-slashed" - BlockProposed ValidatorNotification = "block-proposed" + BlockProposal ValidatorNotification = "block-proposal" ) diff --git a/internal/application/ports/dappmanager.go b/internal/application/ports/dappmanager.go index 5a94307..bc6774c 100644 --- a/internal/application/ports/dappmanager.go +++ b/internal/application/ports/dappmanager.go @@ -2,8 +2,10 @@ package ports import ( "context" + + "github.com/dappnode/validator-tracker/internal/application/domain" ) type DappManagerPort interface { - GetNotificationsEnabled(ctx context.Context) (map[string]bool, error) + GetNotificationsEnabled(ctx context.Context) (domain.ValidatorNotificationsEnabled, error) } diff --git a/internal/application/ports/notifier.go b/internal/application/ports/notifier.go index 83fa4e4..fd6f4bd 100644 --- a/internal/application/ports/notifier.go +++ b/internal/application/ports/notifier.go @@ -3,8 +3,7 @@ package ports import "github.com/dappnode/validator-tracker/internal/application/domain" type NotifierPort interface { - SendValidatorsOffNot(validators []domain.ValidatorIndex) error - SendValidatorsOnNot(validators []domain.ValidatorIndex) error + SendValidatorLivenessNot(validators []domain.ValidatorIndex, live bool) error SendValidatorsSlashedNot(validators []domain.ValidatorIndex) error - SendBlockProposedNot(validators []domain.ValidatorIndex, epoch int) error + SendBlockProposalNot(validators []domain.ValidatorIndex, epoch int, proposed bool) error } diff --git a/internal/application/services/dutieschecker_service.go b/internal/application/services/dutieschecker_service.go index a533e56..844d12e 100644 --- a/internal/application/services/dutieschecker_service.go +++ b/internal/application/services/dutieschecker_service.go @@ -10,12 +10,17 @@ import ( ) type DutiesChecker struct { - BeaconAdapter ports.BeaconChainAdapter - Web3SignerAdapter ports.Web3SignerAdapter - PollInterval time.Duration + Beacon ports.BeaconChainAdapter + Signer ports.Web3SignerAdapter + Notifier ports.NotifierPort + Dappmanager ports.DappManagerPort + PollInterval time.Duration lastJustifiedEpoch domain.Epoch CheckedEpochs map[domain.ValidatorIndex]domain.Epoch // latest epoch checked for each validator index + + lastLivenessState bool // true if last notification was online, false if offline, unset if first run + livenessStateSet bool // to track if lastLivenessState is initialized } // If at interval, ticker ticks but check has not ended, we wont start a new check, we will just wait for the next tick. @@ -26,15 +31,19 @@ func (a *DutiesChecker) Run(ctx context.Context) { for { select { case <-ticker.C: - a.chechLatestJustifiedEpoch(ctx) + notificationsEnabled, err := a.Dappmanager.GetNotificationsEnabled(ctx) + if err != nil { + logger.Warn("Error fetching notifications enabled, notification will not be sent: %v", err) + } + a.checkLatestJustifiedEpoch(ctx, notificationsEnabled) case <-ctx.Done(): return } } } -func (a *DutiesChecker) chechLatestJustifiedEpoch(ctx context.Context) { - justifiedEpoch, err := a.BeaconAdapter.GetJustifiedEpoch(ctx) +func (a *DutiesChecker) checkLatestJustifiedEpoch(ctx context.Context, notificationsEnabled domain.ValidatorNotificationsEnabled) { + justifiedEpoch, err := a.Beacon.GetJustifiedEpoch(ctx) if err != nil { logger.Error("Error fetching justified epoch: %v", err) return @@ -46,13 +55,13 @@ func (a *DutiesChecker) chechLatestJustifiedEpoch(ctx context.Context) { a.lastJustifiedEpoch = justifiedEpoch logger.Info("New justified epoch %d detected.", justifiedEpoch) - pubkeys, err := a.Web3SignerAdapter.GetValidatorPubkeys() + pubkeys, err := a.Signer.GetValidatorPubkeys() if err != nil { logger.Error("Error fetching pubkeys from web3signer: %v", err) return } - indices, err := a.BeaconAdapter.GetValidatorIndicesByPubkeys(ctx, pubkeys) + indices, err := a.Beacon.GetValidatorIndicesByPubkeys(ctx, pubkeys) if err != nil { logger.Error("Error fetching validator indices from beacon node: %v", err) return @@ -65,18 +74,51 @@ func (a *DutiesChecker) chechLatestJustifiedEpoch(ctx context.Context) { return } - // Initialize a map to track success of both checks - proposalChecked := make(map[domain.ValidatorIndex]bool) - livenessChecked := make(map[domain.ValidatorIndex]bool) + // Liveness notification logic + offline, _, allLive, err := a.checkLiveness(ctx, justifiedEpoch, validatorIndices) + if err != nil { + logger.Error("Error checking liveness for validators: %v", err) + return + } + if len(offline) > 0 && (!a.livenessStateSet || a.lastLivenessState) { + if notificationsEnabled[domain.ValidatorLiveness] { + if err := a.Notifier.SendValidatorLivenessNot(offline, false); err != nil { + logger.Warn("Error sending validator liveness notification: %v", err) + } + } + a.lastLivenessState = false + a.livenessStateSet = true + } + if allLive && (!a.livenessStateSet || !a.lastLivenessState) { + if notificationsEnabled[domain.ValidatorLiveness] { + if err := a.Notifier.SendValidatorLivenessNot(validatorIndices, true); err != nil { + logger.Warn("Error sending validator liveness notification: %v", err) + } + } + a.lastLivenessState = true + a.livenessStateSet = true + } - a.checkProposals(ctx, justifiedEpoch, validatorIndices, proposalChecked) - a.checkLiveness(ctx, justifiedEpoch, validatorIndices, livenessChecked) + // Block proposal notification logic + proposed, missed, err := a.checkProposals(ctx, justifiedEpoch, validatorIndices) + if err != nil { + logger.Error("Error checking block proposals: %v", err) + return + } + if len(proposed) > 0 && notificationsEnabled[domain.BlockProposal] { + if err := a.Notifier.SendBlockProposalNot(proposed, int(justifiedEpoch), true); err != nil { + logger.Warn("Error sending block proposal notification: %v", err) + } + } + if len(missed) > 0 && notificationsEnabled[domain.BlockProposal] { + if err := a.Notifier.SendBlockProposalNot(missed, int(justifiedEpoch), false); err != nil { + logger.Warn("Error sending block proposal notification: %v", err) + } + } - // Mark validators as checked only if both checks succeeded + // Mark validators as checked for _, index := range validatorIndices { - if proposalChecked[index] && livenessChecked[index] { - a.CheckedEpochs[index] = justifiedEpoch - } + a.CheckedEpochs[index] = justifiedEpoch } } @@ -84,64 +126,60 @@ func (a *DutiesChecker) checkLiveness( ctx context.Context, epochToTrack domain.Epoch, indices []domain.ValidatorIndex, - livenessChecked map[domain.ValidatorIndex]bool, -) { +) (offline []domain.ValidatorIndex, online []domain.ValidatorIndex, allLive bool, err error) { if len(indices) == 0 { logger.Warn("No validators to check liveness for in epoch %d", epochToTrack) - return + return nil, nil, false, nil } - livenessMap, err := a.BeaconAdapter.GetValidatorsLiveness(ctx, epochToTrack, indices) + livenessMap, err := a.Beacon.GetValidatorsLiveness(ctx, epochToTrack, indices) if err != nil { - logger.Error("Error checking liveness for validators: %v", err) - return + return nil, nil, false, err } - for _, index := range indices { - isLive, ok := livenessMap[index] - if !ok { - logger.Warn("⚠️ Liveness info not found for validator %d in epoch %d", index, epochToTrack) - continue - } - livenessChecked[index] = true - if !isLive { - logger.Warn("❌ Validator %d is not live in epoch %d", index, epochToTrack) + allLive = true + for _, idx := range indices { + isLive, ok := livenessMap[idx] + if !ok || !isLive { + offline = append(offline, idx) + allLive = false } else { - logger.Info("✅ Validator %d is live in epoch %d", index, epochToTrack) + online = append(online, idx) } } + return offline, online, allLive, nil } func (a *DutiesChecker) checkProposals( ctx context.Context, epochToTrack domain.Epoch, indices []domain.ValidatorIndex, - proposalChecked map[domain.ValidatorIndex]bool, -) { - proposerDuties, err := a.BeaconAdapter.GetProposerDuties(ctx, epochToTrack, indices) +) (proposed []domain.ValidatorIndex, missed []domain.ValidatorIndex, err error) { + proposerDuties, err := a.Beacon.GetProposerDuties(ctx, epochToTrack, indices) if err != nil { - logger.Error("Error fetching proposer duties: %v", err) - return + return nil, nil, err } if len(proposerDuties) == 0 { logger.Warn("No proposer duties for any validators in epoch %d", epochToTrack) - return + return nil, nil, nil } for _, duty := range proposerDuties { - didPropose, err := a.BeaconAdapter.DidProposeBlock(ctx, duty.Slot) + didPropose, err := a.Beacon.DidProposeBlock(ctx, duty.Slot) if err != nil { logger.Warn("⚠️ Could not determine if block was proposed at slot %d: %v", duty.Slot, err) continue } - proposalChecked[duty.ValidatorIndex] = true if didPropose { + proposed = append(proposed, duty.ValidatorIndex) logger.Info("✅ Validator %d successfully proposed a block at slot %d", duty.ValidatorIndex, duty.Slot) } else { + missed = append(missed, duty.ValidatorIndex) logger.Warn("❌ Validator %d was scheduled to propose at slot %d but did not", duty.ValidatorIndex, duty.Slot) } } + return proposed, missed, nil } func (a *DutiesChecker) getValidatorsToCheck(indices []domain.ValidatorIndex, epoch domain.Epoch) []domain.ValidatorIndex { diff --git a/internal/config/config_loader.go b/internal/config/config_loader.go index ed64ad1..f1c28ae 100644 --- a/internal/config/config_loader.go +++ b/internal/config/config_loader.go @@ -12,8 +12,10 @@ type Config struct { BeaconEndpoint string Web3SignerEndpoint string Network string - DnpName string + SignerDnpName string BeaconchaUrl string + DappmanagerUrl string + NotifierUrl string } func LoadConfig() Config { @@ -25,6 +27,8 @@ func LoadConfig() Config { // Build the dynamic endpoints beaconEndpoint := fmt.Sprintf("http://beacon-chain.%s.dncore.dappnode:3500", network) web3SignerEndpoint := fmt.Sprintf("http://web3signer.web3signer-%s.dappnode:9000", network) + dappmanagerEndpoint := "http://dappmanager.dappnode" + notifierEndpoint := "http://notifier.dappnode:8080" // Allow override via environment variables if envBeacon := os.Getenv("BEACON_ENDPOINT"); envBeacon != "" { @@ -33,6 +37,12 @@ func LoadConfig() Config { if envWeb3Signer := os.Getenv("WEB3SIGNER_ENDPOINT"); envWeb3Signer != "" { web3SignerEndpoint = envWeb3Signer } + if envDappmanager := os.Getenv("DAPPMANAGER_ENDPOINT"); envDappmanager != "" { + dappmanagerEndpoint = envDappmanager + } + if envNotifier := os.Getenv("NOTIFIER_URL"); envNotifier != "" { + notifierEndpoint = envNotifier + } // Normalize network name for logs network = strings.ToLower(network) @@ -67,7 +77,9 @@ func LoadConfig() Config { BeaconEndpoint: beaconEndpoint, Web3SignerEndpoint: web3SignerEndpoint, Network: network, - DnpName: dnpName, + SignerDnpName: dnpName, BeaconchaUrl: beaconchaUrl, + DappmanagerUrl: dappmanagerEndpoint, + NotifierUrl: notifierEndpoint, } } From 84530ea918b9d297fa20e8efd63704d0d7f2487f Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 09:29:35 +0200 Subject: [PATCH 06/11] use one var --- .../services/dutieschecker_service.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/application/services/dutieschecker_service.go b/internal/application/services/dutieschecker_service.go index 844d12e..b41e4fd 100644 --- a/internal/application/services/dutieschecker_service.go +++ b/internal/application/services/dutieschecker_service.go @@ -19,8 +19,9 @@ type DutiesChecker struct { lastJustifiedEpoch domain.Epoch CheckedEpochs map[domain.ValidatorIndex]domain.Epoch // latest epoch checked for each validator index - lastLivenessState bool // true if last notification was online, false if offline, unset if first run - livenessStateSet bool // to track if lastLivenessState is initialized + // lastLivenessState tracks the last liveness notification sent: nil = no notification sent yet (first run), + // true = last notification was online, false = last notification was offline + lastLivenessState *bool } // If at interval, ticker ticks but check has not ended, we wont start a new check, we will just wait for the next tick. @@ -80,23 +81,23 @@ func (a *DutiesChecker) checkLatestJustifiedEpoch(ctx context.Context, notificat logger.Error("Error checking liveness for validators: %v", err) return } - if len(offline) > 0 && (!a.livenessStateSet || a.lastLivenessState) { + if len(offline) > 0 && (a.lastLivenessState == nil || *a.lastLivenessState) { if notificationsEnabled[domain.ValidatorLiveness] { if err := a.Notifier.SendValidatorLivenessNot(offline, false); err != nil { logger.Warn("Error sending validator liveness notification: %v", err) } } - a.lastLivenessState = false - a.livenessStateSet = true + val := false + a.lastLivenessState = &val } - if allLive && (!a.livenessStateSet || !a.lastLivenessState) { + if allLive && (a.lastLivenessState == nil || !*a.lastLivenessState) { if notificationsEnabled[domain.ValidatorLiveness] { if err := a.Notifier.SendValidatorLivenessNot(validatorIndices, true); err != nil { logger.Warn("Error sending validator liveness notification: %v", err) } } - a.lastLivenessState = true - a.livenessStateSet = true + val := true + a.lastLivenessState = &val } // Block proposal notification logic From 530dcbc70b2d14c4c63dfbc72682c668cf2876a7 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 09:30:26 +0200 Subject: [PATCH 07/11] add todo's --- cmd/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 6ddb36b..885f4cc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,6 +34,7 @@ func main() { // Fetch validator pubkeys web3Signer := web3signer.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) + // TODO: move to a service pubkeys, err := web3Signer.GetValidatorPubkeys() if err != nil { logger.Fatal("Failed to get validator pubkeys from web3signer: %v", err) @@ -47,6 +48,7 @@ func main() { } // Get validator indices from pubkeys + // TODO: move to a service indices, err := beacon.GetValidatorIndicesByPubkeys(context.Background(), pubkeys) if err != nil { logger.Fatal("Failed to get validator indices: %v", err) From 821aa49cf9953bc31efc3834ce16089679c32c4a Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 10:03:45 +0200 Subject: [PATCH 08/11] add todo's --- internal/adapters/notifier/notifier.go | 4 ++++ .../services/dutieschecker_service.go | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/adapters/notifier/notifier.go b/internal/adapters/notifier/notifier.go index b4629eb..a73beae 100644 --- a/internal/adapters/notifier/notifier.go +++ b/internal/adapters/notifier/notifier.go @@ -11,6 +11,10 @@ import ( "github.com/dappnode/validator-tracker/internal/application/domain" ) +// TODO: add correlation IDs for notifications +// TODO: move beaconcha URL to call to action +// TODO: discuss isBanner + type Notifier struct { BaseURL string BeaconchaUrl string diff --git a/internal/application/services/dutieschecker_service.go b/internal/application/services/dutieschecker_service.go index b41e4fd..aae84b3 100644 --- a/internal/application/services/dutieschecker_service.go +++ b/internal/application/services/dutieschecker_service.go @@ -32,18 +32,18 @@ func (a *DutiesChecker) Run(ctx context.Context) { for { select { case <-ticker.C: - notificationsEnabled, err := a.Dappmanager.GetNotificationsEnabled(ctx) - if err != nil { - logger.Warn("Error fetching notifications enabled, notification will not be sent: %v", err) - } - a.checkLatestJustifiedEpoch(ctx, notificationsEnabled) + a.checkLatestJustifiedEpoch(ctx) case <-ctx.Done(): return } } } -func (a *DutiesChecker) checkLatestJustifiedEpoch(ctx context.Context, notificationsEnabled domain.ValidatorNotificationsEnabled) { +func (a *DutiesChecker) checkLatestJustifiedEpoch(ctx context.Context) { + // TODO: we want to keep checking on error until? + // 1. info required to determine if notification was required to be sent + // 2. if notification x was suppose to be sent and could not + justifiedEpoch, err := a.Beacon.GetJustifiedEpoch(ctx) if err != nil { logger.Error("Error fetching justified epoch: %v", err) @@ -56,6 +56,12 @@ func (a *DutiesChecker) checkLatestJustifiedEpoch(ctx context.Context, notificat a.lastJustifiedEpoch = justifiedEpoch logger.Info("New justified epoch %d detected.", justifiedEpoch) + // Check for notifications + notificationsEnabled, err := a.Dappmanager.GetNotificationsEnabled(ctx) + if err != nil { + logger.Warn("Error fetching notifications enabled, notification will not be sent: %v", err) + } + pubkeys, err := a.Signer.GetValidatorPubkeys() if err != nil { logger.Error("Error fetching pubkeys from web3signer: %v", err) From c33e4c593cb9196b76debc9a5127dd7340a4fff4 Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 10:06:08 +0200 Subject: [PATCH 09/11] remove unused code from main --- cmd/main.go | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 885f4cc..442733d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -24,6 +24,7 @@ func main() { logger.Info("Loaded config: network=%s, beaconEndpoint=%s, web3SignerEndpoint=%s", cfg.Network, cfg.BeaconEndpoint, cfg.Web3SignerEndpoint) + // Initialize adapters dappmanager := dappmanager.NewDappManagerAdapter(cfg.DappmanagerUrl, cfg.SignerDnpName) notifier := notifier.NewNotifier( cfg.NotifierUrl, @@ -31,37 +32,18 @@ func main() { cfg.Network, cfg.SignerDnpName, ) - - // Fetch validator pubkeys web3Signer := web3signer.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) - // TODO: move to a service - pubkeys, err := web3Signer.GetValidatorPubkeys() - if err != nil { - logger.Fatal("Failed to get validator pubkeys from web3signer: %v", err) - } - logger.Info("Fetched %d pubkeys from web3signer", len(pubkeys)) - - // Initialize beacon chain adapter beacon, err := beacon.NewBeaconAdapter(cfg.BeaconEndpoint) if err != nil { logger.Fatal("Failed to initialize beacon adapter: %v", err) } - // Get validator indices from pubkeys - // TODO: move to a service - indices, err := beacon.GetValidatorIndicesByPubkeys(context.Background(), pubkeys) - if err != nil { - logger.Fatal("Failed to get validator indices: %v", err) - } - logger.Info("Found %d validator indices active", len(indices)) - // Prepare context and WaitGroup for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() var wg sync.WaitGroup // Start the duties checker service in a goroutine - logger.Info("Starting duties checker for %d validators", len(indices)) dutiesChecker := &services.DutiesChecker{ Beacon: beacon, Signer: web3Signer, From 0d41be8ae7974d13b6f39d8288d61e7e11f0c97f Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 10:07:53 +0200 Subject: [PATCH 10/11] add todo beacon adapter --- internal/adapters/beacon/beacon.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/adapters/beacon/beacon.go b/internal/adapters/beacon/beacon.go index 3db6b4d..cd3e628 100644 --- a/internal/adapters/beacon/beacon.go +++ b/internal/adapters/beacon/beacon.go @@ -18,6 +18,8 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" ) +// TODO: implement slash method + type beaconAttestantClient struct { client *_http.Service } From 401e88c568abd617549c78f3c025b028775ba91f Mon Sep 17 00:00:00 2001 From: pablomendezroyo Date: Mon, 28 Jul 2025 10:09:11 +0200 Subject: [PATCH 11/11] add todo comment --- cmd/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 442733d..57f5069 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,6 +18,8 @@ import ( "github.com/dappnode/validator-tracker/internal/logger" ) +//TODO: Implement dev mode with commands example + func main() { // Load config cfg := config.LoadConfig()