diff --git a/cmd/main.go b/cmd/main.go index aa84146..97949e9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ import ( "lido-events/internal/adapters/storage/exits" "lido-events/internal/adapters/storage/performance" "lido-events/internal/adapters/vebo" + "lido-events/internal/application/domain" "lido-events/internal/application/services" "lido-events/internal/config" "lido-events/internal/logger" @@ -40,6 +41,9 @@ func main() { logger.FatalWithPrefix(logPrefix, "Failed to load network configuration: %v", err) } + // Initialize domain-level notification identifiers based on network + domain.InitNotifications(config.Network) + // Initiate RPC Ethereum clients rpcClient, err := ethclient.Dial(config.RpcUrl) if err != nil { @@ -54,7 +58,7 @@ func main() { if err != nil { logger.FatalWithPrefix(logPrefix, "Failed to initialize performance storage adapter: %v", err) } - notifier := notifier.NewNotifier(config.Network, config.LidoDnpName, config.BrainUrl, config.StakersUiUrl, config.BeaconchaUrl) + notifier := notifier.NewNotifier(ctx, config.Network, config.DappmanagerUrl, config.LidoDnpName, config.BrainUrl, config.StakersUiUrl, config.BeaconchaUrl) relays, err := relays.NewARelays(rpcClient, config.MEVBoostRelaysAllowListAddres, config.DappmanagerUrl, config.MevBoostDnpName) if err != nil { diff --git a/internal/adapters/dappmanager/dappmanager.go b/internal/adapters/dappmanager/dappmanager.go deleted file mode 100644 index e245344..0000000 --- a/internal/adapters/dappmanager/dappmanager.go +++ /dev/null @@ -1,99 +0,0 @@ -package dappmanager - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - "lido-events/internal/application/domain" -) - -// DappManager is the adapter to interact with the DappManager API -type DappManager struct { - baseURL string - signerDnpName string - client *http.Client -} - -// NewDappManager creates a new DappManager instance -func NewDappManager(baseURL string, dnpName string) *DappManager { - return &DappManager{ - 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 *DappManager) GetNotificationsEnabled(ctx context.Context) (domain.LidoNotificationsEnabled, 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.Notifications - validCorrelationIDs := map[string]struct{}{ - string(domain.Notifications.Exit): {}, - string(domain.Notifications.Relay): {}, - string(domain.Notifications.Performance): {}, - } - - notifications := make(domain.LidoNotificationsEnabled) - for _, endpoint := range customEndpoints { - if _, ok := validCorrelationIDs[endpoint.CorrelationId]; ok { - notifications[domain.LidoNotification(endpoint.CorrelationId)] = endpoint.Enabled - } - } - - return notifications, nil -} - -// getSignerManifestNotifications gets the notifications from the Signer package manifest -func (d *DappManager) 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/dappmanager/dappmanager.go b/internal/adapters/notifier/dappmanager/dappmanager.go new file mode 100644 index 0000000..944778d --- /dev/null +++ b/internal/adapters/notifier/dappmanager/dappmanager.go @@ -0,0 +1,139 @@ +package dappmanager + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "lido-events/internal/application/domain" +) + +// DappManager is the adapter to interact with the DappManager API +type DappManager struct { + baseURL string + lidoDnpName string + client *http.Client +} + +// NewDappManager creates a new DappManager instance +func NewDappManager(baseURL string, dnpName string) *DappManager { + return &DappManager{ + baseURL: baseURL, + lidoDnpName: 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 *DappManager) getNotificationsEnabled(ctx context.Context) (domain.LidoNotificationsEnabled, error) { + customEndpoints, err := d.getLidoManifestNotifications(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get notifications from signer manifest: %w", err) + } + + notifications := make(domain.LidoNotificationsEnabled) + for _, endpoint := range customEndpoints { + notifications[domain.LidoNotification(endpoint.CorrelationId)] = endpoint.Enabled + } + + return notifications, nil +} + +// isNotificationEnabled is a generic helper that checks whether a given +// notification (identified by its LidoNotification key) is enabled according +// to the signer manifest configuration retrieved from DappManager. +func (d *DappManager) isNotificationEnabled(ctx context.Context, notification domain.LidoNotification) (bool, error) { + notifications, err := d.getNotificationsEnabled(ctx) + if err != nil { + return false, err + } + + enabled, ok := notifications[notification] + if !ok { + return false, nil + } + + return enabled, nil +} + +// IsValidatorExitRequestEnabled reports whether the validator exit request +// notification (correlationId like "-validator-exit-request") is +// enabled in the signer manifest. +func (d *DappManager) IsValidatorExitRequestEnabled(ctx context.Context) (bool, error) { + return d.isNotificationEnabled(ctx, domain.Notifications.ValidatorExitRequest) +} + +// IsValidatorExitEnabled reports whether the validator exit notification +// (correlationId like "-validator-exit") is enabled. +func (d *DappManager) IsValidatorExitEnabled(ctx context.Context) (bool, error) { + return d.isNotificationEnabled(ctx, domain.Notifications.ValidatorExit) +} + +// IsRelaysBlacklistEnabled reports whether the relays blacklist notification +// (correlationId like "-relays-blacklist") is enabled. +func (d *DappManager) IsRelaysBlacklistEnabled(ctx context.Context) (bool, error) { + return d.isNotificationEnabled(ctx, domain.Notifications.RelaysBlacklist) +} + +// IsRelaysMissingEnabled reports whether the relays missing notification +// (correlationId like "-relays-missing") is enabled. +func (d *DappManager) IsRelaysMissingEnabled(ctx context.Context) (bool, error) { + return d.isNotificationEnabled(ctx, domain.Notifications.RleaysMissing) +} + +// IsMissingLogReceiptsEnabled reports whether the missing log receipts +// notification (correlationId like "-missing-log-receipts") is +// enabled. +func (d *DappManager) IsMissingLogReceiptsEnabled(ctx context.Context) (bool, error) { + return d.isNotificationEnabled(ctx, domain.Notifications.MissingLogReceipts) +} + +// getLidoManifestNotifications gets the notifications from the Signer package manifest +func (d *DappManager) getLidoManifestNotifications(ctx context.Context) ([]CustomEndpoint, error) { + url := d.baseURL + "/package-manifest/" + d.lidoDnpName + + 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.lidoDnpName, err) + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest for package %s: %w", d.lidoDnpName, 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.lidoDnpName) + } + + 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.lidoDnpName, err) + } + + return manifest.Notifications.CustomEndpoints, nil +} diff --git a/internal/adapters/notifier/notifier.go b/internal/adapters/notifier/notifier.go index bac0f07..9bebc5f 100644 --- a/internal/adapters/notifier/notifier.go +++ b/internal/adapters/notifier/notifier.go @@ -2,15 +2,20 @@ package notifier import ( "bytes" + "context" "encoding/json" "fmt" "math/big" "net/http" "strings" "time" + + "lido-events/internal/adapters/notifier/dappmanager" + "lido-events/internal/application/domain" ) type Notifier struct { + ctx context.Context Network string Category Category LidoDnpName string @@ -18,14 +23,19 @@ type Notifier struct { StakersUiUrl string BeaconchaUrl string HTTPClient *http.Client + DappManager *dappmanager.DappManager } -func NewNotifier(network, lidoDnpName, brainUrl, stakersUiUrl, beaconchaUrl string) *Notifier { +func NewNotifier(ctx context.Context, network, dappmanagerUrl, lidoDnpName, brainUrl, stakersUiUrl, beaconchaUrl string) *Notifier { category := Category(strings.ToLower(network)) if network == "mainnet" { category = Ethereum } + + dm := dappmanager.NewDappManager(dappmanagerUrl, lidoDnpName) + return &Notifier{ + ctx: ctx, Network: network, Category: category, LidoDnpName: lidoDnpName, @@ -33,6 +43,7 @@ func NewNotifier(network, lidoDnpName, brainUrl, stakersUiUrl, beaconchaUrl stri StakersUiUrl: stakersUiUrl, BeaconchaUrl: beaconchaUrl, HTTPClient: &http.Client{Timeout: 3 * time.Second}, + DappManager: dm, } } @@ -85,7 +96,7 @@ func (n *Notifier) sendNotification(payload NotificationPayload) error { if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(n.ctx, "POST", url, bytes.NewBuffer(body)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } @@ -103,13 +114,18 @@ func (n *Notifier) sendNotification(payload NotificationPayload) error { // SendMisingLogReceiptsNotification sends a notification about missing log receipts. func (n *Notifier) SendMissingLogReceiptsNotification(message string) error { + enabled, err := n.DappManager.IsMissingLogReceiptsEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "⚠️ Lido CSM: Execution client missing Log Receipts", Body: message, Category: &n.Category, Priority: func() *Priority { p := High; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "missing_log_receipts"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.MissingLogReceipts); return &s }(), CallToAction: &CallToAction{ Title: "Switch execution", URL: n.StakersUiUrl, @@ -120,26 +136,37 @@ func (n *Notifier) SendMissingLogReceiptsNotification(message string) error { // SendValidatorExitRequestedNotification sends a notification about a validator exit request. func (n *Notifier) SendValidatorExitRequestedNotification(message string) error { + enabled, err := n.DappManager.IsValidatorExitRequestEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "🚪 Lido CSM: Validator Exit Requested", Body: message, Category: &n.Category, Priority: func() *Priority { p := Medium; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "validator_exit_requested"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.ValidatorExitRequest); return &s }(), } return n.sendNotification(payload) } // SendValidatorFailedExitNotification sends a notification about a validator failed exit. func (n *Notifier) SendValidatorFailedExitNotification(message string) error { + enabled, err := n.DappManager.IsValidatorExitEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "❌ Lido CSM: Validator Exit Failed", Body: message, Category: &n.Category, Priority: func() *Priority { p := Critical; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "validator_exit_failed"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.ValidatorExit); return &s }(), + Status: func() *Status { s := Triggered; return &s }(), CallToAction: &CallToAction{ Title: "Exit validator manually", URL: n.BrainUrl, @@ -150,13 +177,19 @@ func (n *Notifier) SendValidatorFailedExitNotification(message string) error { // SendValidatorSucceedExitNotification sends a notification about a validator successful exit. func (n *Notifier) SendValidatorSucceedExitNotification(message string, validatorIndex *big.Int) error { + enabled, err := n.DappManager.IsValidatorExitEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "✅ Lido CSM: Validator Exit Succeeded", Body: message, Category: &n.Category, Priority: func() *Priority { p := Medium; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "validator_exit_succeeded"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.ValidatorExit); return &s }(), + Status: func() *Status { s := Resolved; return &s }(), CallToAction: &CallToAction{ Title: "View validator", URL: fmt.Sprintf("%s/validator/%s", n.BeaconchaUrl, validatorIndex.String()), @@ -167,13 +200,18 @@ func (n *Notifier) SendValidatorSucceedExitNotification(message string, validato // SendBlackListedNotification sends a notification about blacklisted relays. func (n *Notifier) SendBlackListedNotification(message string) error { + enabled, err := n.DappManager.IsRelaysBlacklistEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "🚨 Lido CSM: Blacklisted Relays Detected", Body: message, Category: &n.Category, Priority: func() *Priority { p := High; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "blacklisted_relays"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.RelaysBlacklist); return &s }(), CallToAction: &CallToAction{ Title: "Review relays", URL: n.StakersUiUrl, @@ -184,13 +222,18 @@ func (n *Notifier) SendBlackListedNotification(message string) error { // SendMissingRelayNotification sends a notification about missing mandatory relays. func (n *Notifier) SendMissingRelayNotification(message string) error { + enabled, err := n.DappManager.IsRelaysMissingEnabled(n.ctx) + if err != nil || !enabled { + return err + } + payload := NotificationPayload{ Title: "⚠️ Lido CSM: No Mandatory Relays in Use", Body: message, Category: &n.Category, Priority: func() *Priority { p := High; return &p }(), DnpName: &n.LidoDnpName, - CorrelationId: func() *string { s := "missing_mandatory_relays"; return &s }(), + CorrelationId: func() *string { s := string(domain.Notifications.RleaysMissing); return &s }(), CallToAction: &CallToAction{ Title: "Review relays", URL: n.StakersUiUrl, diff --git a/internal/application/domain/notification.go b/internal/application/domain/notification.go index 3c37389..50b8b2c 100644 --- a/internal/application/domain/notification.go +++ b/internal/application/domain/notification.go @@ -5,17 +5,22 @@ type LidoNotificationsEnabled map[LidoNotification]bool type LidoNotification string type LidoNotifications struct { - Exit LidoNotification - Relay LidoNotification - Performance LidoNotification + ValidatorExitRequest LidoNotification + ValidatorExit LidoNotification + RelaysBlacklist LidoNotification + RleaysMissing LidoNotification + MissingLogReceipts LidoNotification } var Notifications LidoNotifications func InitNotifications(network string) { + prefix := network + "-" Notifications = LidoNotifications{ - Exit: LidoNotification(network + "exit"), - Relay: LidoNotification(network + "relay"), - Performance: LidoNotification(network + "performance"), + ValidatorExitRequest: LidoNotification(prefix + "validator-exit-request"), + ValidatorExit: LidoNotification(prefix + "validator-exit"), + RelaysBlacklist: LidoNotification(prefix + "relays-blacklist"), + RleaysMissing: LidoNotification(prefix + "relays-missing"), + MissingLogReceipts: LidoNotification(prefix + "missing-log-receipts"), } }