diff --git a/cmd/main.go b/cmd/main.go index fbf13c0..57f5069 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,52 +8,51 @@ 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/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" "github.com/dappnode/validator-tracker/internal/config" "github.com/dappnode/validator-tracker/internal/logger" ) +//TODO: Implement dev mode with commands example + func main() { // Load config cfg := config.LoadConfig() logger.Info("Loaded config: network=%s, beaconEndpoint=%s, web3SignerEndpoint=%s", cfg.Network, cfg.BeaconEndpoint, cfg.Web3SignerEndpoint) - // Fetch validator pubkeys - web3Signer := adapters.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) - 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 - adapter, err := adapters.NewBeaconAttestantAdapter(cfg.BeaconEndpoint) + // Initialize adapters + dappmanager := dappmanager.NewDappManagerAdapter(cfg.DappmanagerUrl, cfg.SignerDnpName) + notifier := notifier.NewNotifier( + cfg.NotifierUrl, + cfg.BeaconchaUrl, + cfg.Network, + cfg.SignerDnpName, + ) + web3Signer := web3signer.NewWeb3SignerAdapter(cfg.Web3SignerEndpoint) + 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) - 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{ - 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/beaconattestant_adapter.go b/internal/adapters/beacon/beacon.go similarity index 83% rename from internal/adapters/beaconattestant_adapter.go rename to internal/adapters/beacon/beacon.go index 07477b2..cd3e628 100644 --- a/internal/adapters/beaconattestant_adapter.go +++ b/internal/adapters/beacon/beacon.go @@ -1,45 +1,46 @@ -// 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" ) +// TODO: implement slash method + 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 +179,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 { @@ -257,3 +258,38 @@ func (b *beaconAttestantClient) GetValidatorsLiveness(ctx context.Context, epoch } 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..693dc9a --- /dev/null +++ b/internal/adapters/dappmanager/dappmanager.go @@ -0,0 +1,99 @@ +package dappmanager + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/dappnode/validator-tracker/internal/application/domain" +) + +// 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"` +} + +// GetNotificationsEnabled retrieves the notifications from the DappManager API +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) + } + + // Build a set of valid correlation IDs from domain.ValidatorNotification + validCorrelationIDs := map[string]struct{}{ + string(domain.ValidatorLiveness): {}, + string(domain.ValidatorSlashed): {}, + string(domain.BlockProposal): {}, + } + + notifications := make(domain.ValidatorNotificationsEnabled) + for _, endpoint := range customEndpoints { + if _, ok := validCorrelationIDs[endpoint.CorrelationId]; ok { + notifications[domain.ValidatorNotification(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) { + 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..a73beae --- /dev/null +++ b/internal/adapters/notifier/notifier.go @@ -0,0 +1,205 @@ +package notifier + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "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 + 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 +} + +// 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 + } + 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) +} + +// 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, + 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..a46710b --- /dev/null +++ b/internal/application/domain/notification.go @@ -0,0 +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" + BlockProposal ValidatorNotification = "block-proposal" +) diff --git a/internal/application/ports/dappmanager.go b/internal/application/ports/dappmanager.go new file mode 100644 index 0000000..bc6774c --- /dev/null +++ b/internal/application/ports/dappmanager.go @@ -0,0 +1,11 @@ +package ports + +import ( + "context" + + "github.com/dappnode/validator-tracker/internal/application/domain" +) + +type DappManagerPort interface { + GetNotificationsEnabled(ctx context.Context) (domain.ValidatorNotificationsEnabled, error) +} diff --git a/internal/application/ports/notifier.go b/internal/application/ports/notifier.go new file mode 100644 index 0000000..fd6f4bd --- /dev/null +++ b/internal/application/ports/notifier.go @@ -0,0 +1,9 @@ +package ports + +import "github.com/dappnode/validator-tracker/internal/application/domain" + +type NotifierPort interface { + SendValidatorLivenessNot(validators []domain.ValidatorIndex, live bool) error + SendValidatorsSlashedNot(validators []domain.ValidatorIndex) 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..aae84b3 100644 --- a/internal/application/services/dutieschecker_service.go +++ b/internal/application/services/dutieschecker_service.go @@ -10,12 +10,18 @@ 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 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. @@ -26,15 +32,19 @@ func (a *DutiesChecker) Run(ctx context.Context) { for { select { case <-ticker.C: - a.chechLatestJustifiedEpoch(ctx) + a.checkLatestJustifiedEpoch(ctx) case <-ctx.Done(): return } } } -func (a *DutiesChecker) chechLatestJustifiedEpoch(ctx context.Context) { - justifiedEpoch, err := a.BeaconAdapter.GetJustifiedEpoch(ctx) +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) return @@ -46,13 +56,19 @@ func (a *DutiesChecker) chechLatestJustifiedEpoch(ctx context.Context) { a.lastJustifiedEpoch = justifiedEpoch logger.Info("New justified epoch %d detected.", justifiedEpoch) - pubkeys, err := a.Web3SignerAdapter.GetValidatorPubkeys() + // 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) 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 +81,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.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) + } + } + val := false + a.lastLivenessState = &val + } + 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) + } + } + val := true + a.lastLivenessState = &val + } - 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 +133,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 0d59495..f1c28ae 100644 --- a/internal/config/config_loader.go +++ b/internal/config/config_loader.go @@ -12,6 +12,10 @@ type Config struct { BeaconEndpoint string Web3SignerEndpoint string Network string + SignerDnpName string + BeaconchaUrl string + DappmanagerUrl string + NotifierUrl string } func LoadConfig() Config { @@ -23,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 != "" { @@ -31,16 +37,49 @@ 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) - 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, + SignerDnpName: dnpName, + BeaconchaUrl: beaconchaUrl, + DappmanagerUrl: dappmanagerEndpoint, + NotifierUrl: notifierEndpoint, } }