diff --git a/apps/api/package.json b/apps/api/package.json index eebb667f54..726ab27e85 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@akashnetwork/akash-api": "1.4.3", - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/console-api-types": "*", "@akashnetwork/database": "*", "@akashnetwork/env-loader": "*", diff --git a/apps/deploy-web/package.json b/apps/deploy-web/package.json index a189307cb0..44ee772dab 100644 --- a/apps/deploy-web/package.json +++ b/apps/deploy-web/package.json @@ -26,7 +26,7 @@ "type-check": "tsc" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/console-api-types": "*", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 80a5a94613..94f0e46759 100644 --- a/apps/indexer/package.json +++ b/apps/indexer/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@akashnetwork/akash-api": "1.4.3", - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/database": "*", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", diff --git a/apps/notifications/drizzle/0006_romantic_sasquatch.sql b/apps/notifications/drizzle/0006_romantic_sasquatch.sql new file mode 100644 index 0000000000..17722516b1 --- /dev/null +++ b/apps/notifications/drizzle/0006_romantic_sasquatch.sql @@ -0,0 +1,33 @@ +CREATE TYPE "public"."provider_tier_demotion_notification_status" AS ENUM('PENDING', 'SENT');--> statement-breakpoint +CREATE TABLE "provider_tier_demotion_notifications" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + "stream_id" uuid NOT NULL, + "cursor" bigint NOT NULL, + "alert_id" uuid NOT NULL, + "provider" text NOT NULL, + "owner" text NOT NULL, + "dseq" text NOT NULL, + "gseq" integer NOT NULL, + "oseq" integer NOT NULL, + "bseq" integer NOT NULL, + "status" "provider_tier_demotion_notification_status" DEFAULT 'PENDING' NOT NULL, + "claim_id" uuid NOT NULL, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + "sent_at" timestamp with time zone, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "provider_tier_demotion_state" ( + "id" integer PRIMARY KEY DEFAULT 1 NOT NULL, + "stream_id" uuid, + "cursor" bigint DEFAULT 0 NOT NULL, + "claim_id" uuid, + "claim_expires_at" timestamp with time zone, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "provider_tier_demotion_notifications" ADD CONSTRAINT "provider_tier_demotion_notifications_alert_id_alerts_id_fk" FOREIGN KEY ("alert_id") REFERENCES "public"."alerts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "provider_tier_demotion_notifications_delivery_uidx" ON "provider_tier_demotion_notifications" USING btree ("stream_id","cursor","alert_id","owner","dseq","gseq","oseq","bseq","provider");--> statement-breakpoint +CREATE INDEX "provider_tier_demotion_notifications_status_idx" ON "provider_tier_demotion_notifications" USING btree ("status","claimed_at"); \ No newline at end of file diff --git a/apps/notifications/drizzle/meta/0006_snapshot.json b/apps/notifications/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..2e5a23b95a --- /dev/null +++ b/apps/notifications/drizzle/meta/0006_snapshot.json @@ -0,0 +1,725 @@ +{ + "id": "5311eb6f-84e6-4f63-86f9-e1fa9d5923d5", + "prevId": "9360dd65-3d4f-40bc-9be1-92015414ec1a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "type": { + "name": "type", + "type": "alert_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "alert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OK'" + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "min_block_height": { + "name": "min_block_height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_alerts_user_id": { + "name": "idx_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_notification_channel_id": { + "name": "idx_alerts_notification_channel_id", + "columns": [ + { + "expression": "notification_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_status": { + "name": "idx_alerts_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_min_block_height_id": { + "name": "idx_alerts_min_block_height_id", + "columns": [ + { + "expression": "min_block_height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_created_at_id": { + "name": "idx_alerts_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_params": { + "name": "idx_alerts_params", + "columns": [ + { + "expression": "\"params\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_alerts_enabled_type_status": { + "name": "idx_alerts_enabled_type_status", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_notification_channel_id_notification_channels_id_fk": { + "name": "alerts_notification_channel_id_notification_channels_id_fk", + "tableFrom": "alerts", + "tableTo": "notification_channels", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_tier_demotion_notifications": { + "name": "provider_tier_demotion_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gseq": { + "name": "gseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oseq": { + "name": "oseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bseq": { + "name": "bseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "provider_tier_demotion_notification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_tier_demotion_notifications_delivery_uidx": { + "name": "provider_tier_demotion_notifications_delivery_uidx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cursor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alert_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_tier_demotion_notifications_status_idx": { + "name": "provider_tier_demotion_notifications_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_tier_demotion_notifications_alert_id_alerts_id_fk": { + "name": "provider_tier_demotion_notifications_alert_id_alerts_id_fk", + "tableFrom": "provider_tier_demotion_notifications", + "tableTo": "alerts", + "columnsFrom": [ + "alert_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_tier_demotion_state": { + "name": "provider_tier_demotion_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block_cursor": { + "name": "block_cursor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'latest'" + }, + "last_processed_block": { + "name": "last_processed_block", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_channel_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_channels_user_id": { + "name": "idx_notification_channels_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_channels_user_id_is_default": { + "name": "idx_notification_channels_user_id_is_default", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true and deleted_at is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.alert_status": { + "name": "alert_status", + "schema": "public", + "values": [ + "OK", + "TRIGGERED" + ] + }, + "public.alert_type": { + "name": "alert_type", + "schema": "public", + "values": [ + "CHAIN_MESSAGE", + "DEPLOYMENT_BALANCE", + "CHAIN_EVENT", + "WALLET_BALANCE" + ] + }, + "public.provider_tier_demotion_notification_status": { + "name": "provider_tier_demotion_notification_status", + "schema": "public", + "values": [ + "PENDING", + "SENT" + ] + }, + "public.notification_channel_type": { + "name": "notification_channel_type", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/notifications/drizzle/meta/_journal.json b/apps/notifications/drizzle/meta/_journal.json index 78ab5e1355..3bd011cb22 100644 --- a/apps/notifications/drizzle/meta/_journal.json +++ b/apps/notifications/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1762440232870, "tag": "0005_left_magus", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787637958458, + "tag": "0006_romantic_sasquatch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/notifications/package.json b/apps/notifications/package.json index 0bff23216b..7e4362215b 100644 --- a/apps/notifications/package.json +++ b/apps/notifications/package.json @@ -36,7 +36,7 @@ "test:watch": "vitest" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", "@akashnetwork/instrumentation": "*", diff --git a/apps/notifications/src/common/config/event-key-registry.config.ts b/apps/notifications/src/common/config/event-key-registry.config.ts index e5009dc311..53f8102257 100644 --- a/apps/notifications/src/common/config/event-key-registry.config.ts +++ b/apps/notifications/src/common/config/event-key-registry.config.ts @@ -2,5 +2,6 @@ export const eventKeyRegistry = { blockCreated: "blockchain.v1.block.created", eventCloseDeployment: "akash.v1.deployment.deployment-closed", eventLeaseReclaimStarted: "akash.v1.market.lease-reclaim-started", + eventProviderMaintenanceOpened: "akash.v1beta4.provider.provider-maintenance-opened", createNotification: "notifications.v1.notification.create" } as const; diff --git a/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts b/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts index 9154045cef..95fb3136c7 100644 --- a/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts +++ b/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts @@ -2,6 +2,7 @@ import type { eventKeyRegistry } from "@src/common/config/event-key-registry.con import type { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import type { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import type { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import type { AlertMessage } from "@src/modules/alert/types/message-callback.type"; export type EventToPayload = { @@ -9,4 +10,5 @@ export type EventToPayload = { [eventKeyRegistry.blockCreated]: ChainBlockCreatedDto; [eventKeyRegistry.eventCloseDeployment]: EventClosedDeploymentDto; [eventKeyRegistry.eventLeaseReclaimStarted]: EventLeaseReclaimStartedDto; + [eventKeyRegistry.eventProviderMaintenanceOpened]: EventProviderMaintenanceOpenedDto; }; diff --git a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts index 11cd72e021..1a296fd9f3 100644 --- a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts +++ b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts @@ -9,8 +9,10 @@ import { BrokerService } from "@src/infrastructure/broker"; import { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; import { DeploymentBalanceAlertsService } from "@src/modules/alert/services/deployment-balance-alerts/deployment-balance-alerts.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; import { ChainEventsHandler } from "./chain-events.handler"; @@ -65,6 +67,20 @@ describe(ChainEventsHandler.name, () => { }); }); + describe("processProviderMaintenanceOpened", () => { + it("routes provider maintenance through the notification broker", async () => { + const { controller, providerMaintenanceAlertService, brokerService } = await setup(); + const event = generateMock(EventProviderMaintenanceOpenedDto.schema); + const alertMessage = generateAlertMessage({}); + providerMaintenanceAlertService.alertFor.mockImplementation((_, callback) => callback(alertMessage)); + + await controller.processProviderMaintenanceOpened(event); + + expect(providerMaintenanceAlertService.alertFor).toHaveBeenCalledWith(event, expect.any(Function)); + expect(brokerService.publish).toHaveBeenCalledWith(eventKeyRegistry.createNotification, alertMessage); + }); + }); + async function setup() { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -73,7 +89,8 @@ describe(ChainEventsHandler.name, () => { MockProvider(ChainAlertService), MockProvider(DeploymentBalanceAlertsService), MockProvider(WalletBalanceAlertsService), - MockProvider(ReclaimAlertService) + MockProvider(ReclaimAlertService), + MockProvider(ProviderMaintenanceAlertService) ] }).compile(); @@ -83,6 +100,7 @@ describe(ChainEventsHandler.name, () => { deploymentBalanceAlertsService: module.get>(DeploymentBalanceAlertsService), walletBalanceAlertsService: module.get>(WalletBalanceAlertsService), reclaimAlertService: module.get>(ReclaimAlertService), + providerMaintenanceAlertService: module.get>(ProviderMaintenanceAlertService), brokerService: module.get>(BrokerService) }; } diff --git a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts index af81b3061f..41655aae8c 100644 --- a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts +++ b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts @@ -5,7 +5,9 @@ import { BrokerService, Handler } from "@src/infrastructure/broker"; import { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; @@ -15,6 +17,7 @@ export class ChainEventsHandler { private readonly chainMessageAlertService: ChainAlertService, private readonly walletBalanceAlertsService: WalletBalanceAlertsService, private readonly reclaimAlertService: ReclaimAlertService, + private readonly providerMaintenanceAlertService: ProviderMaintenanceAlertService, private readonly brokerService: BrokerService ) {} @@ -43,4 +46,12 @@ export class ChainEventsHandler { async processLeaseReclaimStarted(payload: EventLeaseReclaimStartedDto): Promise { await this.reclaimAlertService.alertFor(payload, message => this.brokerService.publish(eventKeyRegistry.createNotification, message)); } + + @Handler({ + key: eventKeyRegistry.eventProviderMaintenanceOpened, + dto: EventProviderMaintenanceOpenedDto + }) + async processProviderMaintenanceOpened(payload: EventProviderMaintenanceOpenedDto): Promise { + await this.providerMaintenanceAlertService.alertFor(payload, message => this.brokerService.publish(eventKeyRegistry.createNotification, message)); + } } diff --git a/apps/notifications/src/modules/alert/alert.module.ts b/apps/notifications/src/modules/alert/alert.module.ts index d5c7139b69..852fce9ebe 100644 --- a/apps/notifications/src/modules/alert/alert.module.ts +++ b/apps/notifications/src/modules/alert/alert.module.ts @@ -10,8 +10,13 @@ import { register } from "@src/infrastructure/db/db.module"; import type { FullSchema } from "@src/infrastructure/db/full-schema"; import { DbHealthzService } from "@src/infrastructure/db/services/db-healthz/db-healthz.service"; import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; import { DeploymentAlertService } from "@src/modules/alert/services/deployment-alert/deployment-alert.service"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; +import { ProviderTierDemotionAlertService } from "@src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; import { HTTP_SDK_PROVIDERS } from "./providers/http-sdk.provider"; @@ -36,6 +41,11 @@ import * as schema from "./model-schemas"; TemplateService, DeploymentAlertService, ReclaimAlertService, + ProviderActiveLeasesService, + ProviderMaintenanceAlertService, + ProviderTierDemotionRepository, + ProviderTierDemotionFeedService, + ProviderTierDemotionAlertService, DbHealthzService, ...HTTP_SDK_PROVIDERS ], @@ -46,6 +56,7 @@ import * as schema from "./model-schemas"; AlertRepository, DeploymentAlertService, ReclaimAlertService, + ProviderMaintenanceAlertService, DbHealthzService ] }) diff --git a/apps/notifications/src/modules/alert/config/env.config.spec.ts b/apps/notifications/src/modules/alert/config/env.config.spec.ts new file mode 100644 index 0000000000..4c8e017a8a --- /dev/null +++ b/apps/notifications/src/modules/alert/config/env.config.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { schema } from "@src/modules/alert/config/env.config"; + +const baseEnv = { + API_NODE_ENDPOINT: "https://rpc.akt.dev/rest", + CONSOLE_WEB_URL: "console.akash.network" +}; + +describe("alert environment config", () => { + it("disables maintenance alerts by default", () => { + expect(schema.parse(baseEnv).PROVIDER_MAINTENANCE_ALERTS_ENABLED).toBe(false); + }); + + it("enables maintenance alerts without a Console API dependency", () => { + const result = schema.parse({ ...baseEnv, PROVIDER_MAINTENANCE_ALERTS_ENABLED: "true" }); + + expect(result.PROVIDER_MAINTENANCE_ALERTS_ENABLED).toBe(true); + }); + + it("disables provider tier-demotion alerts by default", () => { + expect(schema.parse(baseEnv).PROVIDER_TIER_DEMOTION_ALERTS_ENABLED).toBe(false); + }); + + it("requires the Console API only when provider tier-demotion alerts are enabled", () => { + expect(() => schema.parse({ ...baseEnv, PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: "true" })).toThrow("CONSOLE_API_ENDPOINT is required"); + + const result = schema.parse({ + ...baseEnv, + CONSOLE_API_ENDPOINT: "https://api.akash.network", + PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: "true" + }); + expect(result.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED).toBe(true); + }); +}); diff --git a/apps/notifications/src/modules/alert/config/env.config.ts b/apps/notifications/src/modules/alert/config/env.config.ts index 11234d17b4..b150ed2bb3 100644 --- a/apps/notifications/src/modules/alert/config/env.config.ts +++ b/apps/notifications/src/modules/alert/config/env.config.ts @@ -1,9 +1,33 @@ import { z } from "zod"; -export const schema = z.object({ - API_NODE_ENDPOINT: z.string(), - CONSOLE_WEB_URL: z.string(), - DEPLOYMENT_BALANCE_BLOCKS_THROTTLE: z.number({ coerce: true }).optional().default(10) -}); +export const schema = z + .object({ + API_NODE_ENDPOINT: z.string(), + CONSOLE_API_ENDPOINT: z.string().url().optional(), + CONSOLE_API_SECRET_TOKEN: z.string().optional(), + CONSOLE_WEB_URL: z.string(), + DEPLOYMENT_BALANCE_BLOCKS_THROTTLE: z.number({ coerce: true }).optional().default(10), + PROVIDER_MAINTENANCE_ALERTS_ENABLED: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform(value => value === "true"), + PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform(value => value === "true"), + PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS: z.number({ coerce: true }).int().positive().optional().default(15000), + PROVIDER_TIER_DEMOTION_PAGE_SIZE: z.number({ coerce: true }).int().min(1).max(100).optional().default(100) + }) + .superRefine((env, context) => { + if (env.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED && !env.CONSOLE_API_ENDPOINT) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["CONSOLE_API_ENDPOINT"], + message: "CONSOLE_API_ENDPOINT is required when provider tier-demotion alerts are enabled" + }); + } + }); export type AlertEnvConfig = z.infer; diff --git a/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts b/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts new file mode 100644 index 0000000000..ecfe64c2a9 --- /dev/null +++ b/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts @@ -0,0 +1,15 @@ +import { createZodDto } from "nestjs-zod"; +import { z } from "zod"; + +const EventProviderMaintenanceOpenedSchema = z.object({ + module: z.literal("provider"), + action: z.literal("provider-maintenance-opened"), + maintenance_id: z.union([z.string(), z.number()]).transform(String), + provider: z.string(), + maintenance_type: z.union([z.string(), z.number()]).transform(String), + starts_at: z.string().datetime(), + expected_ends_at: z.string().datetime(), + metadata_hash: z.string().optional() +}); + +export class EventProviderMaintenanceOpenedDto extends createZodDto(EventProviderMaintenanceOpenedSchema) {} diff --git a/apps/notifications/src/modules/alert/model-schemas/index.ts b/apps/notifications/src/modules/alert/model-schemas/index.ts index 5ad17470b6..dc85341b9b 100644 --- a/apps/notifications/src/modules/alert/model-schemas/index.ts +++ b/apps/notifications/src/modules/alert/model-schemas/index.ts @@ -1 +1,2 @@ export * from "./alert.schema"; +export * from "./provider-tier-demotion.schema"; diff --git a/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts b/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts new file mode 100644 index 0000000000..7658a3d05d --- /dev/null +++ b/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts @@ -0,0 +1,58 @@ +import { sql } from "drizzle-orm"; +import { bigint, index, integer, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; + +import { timestamps } from "@src/lib/db/timestamps"; +import { Alert } from "./alert.schema"; + +export const ProviderTierDemotionNotificationStatus = pgEnum("provider_tier_demotion_notification_status", ["PENDING", "SENT"]); + +export const ProviderTierDemotionState = pgTable("provider_tier_demotion_state", { + id: integer("id").primaryKey().notNull().default(1), + streamId: uuid("stream_id"), + cursor: bigint("cursor", { mode: "bigint" }) + .notNull() + .default(sql`0`), + claimId: uuid("claim_id"), + claimExpiresAt: timestamp("claim_expires_at", { withTimezone: true }), + ...timestamps +}); + +export const ProviderTierDemotionNotification = pgTable( + "provider_tier_demotion_notifications", + { + id: uuid("id") + .primaryKey() + .notNull() + .default(sql`uuid_generate_v4()`), + streamId: uuid("stream_id").notNull(), + cursor: bigint("cursor", { mode: "bigint" }).notNull(), + alertId: uuid("alert_id") + .notNull() + .references(() => Alert.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + owner: text("owner").notNull(), + dseq: text("dseq").notNull(), + gseq: integer("gseq").notNull(), + oseq: integer("oseq").notNull(), + bseq: integer("bseq").notNull(), + status: ProviderTierDemotionNotificationStatus("status").notNull().default("PENDING"), + claimId: uuid("claim_id").notNull(), + claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(), + sentAt: timestamp("sent_at", { withTimezone: true }), + ...timestamps + }, + table => [ + uniqueIndex("provider_tier_demotion_notifications_delivery_uidx").on( + table.streamId, + table.cursor, + table.alertId, + table.owner, + table.dseq, + table.gseq, + table.oseq, + table.bseq, + table.provider + ), + index("provider_tier_demotion_notifications_status_idx").on(table.status, table.claimedAt) + ] +); diff --git a/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts b/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts index 8c89d53bdb..316d077128 100644 --- a/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts +++ b/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts @@ -46,11 +46,24 @@ export const walletBalanceParamsSchema = z.object({ suppressedBySystem: z.boolean().optional() }); +const providerMaintenanceNotificationSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("pending"), + claimId: z.string().uuid(), + claimedAt: z.string() + }), + z.object({ + status: z.literal("sent"), + sentAt: z.string() + }) +]); + export const generalParamsSchema = z.object({ dseq: dseqSchema, type: z.string(), suppressedBySystem: z.boolean().optional(), - reclaimNotifiedAt: z.string().optional() + reclaimNotifiedAt: z.string().optional(), + providerMaintenanceNotifications: z.record(providerMaintenanceNotificationSchema).optional() }); export const chainMessageTypeSchema = z.literal("CHAIN_MESSAGE"); diff --git a/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts b/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts index 3d563cf895..9c3ff3caa3 100644 --- a/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts +++ b/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts @@ -6,9 +6,11 @@ import { and, count, eq, gt, lte, ne, or, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { SQL } from "drizzle-orm/sql/sql"; import difference from "lodash/difference"; +import { randomUUID } from "node:crypto"; import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; import { NotificationChannel } from "@src/modules/notifications/model-schemas"; import * as schema from "../../model-schemas"; import type { DeploymentBalanceJsonFields, GeneralJsonFields, WalletBalanceJsonFields } from "./alert-json-fields.schema"; @@ -81,6 +83,11 @@ export interface FindAllDeploymentAlertsConditions { includeSuppressed?: boolean; } +export interface ProviderMaintenanceNotificationClaim { + alert: AlertOutput; + claimId: string; +} + /** * The per-deployment escrow-balance alert is retired: the block worker no longer evaluates it. * Rows created before the retirement still exist, so they must stay out of anything a user can @@ -88,6 +95,7 @@ export interface FindAllDeploymentAlertsConditions { * while being invisible in the UI. */ const RETIRED_ALERT_TYPE: AlertType = "DEPLOYMENT_BALANCE"; +const PROVIDER_MAINTENANCE_CLAIM_TIMEOUT_MS = 5 * 60 * 1000; @Injectable() export class AlertRepository { @@ -220,6 +228,97 @@ export class AlertRepository { }); } + async claimProviderMaintenanceNotification( + id: string, + provider: string, + maintenanceId: string, + lease: ProviderLeaseId + ): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + const claimId = randomUUID(); + + return this.db.transaction(async transaction => { + const [alert] = await transaction + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) + || jsonb_build_object( + ${notificationKey}::text, + jsonb_build_object('status', 'pending', 'claimId', ${claimId}::text, 'claimedAt', NOW()) + ) + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`NOT (COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) ? ${notificationKey}::text) + OR ( + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending' + AND ( + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimedAt' + )::timestamptz <= NOW() - (${PROVIDER_MAINTENANCE_CLAIM_TIMEOUT_MS} * INTERVAL '1 millisecond') + )` + ) + ) + .returning(); + + return alert && { alert: this.toOutput(alert), claimId }; + }); + } + + async completeProviderMaintenanceNotification(id: string, provider: string, maintenanceId: string, lease: ProviderLeaseId, claimId: string): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + + await this.db + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) + || jsonb_build_object(${notificationKey}::text, jsonb_build_object('status', 'sent', 'sentAt', NOW())) + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending'`, + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimId' = ${claimId}::text` + ) + ); + } + + async releaseProviderMaintenanceNotification(id: string, provider: string, maintenanceId: string, lease: ProviderLeaseId, claimId: string): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + + await this.db + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) - ${notificationKey}::text + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending'`, + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimId' = ${claimId}::text` + ) + ); + } + + private toProviderMaintenanceNotificationKey(provider: string, maintenanceId: string, lease: ProviderLeaseId): string { + return [provider, maintenanceId, lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.bseq, lease.provider].join("/"); + } + async deleteOneById(id: string): Promise { return this.db.transaction(async transaction => { const [alert] = await transaction diff --git a/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts b/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts new file mode 100644 index 0000000000..69753843ee --- /dev/null +++ b/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts @@ -0,0 +1,191 @@ +import { InjectDrizzle } from "@knaadh/nestjs-drizzle-pg"; +import { Injectable } from "@nestjs/common"; +import { and, eq, isNull, lt, or } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { randomUUID } from "node:crypto"; + +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import * as schema from "@src/modules/alert/model-schemas"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +const FEED_CLAIM_TTL_MS = 15 * 60 * 1000; +const DELIVERY_CLAIM_TTL_MS = FEED_CLAIM_TTL_MS; + +export interface ProviderTierDemotionFeedClaim { + claimId: string; + streamId: string | null; + cursor: string; +} + +export interface ProviderTierDemotionDelivery { + streamId: string; + cursor: string; + alertId: string; + provider: string; + lease: ProviderLeaseId; +} + +export type ProviderTierDemotionDeliveryClaim = { status: "claimed"; claimId: string } | { status: "sent" } | { status: "busy" }; + +@Injectable() +export class ProviderTierDemotionRepository { + constructor( + @InjectDrizzle(DRIZZLE_PROVIDER_TOKEN) + private readonly db: NodePgDatabase + ) {} + + async claimFeed(): Promise { + await this.db.insert(schema.ProviderTierDemotionState).values({ id: 1 }).onConflictDoNothing(); + + const claimId = randomUUID(); + const now = new Date(); + const [state] = await this.db + .update(schema.ProviderTierDemotionState) + .set({ + claimId, + claimExpiresAt: new Date(now.getTime() + FEED_CLAIM_TTL_MS), + updatedAt: now + }) + .where( + and( + eq(schema.ProviderTierDemotionState.id, 1), + or(isNull(schema.ProviderTierDemotionState.claimExpiresAt), lt(schema.ProviderTierDemotionState.claimExpiresAt, now)) + ) + ) + .returning(); + + return ( + state && { + claimId, + streamId: state.streamId, + cursor: state.cursor.toString() + } + ); + } + + async setFeedPosition(claimId: string, streamId: string, cursor: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionState) + .set({ streamId, cursor: BigInt(cursor), updatedAt: new Date() }) + .where(and(eq(schema.ProviderTierDemotionState.id, 1), eq(schema.ProviderTierDemotionState.claimId, claimId))) + .returning({ id: schema.ProviderTierDemotionState.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion feed claim was lost"); + } + + async advanceFeed(claimId: string, streamId: string, cursor: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionState) + .set({ cursor: BigInt(cursor), updatedAt: new Date() }) + .where( + and( + eq(schema.ProviderTierDemotionState.id, 1), + eq(schema.ProviderTierDemotionState.claimId, claimId), + eq(schema.ProviderTierDemotionState.streamId, streamId), + lt(schema.ProviderTierDemotionState.cursor, BigInt(cursor)) + ) + ) + .returning({ id: schema.ProviderTierDemotionState.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion feed claim was lost"); + } + + async releaseFeed(claimId: string): Promise { + await this.db + .update(schema.ProviderTierDemotionState) + .set({ claimId: null, claimExpiresAt: null, updatedAt: new Date() }) + .where(and(eq(schema.ProviderTierDemotionState.id, 1), eq(schema.ProviderTierDemotionState.claimId, claimId))); + } + + async claimDelivery(delivery: ProviderTierDemotionDelivery): Promise { + const claimId = randomUUID(); + const values = this.deliveryValues(delivery, claimId); + const inserted = await this.db + .insert(schema.ProviderTierDemotionNotification) + .values(values) + .onConflictDoNothing() + .returning({ claimId: schema.ProviderTierDemotionNotification.claimId }); + + if (inserted[0]) return { status: "claimed", claimId: inserted[0].claimId }; + + const staleBefore = new Date(Date.now() - DELIVERY_CLAIM_TTL_MS); + const [reclaimed] = await this.db + .update(schema.ProviderTierDemotionNotification) + .set({ claimId, claimedAt: new Date(), updatedAt: new Date() }) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + lt(schema.ProviderTierDemotionNotification.claimedAt, staleBefore) + ) + ) + .returning({ claimId: schema.ProviderTierDemotionNotification.claimId }); + + if (reclaimed) return { status: "claimed", claimId: reclaimed.claimId }; + + const existing = await this.db.query.ProviderTierDemotionNotification.findFirst({ + columns: { status: true }, + where: this.deliveryWhere(delivery) + }); + if (!existing) throw new Error("Provider tier-demotion delivery disappeared while being claimed"); + + return existing.status === "SENT" ? { status: "sent" } : { status: "busy" }; + } + + async completeDelivery(delivery: ProviderTierDemotionDelivery, claimId: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionNotification) + .set({ status: "SENT", sentAt: new Date(), updatedAt: new Date() }) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + eq(schema.ProviderTierDemotionNotification.claimId, claimId) + ) + ) + .returning({ id: schema.ProviderTierDemotionNotification.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion delivery claim was lost"); + } + + async releaseDelivery(delivery: ProviderTierDemotionDelivery, claimId: string): Promise { + await this.db + .delete(schema.ProviderTierDemotionNotification) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + eq(schema.ProviderTierDemotionNotification.claimId, claimId) + ) + ); + } + + private deliveryValues(delivery: ProviderTierDemotionDelivery, claimId: string): typeof schema.ProviderTierDemotionNotification.$inferInsert { + return { + streamId: delivery.streamId, + cursor: BigInt(delivery.cursor), + alertId: delivery.alertId, + provider: delivery.provider, + owner: delivery.lease.owner, + dseq: delivery.lease.dseq, + gseq: delivery.lease.gseq, + oseq: delivery.lease.oseq, + bseq: delivery.lease.bseq, + claimId + }; + } + + private deliveryWhere(delivery: ProviderTierDemotionDelivery) { + return and( + eq(schema.ProviderTierDemotionNotification.streamId, delivery.streamId), + eq(schema.ProviderTierDemotionNotification.cursor, BigInt(delivery.cursor)), + eq(schema.ProviderTierDemotionNotification.alertId, delivery.alertId), + eq(schema.ProviderTierDemotionNotification.provider, delivery.provider), + eq(schema.ProviderTierDemotionNotification.owner, delivery.lease.owner), + eq(schema.ProviderTierDemotionNotification.dseq, delivery.lease.dseq), + eq(schema.ProviderTierDemotionNotification.gseq, delivery.lease.gseq), + eq(schema.ProviderTierDemotionNotification.oseq, delivery.lease.oseq), + eq(schema.ProviderTierDemotionNotification.bseq, delivery.lease.bseq) + ); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts new file mode 100644 index 0000000000..3d62a117cb --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts @@ -0,0 +1,106 @@ +import type { HttpClient, RestAkashLeaseListResponse, RpcLease } from "@akashnetwork/http-sdk"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { CHAIN_API_HTTP_CLIENT_TOKEN } from "@src/modules/alert/providers/http-sdk.provider"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; + +describe(ProviderActiveLeasesService.name, () => { + it("paginates provider-filtered chain leases and keeps active and reclaiming workloads", async () => { + const { service, chainApi } = await setup(); + chainApi.get + .mockResolvedValueOnce({ + data: chainLeases( + [ + lease({ owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3 }, "active"), + lease({ owner: "akash1owner2", dseq: "200", gseq: 2, oseq: 1, bseq: 4 }, "closed") + ], + "next-page" + ) + }) + .mockResolvedValueOnce({ + data: chainLeases([ + lease({ owner: "akash1owner3", dseq: "300", gseq: 3, oseq: 1, bseq: 7 }, "reclaiming"), + lease({ owner: "akash1owner4", dseq: "400", gseq: 4, oseq: 1, bseq: 8 }, "insufficient_funds") + ]) + }); + + const result = await service.list(PROVIDER, 2); + + expect(chainApi.get).toHaveBeenNthCalledWith(1, "/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": PROVIDER, + "pagination.limit": 2, + "pagination.key": undefined + }, + timeout: 30000 + }); + expect(chainApi.get).toHaveBeenNthCalledWith(2, "/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": PROVIDER, + "pagination.limit": 2, + "pagination.key": "next-page" + }, + timeout: 30000 + }); + expect(result).toEqual([ + { owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3, provider: PROVIDER }, + { owner: "akash1owner3", dseq: "300", gseq: 3, oseq: 1, bseq: 7, provider: PROVIDER } + ]); + }); + + it("stops after an empty page even when a malformed response includes a continuation key", async () => { + const { service, chainApi } = await setup(); + chainApi.get.mockResolvedValue({ data: chainLeases([], "unexpected-next-page") }); + + await expect(service.list(PROVIDER)).resolves.toEqual([]); + expect(chainApi.get).toHaveBeenCalledTimes(1); + }); + + async function setup() { + const module = await Test.createTestingModule({ + providers: [ProviderActiveLeasesService, { provide: CHAIN_API_HTTP_CLIENT_TOKEN, useValue: { get: vi.fn() } }] + }).compile(); + + return { + service: module.get(ProviderActiveLeasesService), + chainApi: module.get>(CHAIN_API_HTTP_CLIENT_TOKEN) + }; + } +}); + +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; + +function chainLeases(leases: RpcLease[], nextKey: string | null = null): RestAkashLeaseListResponse { + return { + leases, + pagination: { next_key: nextKey, total: String(leases.length) } + }; +} + +function lease( + input: { owner: string; dseq: string; gseq: number; oseq: number; bseq: number }, + state: "active" | "closed" | "insufficient_funds" | "reclaiming" +): RpcLease { + return { + lease: { + id: { ...input, provider: PROVIDER }, + state, + price: { denom: "uakt", amount: "1" }, + created_at: "1", + closed_on: "0" + }, + escrow_payment: { + id: { aid: { scope: "deployment", xid: input.dseq }, xid: "1" }, + state: { + owner: input.owner, + state: "open", + rate: { denom: "uakt", amount: "1" }, + balance: { denom: "uakt", amount: "1" }, + unsettled: { denom: "uakt", amount: "0" }, + withdrawn: { denom: "uakt", amount: "0" } + } + } + }; +} diff --git a/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts new file mode 100644 index 0000000000..ae9f66e3ce --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts @@ -0,0 +1,35 @@ +import type { HttpClient, RestAkashLeaseListResponse, RpcLease } from "@akashnetwork/http-sdk"; +import { extractData, isLeaseLive } from "@akashnetwork/http-sdk"; +import { Inject, Injectable } from "@nestjs/common"; + +import { CHAIN_API_HTTP_CLIENT_TOKEN } from "@src/modules/alert/providers/http-sdk.provider"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +@Injectable() +export class ProviderActiveLeasesService { + constructor(@Inject(CHAIN_API_HTTP_CLIENT_TOKEN) private readonly chainApi: HttpClient) {} + + async list(provider: string, pageSize = 100): Promise { + const leases: RpcLease[] = []; + let key: string | undefined; + + do { + const page = extractData( + await this.chainApi.get("/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": provider, + "pagination.limit": pageSize, + "pagination.key": key + }, + timeout: 30000 + }) + ); + leases.push(...page.leases); + + if (page.leases.length === 0) break; + key = page.pagination.next_key ?? undefined; + } while (key); + + return leases.filter(({ lease }) => isLeaseLive(lease)).map(({ lease }) => ({ ...lease.id })); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts new file mode 100644 index 0000000000..f00d15fb5d --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts @@ -0,0 +1,128 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; + +import { MockProvider } from "@test/mocks/provider.mock"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; + +describe(ProviderMaintenanceAlertService.name, () => { + it("does nothing when maintenance notifications are disabled", async () => { + const { service, activeLeases, onMessage } = await setup({ enabled: false }); + + await service.alertFor(EVENT, onMessage); + + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("notifies an active lease through its enabled deployment alert channel", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue({ alert, claimId: CLAIM_ID }); + + await service.alertFor(EVENT, onMessage); + + expect(alertRepository.claimProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE); + expect(onMessage).toHaveBeenCalledWith({ + notificationChannelId: alert.notificationChannelId, + payload: { + summary: "Provider maintenance scheduled for deployment 100", + description: expect.stringContaining("The lease remains open") + } + }); + expect(alertRepository.completeProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE, CLAIM_ID); + }); + + it("skips leases without an enabled deployment notification", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: false })); + + await service.alertFor(EVENT, onMessage); + + expect(alertRepository.claimProviderMaintenanceNotification).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("skips a replay when the maintenance and lease tuple is already claimed", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue(undefined); + + await service.alertFor(EVENT, onMessage); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("releases the claim when publishing fails so the broker can retry", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue({ alert, claimId: CLAIM_ID }); + onMessage.mockRejectedValue(new Error("publish failed")); + + await expect(service.alertFor(EVENT, onMessage)).rejects.toThrow("publish failed"); + + expect(alertRepository.releaseProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE, CLAIM_ID); + expect(alertRepository.completeProviderMaintenanceNotification).not.toHaveBeenCalled(); + }); + + async function setup({ enabled = true }: { enabled?: boolean } = {}) { + const configService = { + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.PROVIDER_MAINTENANCE_ALERTS_ENABLED") return enabled; + if (key === "alert.CONSOLE_WEB_URL") return "console.akash.network"; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ + ProviderMaintenanceAlertService, + ProviderActiveLeasesService, + MockProvider(AlertRepository), + MockProvider(ProviderActiveLeasesService), + MockProvider(LoggerService), + { provide: ConfigService, useValue: configService } + ] + }).compile(); + + return { + service: module.get(ProviderMaintenanceAlertService), + activeLeases: module.get>(ProviderActiveLeasesService), + alertRepository: module.get>(AlertRepository), + onMessage: vi.fn() + }; + } +}); + +const EVENT = { + module: "provider" as const, + action: "provider-maintenance-opened" as const, + maintenance_id: "17", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z" +}; + +const LEASE = { + owner: "akash1owner1", + dseq: "100", + gseq: 1, + oseq: 1, + bseq: 3, + provider: EVENT.provider +}; + +const CLAIM_ID = "b88f6777-7885-41b9-81ca-a601ea4d72f8"; diff --git a/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts new file mode 100644 index 0000000000..045ec547f2 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import type { AlertConfig } from "@src/modules/alert/config"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import type { MessageCallback } from "@src/modules/alert/types/message-callback.type"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +@Injectable() +export class ProviderMaintenanceAlertService { + constructor( + private readonly activeLeases: ProviderActiveLeasesService, + private readonly alertRepository: AlertRepository, + private readonly configService: ConfigService, + private readonly loggerService: LoggerService + ) { + this.loggerService.setContext(ProviderMaintenanceAlertService.name); + } + + async alertFor(event: EventProviderMaintenanceOpenedDto, onMessage: MessageCallback): Promise { + if (!this.configService.getOrThrow("alert.PROVIDER_MAINTENANCE_ALERTS_ENABLED")) return; + + const leases = await this.activeLeases.list(event.provider); + await Promise.all(leases.map(lease => this.alertForLease(event, lease, onMessage))); + } + + private async alertForLease(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId, onMessage: MessageCallback): Promise { + const alert = await this.alertRepository.findDeploymentClosedAlertByOwnerAndDseq(lease.owner, lease.dseq); + if (!alert?.enabled) return; + + const claim = await this.alertRepository.claimProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease); + if (!claim) return; + + try { + await onMessage({ + notificationChannelId: claim.alert.notificationChannelId, + payload: { + summary: `Provider maintenance scheduled for deployment ${lease.dseq}`, + description: this.description(event, lease) + } + }); + await this.alertRepository.completeProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease, claim.claimId); + } catch (error) { + await this.alertRepository.releaseProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease, claim.claimId); + throw error; + } + } + + private description(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId): string { + const type = event.maintenance_type.replace(/^provider_maintenance_type_/, "").replaceAll("_", " "); + const baseUrl = this.configService.getOrThrow("alert.CONSOLE_WEB_URL"); + const link = `${baseUrl}`; + + return ( + `Provider ${event.provider} announced ${type} maintenance for lease group ${lease.gseq}/${lease.oseq}. ` + + `The window starts at ${event.starts_at} and is expected to end at ${event.expected_ends_at}. ` + + `The lease remains open. Please visit ${link} to review the deployment.` + ); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts new file mode 100644 index 0000000000..c1b1b681f5 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts @@ -0,0 +1,207 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import { BrokerService } from "@src/infrastructure/broker"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderTierDemotionAlertService } from "@src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; +import type { ProviderTierDemotionFeed } from "@src/modules/alert/types/provider-tier-demotion.type"; + +import { MockProvider } from "@test/mocks/provider.mock"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; + +describe(ProviderTierDemotionAlertService.name, () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("does not poll when the feature is disabled", async () => { + const { service, repository, feedService } = await setup({ enabled: false }); + + await service.processNextPage(); + + expect(repository.claimFeed).not.toHaveBeenCalled(); + expect(feedService.get).not.toHaveBeenCalled(); + }); + + it.each([ + ["first observation", null], + ["stream reset", OLD_STREAM_ID] + ])("moves to the feed head without alerting on %s", async (_, streamId) => { + const { service, repository, feedService, activeLeases, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue({ claimId: FEED_CLAIM_ID, streamId, cursor: "0" }); + feedService.get.mockResolvedValue(FEED); + + await service.processNextPage(); + + expect(repository.setFeedPosition).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "12"); + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(brokerService.publish).not.toHaveBeenCalled(); + expect(repository.releaseFeed).toHaveBeenCalledWith(FEED_CLAIM_ID); + }); + + it("silently advances to the head while the verification module is inactive", async () => { + const { service, repository, feedService, activeLeases, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + feedService.get.mockResolvedValue({ ...FEED, moduleActive: false }); + + await service.processNextPage(); + + expect(repository.setFeedPosition).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "12"); + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(brokerService.publish).not.toHaveBeenCalled(); + }); + + it("publishes once for an active lease and advances only after delivery completes", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "claimed", claimId: DELIVERY_CLAIM_ID }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + + await service.processNextPage(); + + const delivery = { + streamId: STREAM_ID, + cursor: "11", + alertId: alert.id, + provider: PROVIDER, + lease: LEASE + }; + expect(repository.claimDelivery).toHaveBeenCalledWith(delivery); + expect(brokerService.publish).toHaveBeenCalledWith( + "notifications.v1.notification.create", + { + notificationChannelId: alert.notificationChannelId, + payload: { + summary: "Provider verification changed for deployment 100", + description: expect.stringContaining("The existing lease remains open") + } + }, + { id: expect.stringMatching(/^[0-9a-f-]{36}$/) } + ); + expect(repository.completeDelivery).toHaveBeenCalledWith(delivery, DELIVERY_CLAIM_ID); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + expect(repository.completeDelivery.mock.invocationCallOrder[0]).toBeLessThan(repository.advanceFeed.mock.invocationCallOrder[0]); + }); + + it("skips a delivery already marked sent and still advances the cursor", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "sent" }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + + await service.processNextPage(); + + expect(brokerService.publish).not.toHaveBeenCalled(); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + }); + + it("does not advance when another worker still owns a delivery", async () => { + const { service, repository, feedService, activeLeases, alertRepository } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "busy" }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + + await expect(service.processNextPage()).rejects.toThrow("already being processed"); + + expect(repository.advanceFeed).not.toHaveBeenCalled(); + expect(repository.releaseFeed).toHaveBeenCalledWith(FEED_CLAIM_ID); + }); + + it("releases a failed delivery, keeps the cursor, and reuses the broker job id on retry", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValueOnce({ status: "claimed", claimId: DELIVERY_CLAIM_ID }).mockResolvedValueOnce({ + status: "claimed", + claimId: SECOND_DELIVERY_CLAIM_ID + }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + brokerService.publish.mockRejectedValueOnce(new Error("publish failed")).mockResolvedValueOnce(); + + await expect(service.processNextPage()).rejects.toThrow("publish failed"); + expect(repository.advanceFeed).not.toHaveBeenCalled(); + expect(repository.releaseDelivery).toHaveBeenCalledWith(expect.anything(), DELIVERY_CLAIM_ID); + + await service.processNextPage(); + + expect(brokerService.publish).toHaveBeenCalledTimes(2); + expect(brokerService.publish.mock.calls[0][2]?.id).toBe(brokerService.publish.mock.calls[1][2]?.id); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + }); + + async function setup({ enabled = true }: { enabled?: boolean } = {}) { + const configService = { + get: vi.fn(), + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED") return enabled; + if (key === "alert.CONSOLE_WEB_URL") return "console.akash.network"; + if (key === "alert.PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS") return 15000; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ + ProviderTierDemotionAlertService, + MockProvider(ProviderTierDemotionFeedService), + MockProvider(ProviderTierDemotionRepository), + MockProvider(ProviderActiveLeasesService), + MockProvider(AlertRepository), + MockProvider(BrokerService), + MockProvider(LoggerService), + { provide: ConfigService, useValue: configService } + ] + }).compile(); + + return { + service: module.get(ProviderTierDemotionAlertService), + feedService: module.get>(ProviderTierDemotionFeedService), + repository: module.get>(ProviderTierDemotionRepository), + activeLeases: module.get>(ProviderActiveLeasesService), + alertRepository: module.get>(AlertRepository), + brokerService: module.get>(BrokerService) + }; + } +}); + +const STREAM_ID = "5be32550-fbc2-4f02-9ac2-7d58f0362451"; +const OLD_STREAM_ID = "28d32c3f-38a0-43e5-a07a-d8c1d6b99203"; +const FEED_CLAIM_ID = "0d29d7ce-41fc-4c4a-bd33-a1bcbf296e4d"; +const DELIVERY_CLAIM_ID = "e6e6d36c-86c6-4da2-8b5a-86ca83b780ee"; +const SECOND_DELIVERY_CLAIM_ID = "9e4c6ef0-3577-4b56-98a7-ae6068f0e98e"; +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; + +const FEED_CLAIM = { claimId: FEED_CLAIM_ID, streamId: STREAM_ID, cursor: "10" }; +const LEASE = { owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3, provider: PROVIDER }; +const FEED: ProviderTierDemotionFeed = { + streamId: STREAM_ID, + headCursor: "12", + nextCursor: "11", + moduleActive: true, + items: [ + { + cursor: "11", + provider: PROVIDER, + previous: { effectiveTier: "L3", maxPlacementTier: "L3", snapshotState: "current" }, + current: { effectiveTier: "L1", maxPlacementTier: "L1", snapshotState: "stale" }, + changes: ["tier_gate", "snapshot_eligibility"], + observedHeight: "12345", + observedAt: "2026-08-25T00:00:00.000Z" + } + ] +}; diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts new file mode 100644 index 0000000000..90d7d6480d --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts @@ -0,0 +1,171 @@ +import { Injectable, OnApplicationBootstrap, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +import { eventKeyRegistry } from "@src/common/config/event-key-registry.config"; +import { LoggerService } from "@src/common/services/logger/logger.service"; +import { BrokerService } from "@src/infrastructure/broker"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { + type ProviderTierDemotionDelivery, + ProviderTierDemotionRepository +} from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; +import type { ProviderTierDemotion } from "@src/modules/alert/types/provider-tier-demotion.type"; + +@Injectable() +export class ProviderTierDemotionAlertService implements OnApplicationBootstrap, OnModuleDestroy { + private readonly abortController = new AbortController(); + private polling?: Promise; + + constructor( + private readonly feedService: ProviderTierDemotionFeedService, + private readonly repository: ProviderTierDemotionRepository, + private readonly activeLeases: ProviderActiveLeasesService, + private readonly alertRepository: AlertRepository, + private readonly brokerService: BrokerService, + private readonly configService: ConfigService, + private readonly loggerService: LoggerService + ) { + this.loggerService.setContext(ProviderTierDemotionAlertService.name); + } + + onApplicationBootstrap(): void { + if (!this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED")) return; + + this.polling = this.pollLoop(); + } + + async processNextPage(signal: AbortSignal = this.abortController.signal): Promise { + if (!this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED")) return; + + const claim = await this.repository.claimFeed(); + if (!claim) return; + + try { + const feed = await this.feedService.get(claim.cursor, signal); + + if (!claim.streamId || claim.streamId !== feed.streamId || !feed.moduleActive) { + await this.repository.setFeedPosition(claim.claimId, feed.streamId, feed.headCursor); + return; + } + + let cursor = BigInt(claim.cursor); + for (const demotion of feed.items) { + const nextCursor = BigInt(demotion.cursor); + if (nextCursor <= cursor) throw new Error("Provider tier-demotion feed cursors must increase monotonically"); + + await this.processDemotion(feed.streamId, demotion); + await this.repository.advanceFeed(claim.claimId, feed.streamId, demotion.cursor); + cursor = nextCursor; + } + } finally { + await this.repository.releaseFeed(claim.claimId); + } + } + + async onModuleDestroy(): Promise { + this.abortController.abort(); + await this.polling; + } + + private async pollLoop(): Promise { + while (!this.abortController.signal.aborted) { + try { + await this.processNextPage(); + } catch (error) { + if (!this.abortController.signal.aborted) { + this.loggerService.error({ event: "PROVIDER_TIER_DEMOTION_POLL_FAILED", error }); + } + } + + await delay(this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS"), undefined, { + signal: this.abortController.signal + }).catch(error => (error?.name === "AbortError" ? undefined : Promise.reject(error))); + } + } + + private async processDemotion(streamId: string, demotion: ProviderTierDemotion): Promise { + const leases = await this.activeLeases.list(demotion.provider); + await Promise.all(leases.map(lease => this.processLease(streamId, demotion, lease))); + } + + private async processLease(streamId: string, demotion: ProviderTierDemotion, lease: ProviderLeaseId): Promise { + const alert = await this.alertRepository.findDeploymentClosedAlertByOwnerAndDseq(lease.owner, lease.dseq); + if (!alert?.enabled) return; + + const delivery: ProviderTierDemotionDelivery = { + streamId, + cursor: demotion.cursor, + alertId: alert.id, + provider: demotion.provider, + lease + }; + const claim = await this.repository.claimDelivery(delivery); + if (claim.status === "sent") return; + if (claim.status === "busy") throw new Error("Provider tier-demotion delivery is already being processed"); + + try { + await this.brokerService.publish( + eventKeyRegistry.createNotification, + { + notificationChannelId: alert.notificationChannelId, + payload: { + summary: `Provider verification changed for deployment ${lease.dseq}`, + description: this.description(demotion, lease) + } + }, + { id: this.deliveryId(delivery) } + ); + await this.repository.completeDelivery(delivery, claim.claimId); + } catch (error) { + await this.repository.releaseDelivery(delivery, claim.claimId); + throw error; + } + } + + private description(demotion: ProviderTierDemotion, lease: ProviderLeaseId): string { + const baseUrl = this.configService.getOrThrow("alert.CONSOLE_WEB_URL"); + const link = `${baseUrl}`; + const tierChange = + demotion.previous.effectiveTier === demotion.current.effectiveTier + ? `Provider ${demotion.provider} verification eligibility changed.` + : `Provider ${demotion.provider} verification tier changed from ${demotion.previous.effectiveTier} to ${demotion.current.effectiveTier}.`; + const changed = demotion.changes.includes("snapshot_eligibility") ? ` Snapshot eligibility is now ${demotion.current.snapshotState}.` : ""; + + return ( + `${tierChange} ` + + `New placements qualify up to ${demotion.current.maxPlacementTier}.${changed} ` + + `The existing lease remains open. Please visit ${link} to review the deployment.` + ); + } + + private deliveryId(delivery: ProviderTierDemotionDelivery): string { + const digest = createHash("sha256") + .update( + [ + "provider-tier-demotion", + delivery.streamId, + delivery.cursor, + delivery.alertId, + delivery.provider, + delivery.lease.owner, + delivery.lease.dseq, + delivery.lease.gseq, + delivery.lease.oseq, + delivery.lease.bseq + ].join("/") + ) + .digest() + .subarray(0, 16); + digest[6] = (digest[6] & 0x0f) | 0x50; + digest[8] = (digest[8] & 0x3f) | 0x80; + const hex = digest.toString("hex"); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts new file mode 100644 index 0000000000..8191436718 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts @@ -0,0 +1,72 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AlertConfig } from "@src/modules/alert/config"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; + +describe(ProviderTierDemotionFeedService.name, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests and validates the private cursor feed", async () => { + const service = await setup(); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(FEED), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(service.get("10")).resolves.toEqual(FEED); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://api.akash.network/internal/v1/provider-verification/tier-demotions?after=10&limit=50&token=private-token"); + expect(init).toMatchObject({ headers: { accept: "application/json" } }); + }); + + it("does not accept an unavailable feed as progress", async () => { + const service = await setup(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response('{"error":"provider_verification_not_ready"}', { status: 503 }))); + + await expect(service.get("10")).rejects.toThrow("HTTP 503"); + }); + + it("rejects malformed feed data at the HTTP boundary", async () => { + const service = await setup(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...FEED, nextCursor: "invalid" }), { status: 200 }))); + + await expect(service.get("10")).rejects.toThrow(); + }); + + async function setup() { + const configService = { + get: vi.fn((key: keyof AlertConfig) => (key === "alert.CONSOLE_API_SECRET_TOKEN" ? "private-token" : undefined)), + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.CONSOLE_API_ENDPOINT") return "https://api.akash.network"; + if (key === "alert.PROVIDER_TIER_DEMOTION_PAGE_SIZE") return 50; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ProviderTierDemotionFeedService, { provide: ConfigService, useValue: configService }] + }).compile(); + + return module.get(ProviderTierDemotionFeedService); + } +}); + +const FEED = { + streamId: "5be32550-fbc2-4f02-9ac2-7d58f0362451", + headCursor: "12", + nextCursor: "11", + moduleActive: true, + items: [ + { + cursor: "11", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + previous: { effectiveTier: "L3", maxPlacementTier: "L3", snapshotState: "current" }, + current: { effectiveTier: "L1", maxPlacementTier: "L1", snapshotState: "stale" }, + changes: ["tier_gate", "snapshot_eligibility"], + observedHeight: "12345", + observedAt: "2026-08-25T00:00:00.000Z" + } + ] +}; diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts new file mode 100644 index 0000000000..85d9f890e7 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import type { AlertConfig } from "@src/modules/alert/config"; +import { type ProviderTierDemotionFeed, ProviderTierDemotionFeedSchema } from "@src/modules/alert/types/provider-tier-demotion.type"; + +@Injectable() +export class ProviderTierDemotionFeedService { + constructor(private readonly configService: ConfigService) {} + + async get(after: string, signal?: AbortSignal): Promise { + const endpoint = new URL("/internal/v1/provider-verification/tier-demotions", this.configService.getOrThrow("alert.CONSOLE_API_ENDPOINT")); + endpoint.searchParams.set("after", after); + endpoint.searchParams.set("limit", String(this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_PAGE_SIZE"))); + + const token = this.configService.get("alert.CONSOLE_API_SECRET_TOKEN"); + if (token) endpoint.searchParams.set("token", token); + + const response = await fetch(endpoint, { + headers: { accept: "application/json" }, + signal + }); + + if (!response.ok) { + throw new Error(`Provider tier-demotion feed returned HTTP ${response.status}`); + } + + return ProviderTierDemotionFeedSchema.parse(await response.json()); + } +} diff --git a/apps/notifications/src/modules/alert/types/provider-lease.type.ts b/apps/notifications/src/modules/alert/types/provider-lease.type.ts new file mode 100644 index 0000000000..0361a1f8a9 --- /dev/null +++ b/apps/notifications/src/modules/alert/types/provider-lease.type.ts @@ -0,0 +1,8 @@ +export interface ProviderLeaseId { + owner: string; + dseq: string; + gseq: number; + oseq: number; + bseq: number; + provider: string; +} diff --git a/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts b/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts new file mode 100644 index 0000000000..1a5b8a7a8a --- /dev/null +++ b/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const UIntStringSchema = z.string().regex(/^\d+$/); +const TierSchema = z.enum(["L0", "L1", "L2", "L3", "L4", "unknown"]); +const SnapshotStateSchema = z.enum(["unknown", "not_posted", "current", "stale", "suspended"]); + +const TierStateSchema = z.object({ + effectiveTier: TierSchema, + maxPlacementTier: TierSchema, + snapshotState: SnapshotStateSchema +}); + +export const ProviderTierDemotionFeedSchema = z.object({ + streamId: z.string().uuid(), + headCursor: UIntStringSchema, + nextCursor: UIntStringSchema, + moduleActive: z.boolean(), + items: z.array( + z.object({ + cursor: UIntStringSchema, + provider: z.string().min(1), + previous: TierStateSchema, + current: TierStateSchema, + changes: z.array(z.enum(["tier_gate", "snapshot_eligibility"])), + observedHeight: UIntStringSchema, + observedAt: z.string().datetime() + }) + ) +}); + +export type ProviderTierDemotionFeed = z.infer; +export type ProviderTierDemotion = ProviderTierDemotionFeed["items"][number]; diff --git a/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts b/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts new file mode 100644 index 0000000000..f4352efdc0 --- /dev/null +++ b/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts @@ -0,0 +1,13 @@ +import type { Registry } from "@cosmjs/proto-signing"; +import type { FactoryProvider } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; + +import { RegistryProvider } from "./registry.provider"; + +describe("RegistryProvider", () => { + it("registers AEP-86 transaction types from the expanded SDK barrel", () => { + const registry = (RegistryProvider as FactoryProvider).useFactory(); + + expect(registry.lookupType("/akash.verification.v1.MsgSubmitAttestation")).toBeDefined(); + }); +}); diff --git a/apps/notifications/src/modules/chain/providers/registry.provider.ts b/apps/notifications/src/modules/chain/providers/registry.provider.ts index 529e0135b9..c777d47e1f 100644 --- a/apps/notifications/src/modules/chain/providers/registry.provider.ts +++ b/apps/notifications/src/modules/chain/providers/registry.provider.ts @@ -12,18 +12,16 @@ import type { Provider } from "@nestjs/common"; export const RegistryProvider: Provider = { provide: Registry, useFactory: () => { - const akashTypes: ReadonlyArray<[string, GeneratedType]> = [ - ...Object.values(v1), - ...Object.values(v1beta4), - ...Object.values(v1beta5), - ...Object.values(cosmosv1), - ...Object.values(cosmosv1beta1), - ...Object.values(cosmosv1alpha1), - ...Object.values(cosmosv2alpha1) - ] - .filter(x => "$type" in x) - .map(x => ["/" + x.$type, x as unknown as GeneratedType]); + const modules: ReadonlyArray> = [v1, v1beta4, v1beta5, cosmosv1, cosmosv1beta1, cosmosv1alpha1, cosmosv2alpha1]; + const akashTypes: ReadonlyArray<[string, GeneratedType]> = modules + .flatMap(module => Object.values(module)) + .filter(hasType) + .map(type => ["/" + type.$type, type as unknown as GeneratedType]); return new Registry(akashTypes); } }; + +function hasType(value: unknown): value is { $type: string } { + return typeof value === "object" && value !== null && "$type" in value && typeof value.$type === "string"; +} diff --git a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts index 6db8d4e175..61abe841a2 100644 --- a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts +++ b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts @@ -94,12 +94,32 @@ describe(ChainEventsPollerService.name, () => { CURRENT_HEIGHT + 1, expect.arrayContaining([ { module: "deployment", version: "v1", source: "akash", action: ["deployment-closed"] }, - { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] } + { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] }, + { module: "provider", version: "v1beta4", source: "akash", action: ["provider-maintenance-opened"] } ]), expect.any(AbortSignal) ); }); + it("does not commit the block cursor when event fetching fails", async () => { + const { service, blockCursorRepository, blockMessageService, txEventsService, CURRENT_HEIGHT } = await setup(); + const committedHeights: number[] = []; + blockMessageService.getMessages.mockResolvedValue(generateMockBlockData({ height: CURRENT_HEIGHT + 1, time: new Date().toISOString() })); + txEventsService.getBlockEvents.mockRejectedValue(new Error("block results unavailable")); + blockCursorRepository.getNextBlockForProcessing.mockImplementation(async callback => { + const block = await callback(CURRENT_HEIGHT + 1); + committedHeights.push(block.height); + return block; + }); + + service.onApplicationBootstrap(); + await delay(100); + await service.onModuleDestroy(); + + expect(blockCursorRepository.getNextBlockForProcessing).toHaveBeenCalled(); + expect(committedHeights).toEqual([]); + }); + it("retries instead of shutting down when block processing consistently fails", async () => { const { service, blockCursorRepository, blockMessageService, CURRENT_HEIGHT } = await setup(); diff --git a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts index 143df8c3f1..580de3920e 100644 --- a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts +++ b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts @@ -149,7 +149,8 @@ export class ChainEventsPollerService implements OnApplicationBootstrap, OnModul nextBlockHeight, [ { module: "deployment", version: "v1", source: "akash", action: ["deployment-closed"] }, - { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] } + { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] }, + { module: "provider", version: "v1beta4", source: "akash", action: ["provider-maintenance-opened"] } ], this.signal ); diff --git a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts index 4e4da54aa3..019df07169 100644 --- a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts +++ b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts @@ -1,7 +1,7 @@ import type { comet38 } from "@cosmjs/tendermint-rpc"; import { Comet38Client } from "@cosmjs/tendermint-rpc"; import { Test } from "@nestjs/testing"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { MockProxy } from "vitest-mock-extended"; import { mock } from "vitest-mock-extended"; @@ -12,6 +12,29 @@ import { MockProvider } from "@test/mocks/provider.mock"; describe(TxEventsService.name, () => { describe("getBlockEvents", () => { + it("propagates an exhausted block-results failure", async () => { + vi.useFakeTimers(); + + try { + const { module } = await setup(); + const service = module.get(TxEventsService); + const cometClient = module.get>(Comet38Client); + const error = new Error("block results unavailable"); + cometClient.blockResults.mockRejectedValue(error); + + const result = service.getBlockEvents(1).then( + () => undefined, + rejection => rejection + ); + await vi.runAllTimersAsync(); + + expect(await result).toBe(error); + expect(cometClient.blockResults).toHaveBeenCalledTimes(6); + } finally { + vi.useRealTimers(); + } + }); + it("should extract certain events from tx logs", async () => { const { module } = await setup(); const service = module.get(TxEventsService); @@ -237,6 +260,62 @@ describe(TxEventsService.name, () => { ]); }); + it("extracts a provider maintenance opened event", async () => { + const { module } = await setup(); + const service = module.get(TxEventsService); + const cometClient = module.get>(Comet38Client); + const blockResults: comet38.BlockResultsResponse = { + height: 1, + results: [ + { + code: 0, + codespace: "", + data: Uint8Array.from([]), + events: [ + { + type: "akash.provider.v1beta4.EventProviderMaintenanceOpened", + attributes: [ + { key: "maintenance_id", value: '"17"' }, + { key: "provider", value: '"akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"' }, + { key: "maintenance_type", value: '"provider_maintenance_type_planned"' }, + { key: "starts_at", value: '"2026-08-25T12:00:00Z"' }, + { key: "expected_ends_at", value: '"2026-08-25T14:00:00Z"' }, + { key: "metadata_hash", value: '"AQID"' }, + { key: "msg_index", value: "0" } + ] + } + ], + gasWanted: 100000n, + gasUsed: 80000n + } + ], + validatorUpdates: [], + finalizeBlockEvents: [] + }; + cometClient.blockResults.mockResolvedValue(blockResults); + + const result = await service.getBlockEvents(1, { + source: "akash", + module: "provider", + version: "v1beta4", + action: ["provider-maintenance-opened"] + }); + + expect(result).toEqual([ + { + type: "akash.v1beta4", + module: "provider", + action: "provider-maintenance-opened", + maintenance_id: "17", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z", + metadata_hash: "AQID" + } + ]); + }); + it("applies multiple filters in a single block fetch", async () => { const { module } = await setup(); const service = module.get(TxEventsService); diff --git a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts index 7ae67b99da..e7c2fcffef 100644 --- a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts +++ b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts @@ -18,7 +18,7 @@ interface ProcessedEvent { /** * Supported blockchain event actions */ -type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-started"; +type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-started" | "provider-maintenance-opened"; /** * Filter criteria for blockchain events @@ -26,8 +26,8 @@ type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-starte interface EventFilter { source?: "akash"; action?: Action | Action[]; - module?: "deployment" | "market"; - version?: "v1"; + module?: "deployment" | "market" | "provider"; + version?: "v1" | "v1beta4"; } /** @@ -47,7 +47,8 @@ export class TxEventsService { private readonly EVENT_ACTIONS: Record = { EventDeploymentClosed: "deployment-closed", EventDeploymentCreated: "deployment-created", - EventLeaseReclaimStarted: "lease-reclaim-started" + EventLeaseReclaimStarted: "lease-reclaim-started", + EventProviderMaintenanceOpened: "provider-maintenance-opened" }; private readonly ACTION_EVENTS: Record = Object.fromEntries(Object.entries(this.EVENT_ACTIONS).map(([k, v]) => [v, k])); @@ -90,7 +91,7 @@ export class TxEventsService { blockHeight, error }); - return []; + throw error; } } diff --git a/apps/notifications/test/functional/provider-maintenance-alert.spec.ts b/apps/notifications/test/functional/provider-maintenance-alert.spec.ts new file mode 100644 index 0000000000..a61d727e4f --- /dev/null +++ b/apps/notifications/test/functional/provider-maintenance-alert.spec.ts @@ -0,0 +1,220 @@ +import { faker } from "@faker-js/faker"; +import { Test } from "@nestjs/testing"; +import { eq } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; + +import { eventKeyRegistry } from "@src/common/config/event-key-registry.config"; +import { BrokerService } from "@src/infrastructure/broker"; +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import AlertEventsModule from "@src/interfaces/alert-events/alert-events.module"; +import { ChainEventsHandler } from "@src/interfaces/alert-events/handlers/chain-events/chain-events.handler"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; +import * as schema from "@src/modules/alert/model-schemas"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; +import { NotificationChannel } from "@src/modules/notifications/model-schemas"; + +import { mockAkashAddress } from "@test/seeders/akash-address.seeder"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; +import { generateNotificationChannel } from "@test/seeders/notification-channel.seeder"; + +describe("provider maintenance alerts", () => { + it("publishes once for the provider, maintenance and lease tuple", async () => { + const provider = mockAkashAddress(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const lease: ProviderLeaseId = { owner, dseq, provider, gseq: 1, oseq: 1, bseq: 4 }; + const activeLeases = { list: vi.fn().mockResolvedValue([lease]) }; + const module = await setup(activeLeases); + + try { + const handler = module.get(ChainEventsHandler); + const brokerService = module.get(BrokerService); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + vi.spyOn(brokerService, "publish").mockResolvedValue(undefined); + + const [channel] = await db + .insert(NotificationChannel) + .values([generateNotificationChannel({})]) + .returning(); + const [alert] = await db + .insert(schema.Alert) + .values([generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })]) + .returning(); + const event = maintenanceEvent(provider); + + await handler.processProviderMaintenanceOpened(event); + await handler.processProviderMaintenanceOpened(event); + + expect(activeLeases.list).toHaveBeenCalledWith(provider); + expect(brokerService.publish).toHaveBeenCalledTimes(1); + expect(brokerService.publish).toHaveBeenCalledWith( + eventKeyRegistry.createNotification, + expect.objectContaining({ + notificationChannelId: channel.id, + payload: expect.objectContaining({ + summary: expect.stringContaining(dseq), + description: expect.stringContaining("The lease remains open") + }) + }) + ); + + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[notificationKey(event, lease)]).toEqual({ + status: "sent", + sentAt: expect.any(String) + }); + } finally { + await module.close(); + } + }); + + it("does not reclaim a fresh pending notification", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + const pending = { + status: "pending", + claimId: faker.string.uuid(), + claimedAt: new Date().toISOString() + }; + await db + .update(schema.Alert) + .set({ params: { ...alert.params, providerMaintenanceNotifications: { [key]: pending } } }) + .where(eq(schema.Alert.id, alert.id)); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).not.toHaveBeenCalled(); + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[key]).toEqual(pending); + } finally { + await context.module.close(); + } + }); + + it("reclaims a stale pending notification and marks it sent", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + await db + .update(schema.Alert) + .set({ + params: { + ...alert.params, + providerMaintenanceNotifications: { + [key]: { status: "pending", claimId: faker.string.uuid(), claimedAt: new Date(0).toISOString() } + } + } + }) + .where(eq(schema.Alert.id, alert.id)); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).toHaveBeenCalledTimes(1); + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[key]).toEqual({ status: "sent", sentAt: expect.any(String) }); + } finally { + await context.module.close(); + } + }); + + it("releases a failed publication so replay can send it", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + vi.mocked(brokerService.publish).mockRejectedValueOnce(new Error("publish failed")).mockResolvedValueOnce(undefined); + + await expect(handler.processProviderMaintenanceOpened(event)).rejects.toThrow("publish failed"); + const failed = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(failed.params.providerMaintenanceNotifications?.[key]).toBeUndefined(); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).toHaveBeenCalledTimes(2); + const sent = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(sent.params.providerMaintenanceNotifications[key]).toEqual({ status: "sent", sentAt: expect.any(String) }); + } finally { + await context.module.close(); + } + }); + + async function setupScenario() { + const provider = mockAkashAddress(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const lease: ProviderLeaseId = { owner, dseq, provider, gseq: 1, oseq: 1, bseq: 4 }; + const module = await setup({ list: vi.fn().mockResolvedValue([lease]) }); + const brokerService = module.get(BrokerService); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + vi.spyOn(brokerService, "publish").mockResolvedValue(undefined); + const [channel] = await db + .insert(NotificationChannel) + .values([generateNotificationChannel({})]) + .returning(); + const [alert] = await db + .insert(schema.Alert) + .values([generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })]) + .returning(); + + return { + alert, + brokerService, + db, + event: maintenanceEvent(provider), + handler: module.get(ChainEventsHandler), + lease, + module + }; + } + + async function setup(activeLeases: Pick) { + process.env.PROVIDER_MAINTENANCE_ALERTS_ENABLED = "true"; + + return await Test.createTestingModule({ imports: [AlertEventsModule] }) + .overrideProvider(ProviderActiveLeasesService) + .useValue(activeLeases) + .compile(); + } +}); + +function generateClosedAlert(input: { owner: string; dseq: string; notificationChannelId: string }) { + return generateGeneralAlert({ + type: "CHAIN_EVENT", + notificationChannelId: input.notificationChannelId, + enabled: true, + params: { dseq: input.dseq, type: "DEPLOYMENT_CLOSED" }, + conditions: { + operator: "and", + value: [ + { field: "action", value: "deployment-closed", operator: "eq" }, + { field: "owner", value: input.owner, operator: "eq" }, + { field: "dseq", value: input.dseq, operator: "eq" } + ] + }, + summary: "Deployment closed", + description: "Deployment closed" + }); +} + +function maintenanceEvent(provider: string): EventProviderMaintenanceOpenedDto { + return { + module: "provider", + action: "provider-maintenance-opened", + maintenance_id: "17", + provider, + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z" + } as EventProviderMaintenanceOpenedDto; +} + +function notificationKey(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId): string { + return [event.provider, event.maintenance_id, lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.bseq, lease.provider].join("/"); +} diff --git a/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts b/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts new file mode 100644 index 0000000000..3a13cd4a06 --- /dev/null +++ b/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts @@ -0,0 +1,89 @@ +import { faker } from "@faker-js/faker"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it } from "vitest"; + +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import AlertEventsModule from "@src/interfaces/alert-events/alert-events.module"; +import * as schema from "@src/modules/alert/model-schemas"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { NotificationChannel } from "@src/modules/notifications/model-schemas"; + +import { mockAkashAddress } from "@test/seeders/akash-address.seeder"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; +import { generateNotificationChannel } from "@test/seeders/notification-channel.seeder"; + +describe("provider tier-demotion alert persistence", () => { + it("serializes feed processing and persists sent-delivery deduplication", async () => { + const module = await Test.createTestingModule({ imports: [AlertEventsModule] }).compile(); + + try { + const repository = module.get(ProviderTierDemotionRepository); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + const firstClaim = await repository.claimFeed(); + + expect(firstClaim).toMatchObject({ streamId: null, cursor: "0" }); + await expect(repository.claimFeed()).resolves.toBeUndefined(); + + await repository.setFeedPosition(firstClaim.claimId, STREAM_ID, "10"); + await repository.releaseFeed(firstClaim.claimId); + await expect(repository.claimFeed()).resolves.toMatchObject({ streamId: STREAM_ID, cursor: "10" }); + + const [channel] = await db.insert(NotificationChannel).values(generateNotificationChannel({})).returning(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const [alert] = await db + .insert(schema.Alert) + .values(generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })) + .returning(); + const delivery = { + streamId: STREAM_ID, + cursor: "11", + alertId: alert.id, + provider: PROVIDER, + lease: { owner, dseq, provider: PROVIDER, gseq: 1, oseq: 1, bseq: 3 } + }; + + const deliveryClaim = await repository.claimDelivery(delivery); + expect(deliveryClaim.status).toBe("claimed"); + if (deliveryClaim.status !== "claimed") throw new Error("Expected a claimed delivery"); + await repository.completeDelivery(delivery, deliveryClaim.claimId); + + await expect(repository.claimDelivery(delivery)).resolves.toEqual({ status: "sent" }); + await expect(db.select().from(schema.ProviderTierDemotionNotification)).resolves.toMatchObject([ + { streamId: STREAM_ID, cursor: 11n, status: "SENT", sentAt: expect.any(Date) } + ]); + + const retryableDelivery = { ...delivery, cursor: "12" }; + const pendingClaim = await repository.claimDelivery(retryableDelivery); + expect(pendingClaim.status).toBe("claimed"); + await expect(repository.claimDelivery(retryableDelivery)).resolves.toEqual({ status: "busy" }); + if (pendingClaim.status !== "claimed") throw new Error("Expected a claimed delivery"); + await repository.releaseDelivery(retryableDelivery, pendingClaim.claimId); + await expect(repository.claimDelivery(retryableDelivery)).resolves.toMatchObject({ status: "claimed" }); + } finally { + await module.close(); + } + }); +}); + +function generateClosedAlert(input: { owner: string; dseq: string; notificationChannelId: string }) { + return generateGeneralAlert({ + type: "CHAIN_EVENT", + notificationChannelId: input.notificationChannelId, + enabled: true, + params: { dseq: input.dseq, type: "DEPLOYMENT_CLOSED" }, + conditions: { + operator: "and", + value: [ + { field: "action", value: "deployment-closed", operator: "eq" }, + { field: "owner", value: input.owner, operator: "eq" }, + { field: "dseq", value: input.dseq, operator: "eq" } + ] + }, + summary: "Deployment closed", + description: "Deployment closed" + }); +} + +const STREAM_ID = "5be32550-fbc2-4f02-9ac2-7d58f0362451"; +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; diff --git a/apps/provider-inventory/package.json b/apps/provider-inventory/package.json index 3b6e38edff..468f4e50f5 100644 --- a/apps/provider-inventory/package.json +++ b/apps/provider-inventory/package.json @@ -24,7 +24,7 @@ "test:unit": "vitest run --project unit" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", diff --git a/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts b/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts index 8f6fde45ea..71e1c9c4ce 100644 --- a/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts +++ b/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts @@ -45,4 +45,10 @@ export function getAttributeFingerprint(attributes: ResourceAttribute[] | undefi .join(","); } -export type GroupSpecJSON = ToJSON; +type GroupSpecRequirementsJSON = ToJSON>; + +export type GroupSpecJSON = Omit, "requirements"> & { + requirements: Omit & { + verification?: GroupSpecRequirementsJSON["verification"]; + }; +}; diff --git a/apps/provider-proxy/package.json b/apps/provider-proxy/package.json index 40ec7c112d..836c637cc7 100644 --- a/apps/provider-proxy/package.json +++ b/apps/provider-proxy/package.json @@ -20,7 +20,7 @@ "test:unit": "vitest run --project unit" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", diff --git a/apps/stats-web/package.json b/apps/stats-web/package.json index 44ebfa7f69..b7d99db4ed 100644 --- a/apps/stats-web/package.json +++ b/apps/stats-web/package.json @@ -14,7 +14,7 @@ "test:unit": "NODE_ENV=test vitest run" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/logging": "*", "@akashnetwork/network-store": "*", diff --git a/apps/tx-signer/package.json b/apps/tx-signer/package.json index a5b8063fd8..690a2b4982 100644 --- a/apps/tx-signer/package.json +++ b/apps/tx-signer/package.json @@ -21,7 +21,7 @@ "test:unit": "vitest run --project unit" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", "@akashnetwork/instrumentation": "*", diff --git a/package-lock.json b/package-lock.json index 29427d428a..266a0fcf50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "license": "Apache-2.0", "dependencies": { "@akashnetwork/akash-api": "1.4.3", - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/console-api-types": "*", "@akashnetwork/database": "*", "@akashnetwork/env-loader": "*", @@ -542,7 +542,7 @@ "version": "3.31.0", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/console-api-types": "*", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", @@ -2168,7 +2168,7 @@ "license": "Apache-2.0", "dependencies": { "@akashnetwork/akash-api": "1.4.3", - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/database": "*", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", @@ -2558,7 +2558,7 @@ "version": "2.16.0", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", "@akashnetwork/instrumentation": "*", @@ -3701,7 +3701,7 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", @@ -4041,7 +4041,7 @@ "version": "2.10.2", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", @@ -4218,7 +4218,7 @@ "name": "@akashnetwork/stats-web", "version": "1.14.1", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/logging": "*", "@akashnetwork/network-store": "*", @@ -4982,7 +4982,7 @@ "version": "1.2.0", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", "@akashnetwork/instrumentation": "*", @@ -5473,9 +5473,9 @@ } }, "node_modules/@akashnetwork/chain-sdk": { - "version": "1.0.0-alpha.41", - "resolved": "https://registry.npmjs.org/@akashnetwork/chain-sdk/-/chain-sdk-1.0.0-alpha.41.tgz", - "integrity": "sha512-mr3fGDBSISxl0eZF83+IJLVulGSuJVu+MRj6fG61pi2rgfgnv9QEKpdU3w7wtFFt5saJaJNgsqAlyjGZQecZ1A==", + "version": "1.0.0-alpha.43", + "resolved": "https://registry.npmjs.org/@akashnetwork/chain-sdk/-/chain-sdk-1.0.0-alpha.43.tgz", + "integrity": "sha512-+XTgpLn3kibMEWBAOkfmOGo2VJcv+VtT5Uvq966Nk5YMyD9HCYUvdDLm1k167Di1S11t66SH06BhoY1M/m2IWg==", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.12.0", @@ -45932,7 +45932,7 @@ "version": "1.0.1", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/net": "*", "jotai": "^2.9.2" }, diff --git a/packages/network-store/package.json b/packages/network-store/package.json index d327cb4813..23265ae324 100644 --- a/packages/network-store/package.json +++ b/packages/network-store/package.json @@ -18,7 +18,7 @@ "validate:types": "tsc --noEmit && echo" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/net": "*", "jotai": "^2.9.2" },