diff --git a/apps/api/package.json b/apps/api/package.json index eebb667f54..8af0ff2ea1 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": "*", @@ -52,6 +52,7 @@ "@akashnetwork/logging": "*", "@akashnetwork/net": "*", "@akashnetwork/openapi-sdk": "*", + "@akashnetwork/provider-verification": "*", "@amplitude/analytics-node": "^1.3.8", "@casl/ability": "^6.8.1", "@chain-registry/assets": "^1.64.79", diff --git a/apps/api/src/bid-screening/http-schemas/bid-screening.schema.spec.ts b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.spec.ts new file mode 100644 index 0000000000..fec21ebd87 --- /dev/null +++ b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.spec.ts @@ -0,0 +1,133 @@ +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { describe, expect, it } from "vitest"; + +import { BidScreeningRequestSchema, BidScreeningResponseSchema } from "./bid-screening.schema"; + +describe("BidScreeningRequestSchema", () => { + it("accepts the canonical verification requirement and preserves its enum values", () => { + const result = BidScreeningRequestSchema.parse(createRequest()); + + expect(result.requirements.verification).toEqual({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1auditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + }); + expect(result.resources[0].resource.endpoints).toEqual([{ kind: "SHARED_HTTP", sequenceNumber: 1 }]); + }); + + it("rejects enum values outside the chain contract", () => { + const request = createRequest(); + request.requirements.verification.minTier = 99 as VerificationTier; + request.requirements.verification.requiredCapabilities = [CapabilityFlag.capability_unspecified]; + + expect(BidScreeningRequestSchema.safeParse(request).success).toBe(false); + }); +}); + +describe("BidScreeningResponseSchema", () => { + it("accepts pass, not-evaluated, and exclusion results", () => { + const summary = createVerificationSummary(); + const response = { + providers: [ + createProvider({ outcome: "pass", summary }), + createProvider({ outcome: "not_evaluated", incompleteFacts: ["snapshot", "attestations"], summary }) + ], + exclusions: [ + { + owner: "akash1excluded", + firstFailure: { + code: "insufficient_tier", + actual: VerificationTier.verification_tier_identified, + required: VerificationTier.verification_tier_verified + }, + failures: [ + { + code: "insufficient_tier", + actual: VerificationTier.verification_tier_identified, + required: VerificationTier.verification_tier_verified + }, + { code: "missing_capability", capability: CapabilityFlag.capability_persistent_storage } + ], + summary + } + ] + }; + + expect(BidScreeningResponseSchema.parse(response)).toEqual(response); + }); + + it("rejects exclusion enum values outside the chain contract", () => { + const summary = createVerificationSummary(); + const response = { + providers: [], + exclusions: [ + { + owner: "akash1excluded", + firstFailure: { code: "required_auditor_not_found", mode: 99, missing: ["akash1auditor"] }, + failures: [{ code: "required_auditor_not_found", mode: 99, missing: ["akash1auditor"] }], + summary + } + ] + }; + + expect(BidScreeningResponseSchema.safeParse(response).success).toBe(false); + }); +}); + +function createRequest() { + return { + requirements: { + signedBy: { allOf: ["akash1legacy"], anyOf: ["akash1existing"] }, + attributes: [{ key: "region", value: "us-west" }], + verification: { + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1auditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + } + }, + resources: [ + { + resource: { + id: 1, + cpu: { units: { val: "1000" } }, + memory: { quantity: { val: "1048576" } }, + gpu: { units: { val: "0" } }, + storage: [{ name: "default", quantity: { val: "1073741824" } }], + endpoints: [{ kind: "SHARED_HTTP" as const, sequenceNumber: "1" }] + }, + count: 1, + price: { denom: "uakt", amount: "1000" } + } + ], + timezone: "UTC" + }; +} + +function createVerificationSummary() { + return { + bestStatusValidTier: VerificationTier.verification_tier_verified, + tierGateTier: VerificationTier.verification_tier_verified, + capabilities: [CapabilityFlag.capability_persistent_storage], + validAttestationCount: 2, + validAuditors: ["akash1auditor", "akash1auditor2"], + snapshotState: "current" as const, + observedHeight: "1234" + }; +} + +function createProvider(verification: Record) { + return { + owner: "akash1provider", + hostUri: "https://provider.example.com:8443", + isAudited: true, + createdAt: "2026-08-24T00:00:00.000Z", + location: "us-west", + organization: "Akash", + verification, + incidents: [] + }; +} diff --git a/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts index a20769d22a..d4e9ab5c00 100644 --- a/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts +++ b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts @@ -1,3 +1,4 @@ +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { z } from "@hono/zod-openapi"; const UIntStringSchema = z.string().regex(/^\d+$/, "Must be an unsigned integer string"); @@ -65,7 +66,14 @@ const ResourceSchema = z.object({ attributes: z.array(AttributeSchema).optional() }), storage: z.array(StorageResourceSchema), - endpoints: z.array(z.unknown()).optional().optional() + endpoints: z + .array( + z.object({ + kind: z.enum(["SHARED_HTTP", "RANDOM_PORT", "LEASED_IP", "UNRECOGNIZED"]).optional(), + sequenceNumber: z.number({ coerce: true }).int().nonnegative().optional() + }) + ) + .optional() }); const PriceSchema = z.object({ @@ -84,9 +92,63 @@ const SignedBySchema = z.object({ anyOf: z.array(z.string()).default([]) }); +const VerificationTierSchema = z.union([ + z.literal(VerificationTier.verification_tier_unspecified), + z.literal(VerificationTier.verification_tier_identified), + z.literal(VerificationTier.verification_tier_verified), + z.literal(VerificationTier.verification_tier_established), + z.literal(VerificationTier.verification_tier_trusted) +]); + +const CapabilityFlagSchema = z.union([ + z.literal(CapabilityFlag.capability_tee_hardware_attestation), + z.literal(CapabilityFlag.capability_confidential_computing), + z.literal(CapabilityFlag.capability_persistent_storage), + z.literal(CapabilityFlag.capability_bare_metal) +]); + +const AnyVerificationTierSchema = z.nativeEnum(VerificationTier); +const AnyCapabilityFlagSchema = z.nativeEnum(CapabilityFlag); +const AnyAuditorSelectionModeSchema = z.nativeEnum(AuditorSelectionMode); + +const AuditorSelectionModeSchema = z.union([ + z.literal(AuditorSelectionMode.auditor_selection_mode_unspecified), + z.literal(AuditorSelectionMode.auditor_selection_mode_any), + z.literal(AuditorSelectionMode.auditor_selection_mode_all) +]); + +const ProviderVerificationSummarySchema = z.object({ + bestStatusValidTier: AnyVerificationTierSchema, + tierGateTier: AnyVerificationTierSchema, + capabilities: z.array(AnyCapabilityFlagSchema), + validAttestationCount: z.number().int().nonnegative(), + validAuditors: z.array(z.string()), + snapshotState: z.enum(["unknown", "not_posted", "current", "stale", "suspended"]), + observedHeight: z.string() +}); + +export const VerificationRequirementSchema = z + .object({ + minTier: VerificationTierSchema, + requiredCapabilities: z.array(CapabilityFlagSchema).default([]), + requiredAuditors: z.array(z.string().min(1)).default([]), + auditorMode: AuditorSelectionModeSchema.default(AuditorSelectionMode.auditor_selection_mode_unspecified), + minAuditorCount: z.number().int().min(0).max(4_294_967_295).default(0) + }) + .superRefine((requirement, ctx) => { + if (requirement.minTier !== VerificationTier.verification_tier_unspecified) return; + if (requirement.requiredCapabilities.length === 0 && requirement.requiredAuditors.length === 0 && requirement.minAuditorCount === 0) return; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Tier 0 cannot be combined with capabilities, auditors, or a minimum auditor count" + }); + }); + const RequirementsSchema = z.object({ signedBy: SignedBySchema.default({}), - attributes: z.array(AttributeSchema).default([]) + attributes: z.array(AttributeSchema).default([]), + verification: VerificationRequirementSchema.optional() }); /** @@ -133,6 +195,19 @@ const ProviderResultSchema = z.object({ description: "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", example: "Akash" }), + verification: z + .discriminatedUnion("outcome", [ + z.object({ + outcome: z.literal("pass"), + summary: ProviderVerificationSummarySchema + }), + z.object({ + outcome: z.literal("not_evaluated"), + incompleteFacts: z.array(z.enum(["params", "attestations", "graces", "snapshot", "module_inactive"])), + summary: ProviderVerificationSummarySchema + }) + ]) + .optional(), incidents: z .array( z.object({ @@ -145,8 +220,26 @@ const ProviderResultSchema = z.object({ .openapi({ description: "Per-day downtime over a rolling 7-day window" }) }); +const VerificationFailureSchema = z.discriminatedUnion("code", [ + z.object({ code: z.literal("snapshot_not_posted") }), + z.object({ code: z.literal("snapshot_suspended") }), + z.object({ code: z.literal("snapshot_stale") }), + z.object({ code: z.literal("insufficient_tier"), actual: AnyVerificationTierSchema, required: AnyVerificationTierSchema }), + z.object({ code: z.literal("missing_capability"), capability: AnyCapabilityFlagSchema }), + z.object({ code: z.literal("insufficient_auditor_count"), actual: z.number().int().nonnegative(), required: z.number().int().nonnegative() }), + z.object({ code: z.literal("required_auditor_not_found"), mode: AnyAuditorSelectionModeSchema, missing: z.array(z.string()) }) +]); + +const VerificationExclusionSchema = z.object({ + owner: z.string(), + firstFailure: VerificationFailureSchema, + failures: z.array(VerificationFailureSchema).min(1), + summary: ProviderVerificationSummarySchema +}); + export const BidScreeningResponseSchema = z.object({ - providers: z.array(ProviderResultSchema) + providers: z.array(ProviderResultSchema), + exclusions: z.array(VerificationExclusionSchema).optional() }); export type BidScreeningResponse = z.infer; diff --git a/apps/api/src/bid-screening/routes/bid-screening.router.spec.ts b/apps/api/src/bid-screening/routes/bid-screening.router.spec.ts new file mode 100644 index 0000000000..0434c5bcf8 --- /dev/null +++ b/apps/api/src/bid-screening/routes/bid-screening.router.spec.ts @@ -0,0 +1,146 @@ +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { describe, expect, it, vi } from "vitest"; + +import { BidScreeningRequestSchema } from "../http-schemas/bid-screening.schema"; +import { applyManagedWalletPolicy, forwardBidScreeningRequest } from "./bid-screening.router"; + +describe(forwardBidScreeningRequest.name, () => { + it("forwards the validated verification request and injects configured auditors without duplicates", async () => { + const request = BidScreeningRequestSchema.parse(createRequest()); + const fetchMock = vi.fn().mockResolvedValue(Response.json({ providers: [] })); + const controller = new AbortController(); + + const response = await forwardBidScreeningRequest(request, controller.signal, { + providerInventoryApiUrl: "https://inventory.example.com/base", + managedWalletAllowedAuditors: ["akash1managed", "akash1existing", "akash1managed2"], + fetch: fetchMock as typeof fetch + }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe("https://inventory.example.com/v1/bid-screening"); + expect(init).toMatchObject({ + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal + }); + + const forwarded = JSON.parse(init.body as string); + expect(forwarded.requirements).toEqual({ + signedBy: { + allOf: ["akash1legacy"], + anyOf: ["akash1existing", "akash1managed", "akash1managed2"] + }, + attributes: [{ key: "region", value: "us-west" }], + verification: { + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1verificationauditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + } + }); + expect(forwarded.resources[0].resource.cpu.units.val).toBe("1000"); + }); + + it("leaves the validated request unchanged when no managed auditors are configured", async () => { + const request = BidScreeningRequestSchema.parse(createRequest()); + const fetchMock = vi.fn().mockResolvedValue(Response.json({ providers: [] })); + + expect(applyManagedWalletPolicy(request, [])).toBe(request); + + await forwardBidScreeningRequest(request, new AbortController().signal, { + providerInventoryApiUrl: "https://inventory.example.com", + managedWalletAllowedAuditors: [], + fetch: fetchMock as typeof fetch + }); + + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(JSON.parse(init.body as string).requirements.signedBy).toEqual({ + allOf: ["akash1legacy"], + anyOf: ["akash1existing"] + }); + }); + + it("passes upstream HTTP errors through unchanged", async () => { + const request = BidScreeningRequestSchema.parse(createRequest()); + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json( + { error: "invalid_requirements", message: "Provider requirements are invalid" }, + { status: 422, headers: { "Content-Type": "application/problem+json" } } + ) + ); + + const response = await forwardBidScreeningRequest(request, new AbortController().signal, { + providerInventoryApiUrl: "https://inventory.example.com", + managedWalletAllowedAuditors: [], + fetch: fetchMock as typeof fetch + }); + + expect(response.status).toBe(422); + expect(response.headers.get("content-type")).toBe("application/problem+json"); + await expect(response.json()).resolves.toEqual({ error: "invalid_requirements", message: "Provider requirements are invalid" }); + }); + + it("maps upstream connection failures to 503", async () => { + const request = BidScreeningRequestSchema.parse(createRequest()); + const fetchMock = vi.fn().mockRejectedValue(new Error("connection refused")); + + await expect( + forwardBidScreeningRequest(request, new AbortController().signal, { + providerInventoryApiUrl: "https://inventory.example.com", + managedWalletAllowedAuditors: [], + fetch: fetchMock as typeof fetch + }) + ).rejects.toMatchObject({ status: 503, message: "Failed to screen providers." }); + }); + + it("preserves the existing 499 mapping for aborted upstream requests", async () => { + const request = BidScreeningRequestSchema.parse(createRequest()); + const abortError = new Error("aborted"); + abortError.name = "AbortError"; + const fetchMock = vi.fn().mockRejectedValue(abortError); + + await expect( + forwardBidScreeningRequest(request, new AbortController().signal, { + providerInventoryApiUrl: "https://inventory.example.com", + managedWalletAllowedAuditors: [], + fetch: fetchMock as typeof fetch + }) + ).rejects.toMatchObject({ status: 499, message: "Failed to screen providers." }); + }); +}); + +function createRequest() { + return { + requirements: { + signedBy: { allOf: ["akash1legacy"], anyOf: ["akash1existing"] }, + attributes: [{ key: "region", value: "us-west" }], + verification: { + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1verificationauditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + } + }, + resources: [ + { + resource: { + id: 1, + cpu: { units: { val: "1000" } }, + memory: { quantity: { val: "1048576" } }, + gpu: { units: { val: "0" } }, + storage: [{ name: "default", quantity: { val: "1073741824" } }], + endpoints: [{ kind: "SHARED_HTTP" as const, sequenceNumber: 1 }] + }, + count: 1, + price: { denom: "uakt", amount: "1000" } + } + ], + timezone: "UTC" + }; +} diff --git a/apps/api/src/bid-screening/routes/bid-screening.router.ts b/apps/api/src/bid-screening/routes/bid-screening.router.ts index b02b2f3aef..be0029a9b2 100644 --- a/apps/api/src/bid-screening/routes/bid-screening.router.ts +++ b/apps/api/src/bid-screening/routes/bid-screening.router.ts @@ -4,10 +4,11 @@ import type { StatusCode } from "hono/utils/http-status"; import { container } from "tsyringe"; import { BID_SCREENING_CONFIG } from "@src/bid-screening/providers/config.provider"; +import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { createRoute } from "@src/core/lib/create-route/create-route"; import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; -import type { BidScreeningResponse } from "../http-schemas/bid-screening.schema"; +import type { BidScreeningRequest, BidScreeningResponse } from "../http-schemas/bid-screening.schema"; import { BidScreeningRequestSchema, BidScreeningResponseSchema } from "../http-schemas/bid-screening.schema"; export const bidScreeningRouter = new OpenApiHonoHandler(); @@ -44,17 +45,42 @@ const postBidScreeningRoute = createRoute({ } }); -bidScreeningRouter.openapi(postBidScreeningRoute, async function routePostBidScreening(c) { - const { PROVIDER_INVENTORY_API_URL } = container.resolve(BID_SCREENING_CONFIG); - const url = new URL("/v1/bid-screening", PROVIDER_INVENTORY_API_URL); +interface ForwardBidScreeningOptions { + providerInventoryApiUrl: string; + managedWalletAllowedAuditors: string[]; + fetch: typeof globalThis.fetch; +} + +export function applyManagedWalletPolicy(request: BidScreeningRequest, allowedAuditors: string[]): BidScreeningRequest { + if (allowedAuditors.length === 0) return request; + + return { + ...request, + requirements: { + ...request.requirements, + signedBy: { + ...request.requirements.signedBy, + anyOf: [...new Set([...request.requirements.signedBy.anyOf, ...allowedAuditors])] + } + } + }; +} + +export async function forwardBidScreeningRequest( + request: BidScreeningRequest, + signal: AbortSignal, + { providerInventoryApiUrl, managedWalletAllowedAuditors, fetch }: ForwardBidScreeningOptions +): Promise { + const url = new URL("/v1/bid-screening", providerInventoryApiUrl); + const normalizedRequest = applyManagedWalletPolicy(request, managedWalletAllowedAuditors); let upstream: Response; try { upstream = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: await c.req.text(), - signal: c.req.raw.signal + body: JSON.stringify(normalizedRequest, (_, value) => (typeof value === "bigint" ? value.toString() : value)), + signal }); } catch (error) { const statusCode = (error instanceof Error && error.name === "AbortError" ? 499 : 503) as StatusCode; @@ -64,5 +90,17 @@ bidScreeningRouter.openapi(postBidScreeningRoute, async function routePostBidScr return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json" } - }) as unknown as TypedResponse; + }); +} + +bidScreeningRouter.openapi(postBidScreeningRoute, async function routePostBidScreening(c) { + const { PROVIDER_INVENTORY_API_URL } = container.resolve(BID_SCREENING_CONFIG); + const allowedAuditors = container.resolve(BillingConfigService).get("MANAGED_WALLET_LEASE_ALLOWED_AUDITORS"); + const upstream = await forwardBidScreeningRequest(c.req.valid("json"), c.req.raw.signal, { + providerInventoryApiUrl: PROVIDER_INVENTORY_API_URL, + managedWalletAllowedAuditors: allowedAuditors, + fetch: globalThis.fetch + }); + + return upstream as unknown as TypedResponse; }); diff --git a/apps/api/src/core/config/env.config.ts b/apps/api/src/core/config/env.config.ts index 5cf76d97ef..3ee7b7a5d8 100644 --- a/apps/api/src/core/config/env.config.ts +++ b/apps/api/src/core/config/env.config.ts @@ -39,6 +39,11 @@ export const envSchema = z .string() .default("false") .transform(value => value === "true"), + AEP86_PROVIDER_VERIFICATION_ENABLED: z + .string() + .default("false") + .transform(value => value === "true"), + AEP86_PROVIDER_VERIFICATION_MAX_INDEXER_LAG_BLOCKS: z.number({ coerce: true }).int().nonnegative().default(2), REST_API_NODE_URL: z .string() .url() diff --git a/apps/api/src/provider/controllers/provider/provider.controller.spec.ts b/apps/api/src/provider/controllers/provider/provider.controller.spec.ts new file mode 100644 index 0000000000..be4e2dd383 --- /dev/null +++ b/apps/api/src/provider/controllers/provider/provider.controller.spec.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { ProviderCleanupService } from "@src/billing/services/provider-cleanup/provider-cleanup.service"; +import { cacheEngine } from "@src/caching/helpers"; +import type { CoreConfigService } from "@src/core/services/core-config/core-config.service"; +import type { ProviderService } from "@src/provider/services/provider/provider.service"; +import type { ProviderStatsService } from "@src/provider/services/provider-stats/provider-stats.service"; +import type { TrialProvidersService } from "@src/provider/services/trial-providers/trial-providers.service"; +import { ProviderController } from "./provider.controller"; + +import { mockConfigService } from "@test/mocks/config-service.mock"; + +describe(ProviderController.name, () => { + type ProviderListItem = Awaited>[number]; + + beforeEach(() => { + cacheEngine.clearAllKeyInCache(); + }); + + it("does not cache provider verification responses while AEP-86 is enabled", async () => { + const { controller, providerService } = setup(true); + providerService.getProviderList + .mockResolvedValueOnce([{ owner: "akash1provider", verification: null } as unknown as ProviderListItem]) + .mockResolvedValueOnce([{ owner: "akash1provider", verification: { summary: { effectiveTier: "L3" } } } as unknown as ProviderListItem]); + + const beforeRecovery = JSON.parse(new TextDecoder().decode(await controller.getProviderListBuffer("all"))); + const afterRecovery = JSON.parse(new TextDecoder().decode(await controller.getProviderListBuffer("all"))); + + expect(beforeRecovery[0].verification).toBeNull(); + expect(afterRecovery[0].verification.summary.effectiveTier).toBe("L3"); + expect(providerService.getProviderList).toHaveBeenCalledTimes(2); + }); + + it("keeps the existing serialized provider cache while AEP-86 is disabled", async () => { + const { controller, providerService } = setup(false); + providerService.getProviderList.mockResolvedValue([{ owner: "akash1provider", verification: null } as unknown as ProviderListItem]); + + await controller.getProviderListBuffer("all"); + await controller.getProviderListBuffer("all"); + + expect(providerService.getProviderList).toHaveBeenCalledOnce(); + }); + + function setup(verificationEnabled: boolean) { + const providerService = mock(); + const controller = new ProviderController( + mock(), + mock(), + providerService, + mock(), + mockConfigService({ AEP86_PROVIDER_VERIFICATION_ENABLED: verificationEnabled }) + ); + + return { controller, providerService }; + } +}); diff --git a/apps/api/src/provider/controllers/provider/provider.controller.ts b/apps/api/src/provider/controllers/provider/provider.controller.ts index d5dffd015d..e089ca38e9 100644 --- a/apps/api/src/provider/controllers/provider/provider.controller.ts +++ b/apps/api/src/provider/controllers/provider/provider.controller.ts @@ -3,6 +3,7 @@ import { singleton } from "tsyringe"; import { ProviderCleanupService } from "@src/billing/services/provider-cleanup/provider-cleanup.service"; import { ProviderCleanupParams } from "@src/billing/types/provider-cleanup"; import { cacheKeys, cacheResponse } from "@src/caching/helpers"; +import { CoreConfigService } from "@src/core/services/core-config/core-config.service"; import type { ProviderListQuery } from "@src/provider/http-schemas/provider.schema"; import { ProviderService } from "@src/provider/services/provider/provider.service"; import { ProviderStatsService } from "@src/provider/services/provider-stats/provider-stats.service"; @@ -15,7 +16,8 @@ export class ProviderController { private readonly trialProvidersService: TrialProvidersService, private readonly providerCleanupService: ProviderCleanupService, private readonly providerService: ProviderService, - private readonly providerStatsService: ProviderStatsService + private readonly providerStatsService: ProviderStatsService, + private readonly coreConfig: CoreConfigService ) {} async getTrialProviders() { @@ -28,12 +30,12 @@ export class ProviderController { async getProviderListBuffer(scope: ProviderListQuery["scope"]): Promise { const cacheKey = scope === "trial" ? cacheKeys.getTrialProviderListJson : cacheKeys.getProviderListJson; - - return cacheResponse(60, cacheKey, async () => { + const load = async () => { const data = await this.providerService.getProviderList(scope === "trial"); - const json = JSON.stringify(data); - return encoder.encode(json); - }); + return encoder.encode(JSON.stringify(data)); + }; + + return this.coreConfig.get("AEP86_PROVIDER_VERIFICATION_ENABLED") ? load() : cacheResponse(60, cacheKey, load); } async getFilteredProviderList(scope: ProviderListQuery["scope"], addresses: string[]) { diff --git a/apps/api/src/provider/http-schemas/provider.schema.ts b/apps/api/src/provider/http-schemas/provider.schema.ts index 0bc5d1dca8..b04866d0db 100644 --- a/apps/api/src/provider/http-schemas/provider.schema.ts +++ b/apps/api/src/provider/http-schemas/provider.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { ProviderVerificationListViewSchema, ProviderVerificationViewSchema } from "@src/provider/provider-verification/provider-verification.schema"; import { openApiExampleProviderAddress } from "@src/utils/constants"; import { AkashAddressSchema } from "@src/utils/schema"; @@ -84,7 +85,8 @@ export const ProviderListResponseSchema = z.array( featEndpointCustomDomain: z.boolean(), workloadSupportChia: z.boolean(), workloadSupportChiaCapabilities: z.array(z.string()).nullable(), - featEndpointIp: z.boolean() + featEndpointIp: z.boolean(), + verification: ProviderVerificationListViewSchema.nullable() }) ); @@ -175,6 +177,7 @@ export const ProviderResponseSchema = z.object({ workloadSupportChia: z.boolean(), workloadSupportChiaCapabilities: z.array(z.string()), featEndpointIp: z.boolean(), + verification: ProviderVerificationViewSchema.nullable(), uptime: z.array( z.object({ id: z.string(), diff --git a/apps/api/src/provider/provider-verification/provider-verification-readiness.service.spec.ts b/apps/api/src/provider/provider-verification/provider-verification-readiness.service.spec.ts new file mode 100644 index 0000000000..a012c25da5 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-readiness.service.spec.ts @@ -0,0 +1,63 @@ +import type { LoggerService } from "@akashnetwork/logging"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { BlockRepository } from "@src/chain/repositories/block.repository"; +import type { BlockHttpService } from "@src/chain/services/block-http/block-http.service"; +import type { CoreConfigService } from "@src/core/services/core-config/core-config.service"; +import { ProviderVerificationReadinessService } from "./provider-verification-readiness.service"; + +describe(ProviderVerificationReadinessService.name, () => { + it.each([ + { indexedHeight: 100, chainHeight: 100 }, + { indexedHeight: 100, chainHeight: 102 }, + { indexedHeight: 102, chainHeight: 100 } + ])("is ready when the chain and processed index are within the configured height skew", async ({ indexedHeight, chainHeight }) => { + const { service } = setup({ indexedHeight, chainHeight }); + + await expect(service.isReady()).resolves.toBe(true); + }); + + it.each([ + { indexedHeight: 100, chainHeight: 103 }, + { indexedHeight: 0, chainHeight: 1 }, + { indexedHeight: 103, chainHeight: 100 } + ])("fails closed when the processed index cannot represent the connected chain", async ({ indexedHeight, chainHeight }) => { + const { service, logger } = setup({ indexedHeight, chainHeight }); + + await expect(service.isReady()).resolves.toBe(false); + expect(logger.warn).toHaveBeenCalledWith({ + event: "PROVIDER_VERIFICATION_INDEXER_NOT_READY", + chainHeight, + indexedHeight, + maxLag: 2 + }); + }); + + it("fails closed when either height cannot be read", async () => { + const error = new Error("chain unavailable"); + const { service, logger } = setup({ chainError: error }); + + await expect(service.isReady()).resolves.toBe(false); + expect(logger.warn).toHaveBeenCalledWith({ event: "PROVIDER_VERIFICATION_READINESS_CHECK_FAILED", error }); + }); +}); + +function setup(input: { indexedHeight?: number; chainHeight?: number; chainError?: Error } = {}) { + const blockRepository = mock(); + const blockHttpService = mock(); + const coreConfig = mock(); + const logger = mock(); + blockRepository.getLatestProcessedHeight.mockResolvedValue(input.indexedHeight ?? 100); + if (input.chainError) { + blockHttpService.getCurrentHeight.mockRejectedValue(input.chainError); + } else { + blockHttpService.getCurrentHeight.mockResolvedValue(input.chainHeight ?? 100); + } + coreConfig.get.calledWith("AEP86_PROVIDER_VERIFICATION_MAX_INDEXER_LAG_BLOCKS").mockReturnValue(2); + + return { + service: new ProviderVerificationReadinessService(blockRepository, blockHttpService, coreConfig, () => logger), + logger + }; +} diff --git a/apps/api/src/provider/provider-verification/provider-verification-readiness.service.ts b/apps/api/src/provider/provider-verification/provider-verification-readiness.service.ts new file mode 100644 index 0000000000..9b885c8cc1 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-readiness.service.ts @@ -0,0 +1,38 @@ +import type { LoggerService } from "@akashnetwork/logging"; +import { inject, singleton } from "tsyringe"; + +import { BlockRepository } from "@src/chain/repositories/block.repository"; +import { BlockHttpService } from "@src/chain/services/block-http/block-http.service"; +import { type CreateLogger, LOGGER_FACTORY } from "@src/core/providers/logging.provider"; +import { CoreConfigService } from "@src/core/services/core-config/core-config.service"; + +@singleton() +export class ProviderVerificationReadinessService { + readonly #logger: LoggerService; + + constructor( + private readonly blockRepository: BlockRepository, + private readonly blockHttpService: BlockHttpService, + private readonly coreConfig: CoreConfigService, + @inject(LOGGER_FACTORY) createLogger: CreateLogger + ) { + this.#logger = createLogger({ context: ProviderVerificationReadinessService.name }); + } + + async isReady(): Promise { + try { + const [indexedHeight, chainHeight] = await Promise.all([this.blockRepository.getLatestProcessedHeight(), this.blockHttpService.getCurrentHeight()]); + const maxLag = this.coreConfig.get("AEP86_PROVIDER_VERIFICATION_MAX_INDEXER_LAG_BLOCKS"); + const ready = indexedHeight > 0 && Math.abs(chainHeight - indexedHeight) <= maxLag; + + if (!ready) { + this.#logger.warn({ event: "PROVIDER_VERIFICATION_INDEXER_NOT_READY", chainHeight, indexedHeight, maxLag }); + } + + return ready; + } catch (error) { + this.#logger.warn({ event: "PROVIDER_VERIFICATION_READINESS_CHECK_FAILED", error }); + return false; + } + } +} diff --git a/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.spec.ts b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.spec.ts new file mode 100644 index 0000000000..e2785ac875 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.spec.ts @@ -0,0 +1,77 @@ +import { + VerificationBlockEvent, + VerificationParams, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import type { Sequelize, Transaction as DbTransaction } from "sequelize"; +import { Op, Transaction } from "sequelize"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ProviderVerificationTierDemotionRepository } from "./provider-verification-tier-demotion.repository"; + +const originalSequelizeDescriptor = Object.getOwnPropertyDescriptor(VerificationProviderTierDemotion, "sequelize"); + +describe(ProviderVerificationTierDemotionRepository.name, () => { + afterEach(() => { + vi.restoreAllMocks(); + if (originalSequelizeDescriptor) { + Object.defineProperty(VerificationProviderTierDemotion, "sequelize", originalSequelizeDescriptor); + } else { + Reflect.deleteProperty(VerificationProviderTierDemotion, "sequelize"); + } + }); + + it("reads the cursor feed and readiness state from one repeatable-read transaction", async () => { + const transaction = {} as DbTransaction; + const runTransaction = vi.fn(async (_options: unknown, callback: (value: DbTransaction) => unknown) => callback(transaction)); + Object.defineProperty(VerificationProviderTierDemotion, "sequelize", { + configurable: true, + value: { transaction: runTransaction } as unknown as Sequelize + }); + + const stream = { streamId: "a3d46e08-d84a-4ab5-b23c-08fc10a575f6" } as VerificationProviderTierStream; + const params = { params: { verification_module_active: true } } as VerificationParams; + const head = { id: "12" } as VerificationProviderTierDemotion; + const demotions = [{ id: "11" }] as VerificationProviderTierDemotion[]; + vi.spyOn(VerificationProviderTierStream, "findByPk").mockResolvedValue(stream); + vi.spyOn(VerificationParams, "findByPk").mockResolvedValue(params); + const findOne = vi.spyOn(VerificationProviderTierDemotion, "findOne").mockResolvedValue(head); + const findAll = vi.spyOn(VerificationProviderTierDemotion, "findAll").mockResolvedValue(demotions); + const pendingTargets = vi.spyOn(VerificationReconcileTarget, "count").mockResolvedValue(0); + const blockEvents = vi.spyOn(VerificationBlockEvent, "count").mockResolvedValue(0); + + await expect(new ProviderVerificationTierDemotionRepository().getFeed("10", 25)).resolves.toEqual({ + stream, + params, + demotions, + headCursor: "12", + globallyComplete: true + }); + expect(runTransaction).toHaveBeenCalledWith({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, expect.any(Function)); + expect(findOne).toHaveBeenCalledWith({ attributes: ["id"], order: [["id", "DESC"]], transaction }); + expect(findAll).toHaveBeenCalledWith({ where: { id: { [Op.gt]: "10" } }, order: [["id", "ASC"]], limit: 25, transaction }); + expect(pendingTargets).toHaveBeenCalledWith({ where: { invalidated: true, targetType: { [Op.ne]: "provider" } }, transaction }); + expect(blockEvents).toHaveBeenCalledWith({ where: { isProcessed: false }, transaction }); + }); + + it("marks the feed incomplete while global reconciliation or block events are pending", async () => { + const transaction = {} as DbTransaction; + Object.defineProperty(VerificationProviderTierDemotion, "sequelize", { + configurable: true, + value: { transaction: vi.fn(async (_options: unknown, callback: (value: DbTransaction) => unknown) => callback(transaction)) } as unknown as Sequelize + }); + vi.spyOn(VerificationProviderTierStream, "findByPk").mockResolvedValue(null); + vi.spyOn(VerificationParams, "findByPk").mockResolvedValue(null); + vi.spyOn(VerificationProviderTierDemotion, "findOne").mockResolvedValue(null); + vi.spyOn(VerificationProviderTierDemotion, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationReconcileTarget, "count").mockResolvedValue(1); + vi.spyOn(VerificationBlockEvent, "count").mockResolvedValue(1); + + await expect(new ProviderVerificationTierDemotionRepository().getFeed("0", 100)).resolves.toMatchObject({ + headCursor: "0", + globallyComplete: false + }); + }); +}); diff --git a/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.ts b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.ts new file mode 100644 index 0000000000..0001bba951 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.ts @@ -0,0 +1,55 @@ +import { + VerificationBlockEvent, + VerificationParams, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import type { Transaction as DbTransaction } from "sequelize"; +import { Op, Transaction } from "sequelize"; +import { singleton } from "tsyringe"; + +export interface ProviderVerificationTierDemotionRows { + stream: VerificationProviderTierStream | null; + params: VerificationParams | null; + demotions: VerificationProviderTierDemotion[]; + headCursor: string; + globallyComplete: boolean; +} + +@singleton() +export class ProviderVerificationTierDemotionRepository { + async getFeed(after: string, limit: number): Promise { + const connection = VerificationProviderTierDemotion.sequelize; + if (!connection) throw new Error("Provider verification tier models are not registered with a database connection"); + + return connection.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, transaction => + this.getFeedInTransaction(after, limit, transaction) + ); + } + + private async getFeedInTransaction(after: string, limit: number, transaction: DbTransaction): Promise { + const stream = await VerificationProviderTierStream.findByPk(1, { transaction }); + const params = await VerificationParams.findByPk(1, { attributes: ["params"], transaction }); + const head = await VerificationProviderTierDemotion.findOne({ attributes: ["id"], order: [["id", "DESC"]], transaction }); + const demotions = await VerificationProviderTierDemotion.findAll({ + where: { id: { [Op.gt]: after } }, + order: [["id", "ASC"]], + limit, + transaction + }); + const pendingGlobalTargets = await VerificationReconcileTarget.count({ + where: { invalidated: true, targetType: { [Op.ne]: "provider" } }, + transaction + }); + const unprocessedBlockEvents = await VerificationBlockEvent.count({ where: { isProcessed: false }, transaction }); + + return { + stream, + params, + demotions, + headCursor: head?.id ?? "0", + globallyComplete: pendingGlobalTargets === 0 && unprocessedBlockEvents === 0 + }; + } +} diff --git a/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.schema.ts b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.schema.ts new file mode 100644 index 0000000000..5daad44dbf --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.schema.ts @@ -0,0 +1,31 @@ +import { z } from "@hono/zod-openapi"; + +import { ProviderVerificationSnapshotStateSchema, ProviderVerificationTierSchema } from "./provider-verification.schema"; + +const UIntStringSchema = z.string().regex(/^\d+$/); + +const ProviderTierStateSchema = z.object({ + effectiveTier: ProviderVerificationTierSchema, + maxPlacementTier: ProviderVerificationTierSchema, + snapshotState: ProviderVerificationSnapshotStateSchema +}); + +export const ProviderVerificationTierDemotionFeedSchema = z.object({ + streamId: z.string().uuid(), + headCursor: UIntStringSchema, + nextCursor: UIntStringSchema, + moduleActive: z.boolean(), + items: z.array( + z.object({ + cursor: UIntStringSchema, + provider: z.string(), + previous: ProviderTierStateSchema, + current: ProviderTierStateSchema, + changes: z.array(z.enum(["tier_gate", "snapshot_eligibility"])), + observedHeight: UIntStringSchema, + observedAt: z.string().datetime() + }) + ) +}); + +export type ProviderVerificationTierDemotionFeed = z.infer; diff --git a/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.spec.ts b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.spec.ts new file mode 100644 index 0000000000..f78fb4c21e --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.spec.ts @@ -0,0 +1,94 @@ +import { VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { VerificationParams, VerificationProviderTierDemotion, VerificationProviderTierStream } from "@akashnetwork/database/dbSchemas/akash"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { ProviderVerificationReadinessService } from "./provider-verification-readiness.service"; +import type { ProviderVerificationTierDemotionRepository } from "./provider-verification-tier-demotion.repository"; +import { ProviderVerificationTierDemotionFeedSchema } from "./provider-verification-tier-demotion.schema"; +import { ProviderVerificationTierDemotionService } from "./provider-verification-tier-demotion.service"; + +describe(ProviderVerificationTierDemotionService.name, () => { + it("returns an ordered normalized cursor feed", async () => { + const { service, repository } = setup(); + repository.getFeed.mockResolvedValue({ + stream: row({ streamId: "a3d46e08-d84a-4ab5-b23c-08fc10a575f6" }), + params: row({ params: { verification_module_active: true } }), + headCursor: "8", + globallyComplete: true, + demotions: [ + row({ + id: "7", + provider: "akash1provider", + previousEffectiveTier: VerificationTier.verification_tier_established, + previousMaxPlacementTier: VerificationTier.verification_tier_established, + previousSnapshotState: "current", + currentEffectiveTier: VerificationTier.verification_tier_verified, + currentMaxPlacementTier: VerificationTier.verification_tier_identified, + currentSnapshotState: "stale", + changes: ["tier_gate", "snapshot_eligibility"], + observedHeight: 123, + observedBlockTime: new Date("2026-08-24T12:00:00.000Z") + }) + ] + }); + + const result = await service.getFeed("6", 50); + + expect(repository.getFeed).toHaveBeenCalledWith("6", 50); + expect(ProviderVerificationTierDemotionFeedSchema.parse(result)).toEqual(result); + expect(result).toMatchObject({ + headCursor: "8", + nextCursor: "7", + moduleActive: true, + items: [ + { + cursor: "7", + previous: { effectiveTier: "L3", maxPlacementTier: "L3", snapshotState: "current" }, + current: { effectiveTier: "L2", maxPlacementTier: "L1", snapshotState: "stale" } + } + ] + }); + }); + + it("returns no feed while indexer readiness or canonical global state is incomplete", async () => { + const { service, readiness, repository } = setup({ ready: false }); + + await expect(service.getFeed("0", 50)).resolves.toBeNull(); + expect(repository.getFeed).not.toHaveBeenCalled(); + + readiness.isReady.mockResolvedValue(true); + repository.getFeed.mockResolvedValue({ + stream: row({ streamId: "a3d46e08-d84a-4ab5-b23c-08fc10a575f6" }), + params: row({ params: { verification_module_active: true } }), + headCursor: "0", + globallyComplete: false, + demotions: [] + }); + await expect(service.getFeed("0", 50)).resolves.toBeNull(); + }); + + it("keeps the caller cursor when no later item exists", async () => { + const { service, repository } = setup(); + repository.getFeed.mockResolvedValue({ + stream: row({ streamId: "a3d46e08-d84a-4ab5-b23c-08fc10a575f6" }), + params: row({ params: { verification_module_active: false } }), + headCursor: "12", + globallyComplete: true, + demotions: [] + }); + + await expect(service.getFeed("12", 50)).resolves.toMatchObject({ moduleActive: false, headCursor: "12", nextCursor: "12", items: [] }); + }); +}); + +function setup(input: { ready?: boolean } = {}) { + const repository = mock(); + const readiness = mock(); + readiness.isReady.mockResolvedValue(input.ready ?? true); + return { repository, readiness, service: new ProviderVerificationTierDemotionService(repository, readiness) }; +} + +function row(value: Partial): T { + return value as T; +} diff --git a/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.ts b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.ts new file mode 100644 index 0000000000..1d73a49f78 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.ts @@ -0,0 +1,59 @@ +import { singleton } from "tsyringe"; + +import { mapTier } from "./provider-verification.mapper"; +import { ProviderVerificationSnapshotStateSchema } from "./provider-verification.schema"; +import { ProviderVerificationReadinessService } from "./provider-verification-readiness.service"; +import { ProviderVerificationTierDemotionRepository } from "./provider-verification-tier-demotion.repository"; +import type { ProviderVerificationTierDemotionFeed } from "./provider-verification-tier-demotion.schema"; + +@singleton() +export class ProviderVerificationTierDemotionService { + constructor( + private readonly repository: ProviderVerificationTierDemotionRepository, + private readonly readiness: ProviderVerificationReadinessService + ) {} + + async getFeed(after: string, limit: number): Promise { + if (!(await this.readiness.isReady())) return null; + + const rows = await this.repository.getFeed(after, limit); + const moduleActive = readModuleActive(rows.params?.params); + if (!rows.stream || !rows.globallyComplete || moduleActive === null) return null; + + const items = rows.demotions.map(row => ({ + cursor: row.id, + provider: row.provider, + previous: { + effectiveTier: mapTier(row.previousEffectiveTier), + maxPlacementTier: mapTier(row.previousMaxPlacementTier), + snapshotState: ProviderVerificationSnapshotStateSchema.parse(row.previousSnapshotState) + }, + current: { + effectiveTier: mapTier(row.currentEffectiveTier), + maxPlacementTier: mapTier(row.currentMaxPlacementTier), + snapshotState: ProviderVerificationSnapshotStateSchema.parse(row.currentSnapshotState) + }, + changes: row.changes.map(change => parseChange(change)), + observedHeight: String(row.observedHeight), + observedAt: row.observedBlockTime.toISOString() + })); + + return { + streamId: rows.stream.streamId, + headCursor: rows.headCursor, + nextCursor: items.at(-1)?.cursor ?? after, + moduleActive, + items + }; + } +} + +function readModuleActive(params: Record | undefined): boolean | null { + const value = params?.verification_module_active; + return typeof value === "boolean" ? value : null; +} + +function parseChange(value: string): "tier_gate" | "snapshot_eligibility" { + if (value === "tier_gate" || value === "snapshot_eligibility") return value; + throw new Error(`Unknown provider tier demotion change: ${value}`); +} diff --git a/apps/api/src/provider/provider-verification/provider-verification.mapper.spec.ts b/apps/api/src/provider/provider-verification/provider-verification.mapper.spec.ts new file mode 100644 index 0000000000..230ff27099 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.mapper.spec.ts @@ -0,0 +1,384 @@ +import type { + AttestationRecord, + AuditEscrowRecord, + DiscrepancyEvent, + ProviderSnapshotRecord, + ProviderVerificationGraceRecord +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { + AttestationStatus, + AuditEscrowSettlementReason, + AuditEscrowStatus, + CapabilityFlag, + DepositStatus, + DiscrepancyResolutionReason, + DiscrepancyStatus, + FaultAttribution, + FeeStatus, + ProviderDepositStatus, + VerificationGraceStatus, + VerificationTier, + VoidedReason +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderMaintenanceWithStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import { ProviderMaintenanceStatus, ProviderMaintenanceType } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import type { ProviderVerificationFacts } from "@akashnetwork/provider-verification"; +import { describe, expect, it } from "vitest"; + +import { + mapProviderVerificationListView, + mapProviderVerificationView, + type MapProviderVerificationViewInput, + type ProviderBondWithRequirement +} from "./provider-verification.mapper"; +import { ProviderVerificationListViewSchema, ProviderVerificationViewSchema } from "./provider-verification.schema"; + +const PROVIDER = "akash1provider"; +const OBSERVED_AT = new Date("2026-08-24T12:00:00.000Z"); + +describe(mapProviderVerificationListView.name, () => { + it("returns only provider-list verification facts", () => { + const detail = completeInput(); + const view = mapProviderVerificationListView({ + provider: detail.provider, + moduleActive: detail.moduleActive, + facts: detail.facts, + maintenanceStatuses: detail.records.maintenance.map(item => item.status), + discrepancyStatuses: detail.records.discrepancies.map(item => item.resolutionStatus), + graceStatus: detail.records.grace?.status ?? null, + completeness: { + params: detail.completeness.params, + maintenance: detail.completeness.maintenance, + discrepancies: detail.completeness.discrepancies + } + }); + + expect(ProviderVerificationListViewSchema.parse(view)).toEqual(view); + expect(view).toEqual({ + provider: PROVIDER, + moduleActive: true, + summary: { + effectiveTier: "L3", + validAuditorCount: 1, + capabilities: ["persistent_storage"], + snapshotState: "current", + maintenanceState: "active", + reviewState: "under_review" + }, + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "12345" + }); + }); +}); + +describe(mapProviderVerificationView.name, () => { + it("maps chain facts and current records into a JSON-safe, truth-labeled view", () => { + const input = completeInput(); + + const view = mapProviderVerificationView(input); + + expect(ProviderVerificationViewSchema.parse(view)).toEqual(view); + expect(JSON.parse(JSON.stringify(view))).toEqual(view); + expect(view).toMatchObject({ + provider: PROVIDER, + providerDeclaredTier: "community", + moduleActive: true, + provenance: { + providerTier: "provider self-declared", + inventory: "provider-signed inventory", + attestations: "auditor-attested" + }, + summary: { + bestAttestedTier: "L2", + effectiveTier: "L3", + capabilities: ["persistent_storage"], + validAttestationCount: 1, + validAuditorCount: 1, + validAuditors: ["akash1auditor-a"], + snapshotState: "current", + maintenanceState: "active", + reviewState: "under_review" + }, + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "12345" + }); + expect(view.attestations).toHaveLength(2); + expect(view.attestations[0]).toMatchObject({ + auditor: "akash1auditor-a", + tier: "L2", + capabilities: ["persistent_storage"], + evidenceHash: "AQID", + status: "valid", + auditEscrowId: "9" + }); + expect(view.snapshot).toMatchObject({ + snapshotHash: "BwgJ", + resourceSummary: { + totalMemoryMb: "32768", + totalStorageMb: "1048576", + softwareSignature: "Cg==", + softwareIdentity: { + digest: "Cw==", + signature: "DA==" + } + } + }); + expect(view.bond).toMatchObject({ + bondedAmount: { denom: "uakt", amount: "500000000" }, + requiredForCurrentTier: { denom: "uakt", amount: "400000000" }, + slashed: false + }); + expect(view.auditEscrows[0]).toMatchObject({ id: "11", status: "settled", consumedByAuditor: null }); + expect(view.maintenance[0]).toMatchObject({ record: { id: "4", maintenanceType: "planned" }, status: "active" }); + expect(view.discrepancies[0]).toMatchObject({ id: "5", resolutionStatus: "pending", auditorATier: "L1", auditorBTier: "L3" }); + }); + + it("uses null and unknown instead of presenting incomplete indexed facts as verified", () => { + const input = completeInput(); + input.moduleActive = true; + input.facts = { + attestations: [], + graces: [], + snapshot: null, + completeness: { attestations: false, graces: false, snapshot: false }, + observedAt: OBSERVED_AT, + observedHeight: "12346" + }; + input.records = { + attestations: [], + bond: null, + snapshot: null, + grace: null, + auditEscrows: [], + maintenance: [], + discrepancies: [] + }; + input.completeness = { params: false, bond: false, auditEscrows: false, maintenance: false, discrepancies: false }; + + const view = mapProviderVerificationView(input); + + expect(view.moduleActive).toBeNull(); + expect(view.summary).toEqual({ + bestAttestedTier: null, + effectiveTier: null, + capabilities: null, + validAttestationCount: null, + validAuditorCount: null, + validAuditors: null, + snapshotState: "unknown", + maintenanceState: "unknown", + reviewState: "unknown" + }); + expect(view).toMatchObject({ + attestations: [], + bond: null, + snapshot: null, + grace: null, + auditEscrows: [], + maintenance: [], + discrepancies: [] + }); + expect(ProviderVerificationViewSchema.safeParse(view).success).toBe(true); + }); + + it("reports active grace after a discrepancy is no longer pending", () => { + const input = completeInput(); + input.records.discrepancies[0].resolutionStatus = DiscrepancyStatus.discrepancy_status_resolved; + + const view = mapProviderVerificationView(input); + + expect(view.summary.reviewState).toBe("grace"); + }); + + it("keeps records for other providers out of a provider-scoped view", () => { + const input = completeInput(); + input.records.attestations = [...input.records.attestations, attestation({ provider: "akash1other", auditor: "akash1other-auditor" })]; + input.records.auditEscrows = [...input.records.auditEscrows, escrow({ provider: "akash1other", id: 99n })]; + input.records.discrepancies = [...input.records.discrepancies, discrepancy({ provider: "akash1other", id: 99n })]; + + const view = mapProviderVerificationView(input); + + expect(view.attestations).toHaveLength(2); + expect(view.auditEscrows).toHaveLength(1); + expect(view.discrepancies).toHaveLength(1); + }); +}); + +function completeInput(): MapProviderVerificationViewInput { + const validAttestation = attestation({ + auditor: "akash1auditor-a", + tier: VerificationTier.verification_tier_verified, + capabilities: [CapabilityFlag.capability_persistent_storage], + evidenceHash: Uint8Array.from([1, 2, 3]), + createdAt: new Date("2026-08-23T12:00:00.000Z"), + expiresAt: new Date("2027-08-23T12:00:00.000Z"), + status: AttestationStatus.attestation_status_valid, + auditEscrowId: 9n + }); + const expiredAttestation = attestation({ + auditor: "akash1auditor-b", + tier: VerificationTier.verification_tier_trusted, + createdAt: new Date("2026-08-22T12:00:00.000Z"), + expiresAt: new Date("2026-08-23T12:00:00.000Z"), + status: AttestationStatus.attestation_status_expired, + auditEscrowId: 8n + }); + const grace = verificationGrace(); + const snapshot = providerSnapshot(); + const facts: ProviderVerificationFacts = { + attestations: [validAttestation, expiredAttestation], + graces: [grace], + snapshot, + completeness: { attestations: true, graces: true, snapshot: true }, + observedAt: OBSERVED_AT, + observedHeight: "12345" + }; + + return { + provider: PROVIDER, + providerDeclaredTier: "community", + moduleActive: true, + facts, + records: { + attestations: [expiredAttestation, validAttestation], + bond: providerBond(), + snapshot, + grace, + auditEscrows: [escrow()], + maintenance: [providerMaintenance()], + discrepancies: [discrepancy()] + }, + completeness: { params: true, bond: true, auditEscrows: true, maintenance: true, discrepancies: true } + }; +} + +function attestation(overrides: Partial = {}): AttestationRecord { + return { + provider: PROVIDER, + auditor: "akash1auditor", + tier: VerificationTier.verification_tier_identified, + capabilities: [], + evidenceHash: new Uint8Array(), + fee: { denom: "uakt", amount: "10000000" }, + feeStatus: FeeStatus.fee_status_escrowed, + createdAt: OBSERVED_AT, + expiresAt: new Date("2027-08-24T12:00:00.000Z"), + status: AttestationStatus.attestation_status_valid, + voidedReason: VoidedReason.voided_reason_unspecified, + deposit: { denom: "uakt", amount: "100000000" }, + depositStatus: DepositStatus.deposit_status_escrowed, + auditEscrowId: 1n, + faultAttribution: FaultAttribution.fault_attribution_unspecified, + ...overrides + }; +} + +function providerBond(): ProviderBondWithRequirement { + return { + provider: PROVIDER, + bondedAmount: { denom: "uakt", amount: "500000000" }, + requiredForCurrentTier: { denom: "uakt", amount: "400000000" }, + unbondingEntries: [{ amount: { denom: "uakt", amount: "100" }, completionTime: new Date("2026-08-30T12:00:00.000Z") }], + slashed: false, + lastSlashTime: undefined + }; +} + +function providerSnapshot(): ProviderSnapshotRecord { + return { + provider: PROVIDER, + snapshotHash: Uint8Array.from([7, 8, 9]), + resourceSummary: { + totalGpus: 1, + totalVcpus: 8, + totalMemoryMb: 32768n, + totalStorageMb: 1048576n, + activeLeases: 3, + softwareVersion: "v0.16.0-a4", + softwareSignature: Uint8Array.from([10]), + softwareIdentity: { + version: "v0.16.0-a4", + artifactRef: "provider-linux-amd64", + digestAlgorithm: "sha3-256", + digest: Uint8Array.from([11]), + signatureType: "cosign", + signature: Uint8Array.from([12]), + signatureRef: "oci://provider.sig", + publicKeyRef: "https://example.com/provider.pub" + } + }, + postedAt: new Date("2026-08-24T11:00:00.000Z"), + snapshotTimestamp: new Date("2026-08-24T10:59:00.000Z"), + complianceDeadline: new Date("2026-08-25T11:00:00.000Z"), + suspended: false + }; +} + +function verificationGrace(): ProviderVerificationGraceRecord { + return { + id: 3n, + provider: PROVIDER, + preservedTier: VerificationTier.verification_tier_established, + sourceDiscrepancyIds: [5n], + startedAt: new Date("2026-08-24T10:00:00.000Z"), + expiresAt: new Date("2026-08-26T10:00:00.000Z"), + status: VerificationGraceStatus.verification_grace_status_active + }; +} + +function escrow(overrides: Partial = {}): AuditEscrowRecord { + return { + id: 11n, + provider: PROVIDER, + consumedByAuditor: "", + requestedTier: VerificationTier.verification_tier_verified, + requestedCapabilities: [CapabilityFlag.capability_persistent_storage], + fee: { denom: "uakt", amount: "50000000" }, + feeStatus: FeeStatus.fee_status_released_to_auditor, + providerDeposit: { denom: "uakt", amount: "100000000" }, + providerDepositStatus: ProviderDepositStatus.provider_deposit_status_returned_to_provider, + status: AuditEscrowStatus.audit_escrow_status_settled, + openedAt: new Date("2026-08-20T12:00:00.000Z"), + consumedAt: new Date("2026-08-21T12:00:00.000Z"), + expiresAt: new Date("2026-08-22T12:00:00.000Z"), + metadataHash: new Uint8Array(), + settlementReason: AuditEscrowSettlementReason.audit_escrow_settlement_reason_no_fault, + faultAttribution: FaultAttribution.fault_attribution_no_fault, + ...overrides + }; +} + +function providerMaintenance(): ProviderMaintenanceWithStatus { + return { + record: { + id: 4n, + provider: PROVIDER, + maintenanceType: ProviderMaintenanceType.provider_maintenance_type_planned, + startsAt: new Date("2026-08-24T11:30:00.000Z"), + expectedEndsAt: new Date("2026-08-24T13:30:00.000Z"), + openedAt: new Date("2026-08-23T12:00:00.000Z"), + closedAt: undefined, + metadataHash: Uint8Array.from([13]) + }, + status: ProviderMaintenanceStatus.provider_maintenance_status_active + }; +} + +function discrepancy(overrides: Partial = {}): DiscrepancyEvent { + return { + id: 5n, + provider: PROVIDER, + auditorA: "akash1auditor-a", + auditorATier: VerificationTier.verification_tier_identified, + auditorB: "akash1auditor-b", + auditorBTier: VerificationTier.verification_tier_established, + timestamp: new Date("2026-08-24T10:00:00.000Z"), + resolutionStatus: DiscrepancyStatus.discrepancy_status_pending, + resolutionProposalId: 0n, + graceRecordId: 3n, + resolutionReason: DiscrepancyResolutionReason.discrepancy_resolution_reason_unspecified, + faultAttribution: FaultAttribution.fault_attribution_unspecified, + resolutionEvidenceHash: new Uint8Array(), + ...overrides + }; +} diff --git a/apps/api/src/provider/provider-verification/provider-verification.mapper.ts b/apps/api/src/provider/provider-verification/provider-verification.mapper.ts new file mode 100644 index 0000000000..f7e0ac0e23 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.mapper.ts @@ -0,0 +1,490 @@ +import type { + AttestationRecord, + AuditEscrowRecord, + DiscrepancyEvent, + ProviderBondRecord, + ProviderSnapshotRecord, + ProviderVerificationGraceRecord, + ResourceSummary, + SoftwareIdentity +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { + AttestationStatus, + AuditEscrowSettlementReason, + AuditEscrowStatus, + CapabilityFlag, + DepositStatus, + DiscrepancyResolutionReason, + DiscrepancyStatus, + FaultAttribution, + FeeStatus, + ProviderDepositStatus, + VerificationGraceStatus, + VerificationTier, + VoidedReason +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderMaintenanceRecord, ProviderMaintenanceWithStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import { ProviderMaintenanceStatus, ProviderMaintenanceType } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import { deriveProviderVerificationSummary, type ProviderVerificationFacts } from "@akashnetwork/provider-verification"; + +import type { + ProviderVerificationCapability, + ProviderVerificationListView, + ProviderVerificationTier, + ProviderVerificationView, + ProviderVerificationViewCompleteness +} from "./provider-verification.schema"; + +type AttestationView = ProviderVerificationView["attestations"][number]; +type EscrowView = ProviderVerificationView["auditEscrows"][number]; +type MaintenanceView = ProviderVerificationView["maintenance"][number]; +type DiscrepancyView = ProviderVerificationView["discrepancies"][number]; + +const CAPABILITY_BY_VALUE: Readonly>> = { + [CapabilityFlag.capability_unspecified]: "unspecified", + [CapabilityFlag.capability_tee_hardware_attestation]: "tee_hardware_attestation", + [CapabilityFlag.capability_confidential_computing]: "confidential_computing", + [CapabilityFlag.capability_persistent_storage]: "persistent_storage", + [CapabilityFlag.capability_bare_metal]: "bare_metal" +}; +const ATTESTATION_STATUS_BY_VALUE: Readonly>> = { + [AttestationStatus.attestation_status_unspecified]: "unspecified", + [AttestationStatus.attestation_status_valid]: "valid", + [AttestationStatus.attestation_status_voided]: "voided", + [AttestationStatus.attestation_status_expired]: "expired", + [AttestationStatus.attestation_status_revoked]: "revoked", + [AttestationStatus.attestation_status_removed]: "removed" +}; +const VOIDED_REASON_BY_VALUE: Readonly>> = { + [VoidedReason.voided_reason_unspecified]: "unspecified", + [VoidedReason.voided_reason_discrepancy]: "discrepancy", + [VoidedReason.voided_reason_governance]: "governance", + [VoidedReason.voided_reason_bond_withdrawn]: "bond_withdrawn", + [VoidedReason.voided_reason_bond_slashed]: "bond_slashed" +}; +const FEE_STATUS_BY_VALUE: Readonly>> = { + [FeeStatus.fee_status_unspecified]: "unspecified", + [FeeStatus.fee_status_escrowed]: "escrowed", + [FeeStatus.fee_status_released_to_auditor]: "released_to_auditor", + [FeeStatus.fee_status_returned_to_provider]: "returned_to_provider" +}; +const DEPOSIT_STATUS_BY_VALUE: Readonly>> = { + [DepositStatus.deposit_status_unspecified]: "unspecified", + [DepositStatus.deposit_status_escrowed]: "escrowed", + [DepositStatus.deposit_status_pending_discrepancy]: "pending_discrepancy", + [DepositStatus.deposit_status_returned_to_auditor]: "returned_to_auditor", + [DepositStatus.deposit_status_slashed]: "slashed" +}; +const PROVIDER_DEPOSIT_STATUS_BY_VALUE: Readonly>> = { + [ProviderDepositStatus.provider_deposit_status_unspecified]: "unspecified", + [ProviderDepositStatus.provider_deposit_status_escrowed]: "escrowed", + [ProviderDepositStatus.provider_deposit_status_returned_to_provider]: "returned_to_provider", + [ProviderDepositStatus.provider_deposit_status_slashed]: "slashed" +}; +const FAULT_ATTRIBUTION_BY_VALUE: Readonly>> = { + [FaultAttribution.fault_attribution_unspecified]: "unspecified", + [FaultAttribution.fault_attribution_provider_fault]: "provider_fault", + [FaultAttribution.fault_attribution_auditor_fault]: "auditor_fault", + [FaultAttribution.fault_attribution_shared_fault]: "shared_fault", + [FaultAttribution.fault_attribution_no_fault]: "no_fault", + [FaultAttribution.fault_attribution_inconclusive]: "inconclusive" +}; +const ESCROW_STATUS_BY_VALUE: Readonly>> = { + [AuditEscrowStatus.audit_escrow_status_unspecified]: "unspecified", + [AuditEscrowStatus.audit_escrow_status_open]: "open", + [AuditEscrowStatus.audit_escrow_status_consumed]: "consumed", + [AuditEscrowStatus.audit_escrow_status_cancelled]: "cancelled", + [AuditEscrowStatus.audit_escrow_status_expired]: "expired", + [AuditEscrowStatus.audit_escrow_status_settled]: "settled" +}; +const ESCROW_SETTLEMENT_REASON_BY_VALUE: Readonly>> = { + [AuditEscrowSettlementReason.audit_escrow_settlement_reason_unspecified]: "unspecified", + [AuditEscrowSettlementReason.audit_escrow_settlement_reason_cancelled_unconsumed]: "cancelled_unconsumed", + [AuditEscrowSettlementReason.audit_escrow_settlement_reason_expired_unconsumed]: "expired_unconsumed", + [AuditEscrowSettlementReason.audit_escrow_settlement_reason_provider_fault]: "provider_fault", + [AuditEscrowSettlementReason.audit_escrow_settlement_reason_no_fault]: "no_fault" +}; +const GRACE_STATUS_BY_VALUE: Readonly["status"]>>> = { + [VerificationGraceStatus.verification_grace_status_unspecified]: "unspecified", + [VerificationGraceStatus.verification_grace_status_active]: "active", + [VerificationGraceStatus.verification_grace_status_expired]: "expired", + [VerificationGraceStatus.verification_grace_status_terminated]: "terminated" +}; +const MAINTENANCE_TYPE_BY_VALUE: Readonly["maintenanceType"]>>> = { + [ProviderMaintenanceType.provider_maintenance_type_unspecified]: "unspecified", + [ProviderMaintenanceType.provider_maintenance_type_planned]: "planned", + [ProviderMaintenanceType.provider_maintenance_type_emergency]: "emergency", + [ProviderMaintenanceType.provider_maintenance_type_security]: "security", + [ProviderMaintenanceType.provider_maintenance_type_network]: "network", + [ProviderMaintenanceType.provider_maintenance_type_capacity]: "capacity" +}; +const MAINTENANCE_STATUS_BY_VALUE: Readonly>> = { + [ProviderMaintenanceStatus.provider_maintenance_status_unspecified]: "unspecified", + [ProviderMaintenanceStatus.provider_maintenance_status_scheduled]: "scheduled", + [ProviderMaintenanceStatus.provider_maintenance_status_active]: "active", + [ProviderMaintenanceStatus.provider_maintenance_status_elapsed]: "elapsed", + [ProviderMaintenanceStatus.provider_maintenance_status_closed]: "closed" +}; +const DISCREPANCY_STATUS_BY_VALUE: Readonly>> = { + [DiscrepancyStatus.discrepancy_status_unspecified]: "unspecified", + [DiscrepancyStatus.discrepancy_status_pending]: "pending", + [DiscrepancyStatus.discrepancy_status_resolved]: "resolved", + [DiscrepancyStatus.discrepancy_status_timed_out]: "timed_out" +}; +const DISCREPANCY_REASON_BY_VALUE: Readonly>> = { + [DiscrepancyResolutionReason.discrepancy_resolution_reason_unspecified]: "unspecified", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_auditor_a_correct]: "auditor_a_correct", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_auditor_b_correct]: "auditor_b_correct", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_both_auditors_wrong]: "both_auditors_wrong", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_provider_fault]: "provider_fault", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_shared_fault]: "shared_fault", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_evidence_inconclusive]: "evidence_inconclusive", + [DiscrepancyResolutionReason.discrepancy_resolution_reason_governance_timeout_review]: "governance_timeout_review" +}; + +export interface ProviderVerificationCurrentRecords { + attestations: readonly AttestationRecord[]; + bond: ProviderBondWithRequirement | null; + snapshot: ProviderSnapshotRecord | null; + grace: ProviderVerificationGraceRecord | null; + auditEscrows: readonly AuditEscrowRecord[]; + maintenance: readonly ProviderMaintenanceWithStatus[]; + discrepancies: readonly DiscrepancyEvent[]; +} + +export interface ProviderBondWithRequirement extends ProviderBondRecord { + requiredForCurrentTier: { denom: string; amount: string }; +} + +export type ProviderVerificationSupplementalCompleteness = Pick< + ProviderVerificationViewCompleteness, + "params" | "bond" | "auditEscrows" | "maintenance" | "discrepancies" +>; + +export type ProviderVerificationSummaryCompleteness = Pick; + +export interface MapProviderVerificationListViewInput { + provider: string; + moduleActive: boolean | null; + facts: ProviderVerificationFacts; + maintenanceStatuses: readonly ProviderMaintenanceStatus[]; + discrepancyStatuses: readonly DiscrepancyStatus[]; + graceStatus: VerificationGraceStatus | null; + completeness: ProviderVerificationSummaryCompleteness; +} + +export interface MapProviderVerificationViewInput { + provider: string; + providerDeclaredTier: string | null; + moduleActive: boolean | null; + facts: ProviderVerificationFacts; + records: ProviderVerificationCurrentRecords; + completeness: ProviderVerificationSupplementalCompleteness; +} + +export function mapProviderVerificationListView(input: MapProviderVerificationListViewInput): ProviderVerificationListView { + return { + provider: input.provider, + moduleActive: input.completeness.params ? input.moduleActive : null, + summary: mapProviderVerificationSummary(input), + observedAt: input.facts.observedAt.toISOString(), + observedHeight: input.facts.observedHeight + }; +} + +export function mapProviderVerificationView(input: MapProviderVerificationViewInput): ProviderVerificationView { + const derivedSummary = deriveProviderVerificationSummary(input.facts); + const attestations = input.records.attestations + .filter(record => record.provider === input.provider) + .sort((left, right) => compareDatesDesc(left.createdAt, right.createdAt) || left.auditor.localeCompare(right.auditor)); + const auditEscrows = input.records.auditEscrows + .filter(record => record.provider === input.provider) + .sort((left, right) => compareBigIntsDesc(left.id, right.id)); + const maintenance = input.records.maintenance + .filter(item => !item.record || item.record.provider === input.provider) + .sort((left, right) => compareDatesDesc(left.record?.startsAt, right.record?.startsAt)); + const discrepancies = input.records.discrepancies + .filter(record => record.provider === input.provider) + .sort((left, right) => compareDatesDesc(left.timestamp, right.timestamp) || compareBigIntsDesc(left.id, right.id)); + const completeness: ProviderVerificationViewCompleteness = { + params: input.completeness.params, + attestations: input.facts.completeness.attestations, + graces: input.facts.completeness.graces, + snapshot: input.facts.completeness.snapshot, + bond: input.completeness.bond, + auditEscrows: input.completeness.auditEscrows, + maintenance: input.completeness.maintenance, + discrepancies: input.completeness.discrepancies + }; + const listSummary = mapProviderVerificationSummary({ + provider: input.provider, + moduleActive: input.moduleActive, + facts: input.facts, + maintenanceStatuses: maintenance.map(item => item.status), + discrepancyStatuses: discrepancies.map(item => item.resolutionStatus), + graceStatus: input.records.grace?.status ?? null, + completeness + }); + + return { + provider: input.provider, + providerDeclaredTier: input.providerDeclaredTier, + moduleActive: completeness.params ? input.moduleActive : null, + provenance: { + providerTier: "provider self-declared", + inventory: "provider-signed inventory", + attestations: "auditor-attested" + }, + summary: { + bestAttestedTier: completeness.attestations ? mapTier(derivedSummary.bestStatusValidTier) : null, + ...listSummary, + validAttestationCount: completeness.attestations ? derivedSummary.validAttestationCount : null, + validAuditors: completeness.attestations ? derivedSummary.validAuditors : null + }, + attestations: attestations.map(mapAttestation), + bond: input.records.bond ? mapBond(input.records.bond) : null, + snapshot: input.records.snapshot ? mapSnapshot(input.records.snapshot) : null, + grace: input.records.grace ? mapGrace(input.records.grace) : null, + auditEscrows: auditEscrows.map(mapAuditEscrow), + maintenance: maintenance.map(mapMaintenance), + discrepancies: discrepancies.map(mapDiscrepancy), + observedAt: input.facts.observedAt.toISOString(), + observedHeight: input.facts.observedHeight, + completeness + }; +} + +function mapProviderVerificationSummary(input: MapProviderVerificationListViewInput): ProviderVerificationListView["summary"] { + const summary = deriveProviderVerificationSummary(input.facts); + + return { + effectiveTier: input.facts.completeness.attestations && input.facts.completeness.graces ? mapTier(summary.tierGateTier) : null, + validAuditorCount: input.facts.completeness.attestations ? summary.validAuditors.length : null, + capabilities: input.facts.completeness.attestations ? summary.capabilities.map(mapCapability) : null, + snapshotState: summary.snapshotState, + maintenanceState: deriveMaintenanceState(input.maintenanceStatuses, input.completeness.maintenance), + reviewState: deriveReviewState(input.discrepancyStatuses, input.graceStatus, { + discrepancies: input.completeness.discrepancies, + graces: input.facts.completeness.graces + }) + }; +} + +function mapAttestation(record: AttestationRecord): ProviderVerificationView["attestations"][number] { + return { + provider: record.provider, + auditor: record.auditor, + tier: mapTier(record.tier), + capabilities: record.capabilities.map(mapCapability), + evidenceHash: mapBytes(record.evidenceHash), + fee: mapCoin(record.fee), + feeStatus: mapEnum(record.feeStatus, FEE_STATUS_BY_VALUE), + createdAt: mapDate(record.createdAt), + expiresAt: mapDate(record.expiresAt), + status: mapEnum(record.status, ATTESTATION_STATUS_BY_VALUE), + voidedReason: mapEnum(record.voidedReason, VOIDED_REASON_BY_VALUE), + deposit: mapCoin(record.deposit), + depositStatus: mapEnum(record.depositStatus, DEPOSIT_STATUS_BY_VALUE), + auditEscrowId: record.auditEscrowId.toString(), + faultAttribution: mapEnum(record.faultAttribution, FAULT_ATTRIBUTION_BY_VALUE) + }; +} + +function mapBond(record: ProviderBondWithRequirement): NonNullable { + return { + provider: record.provider, + bondedAmount: mapCoin(record.bondedAmount), + requiredForCurrentTier: record.requiredForCurrentTier, + unbondingEntries: record.unbondingEntries.map(entry => ({ + amount: mapCoin(entry.amount), + completionTime: mapDate(entry.completionTime) + })), + slashed: record.slashed, + lastSlashTime: mapDate(record.lastSlashTime) + }; +} + +function mapSnapshot(record: ProviderSnapshotRecord): NonNullable { + return { + provider: record.provider, + snapshotHash: mapBytes(record.snapshotHash), + resourceSummary: record.resourceSummary ? mapResourceSummary(record.resourceSummary) : null, + postedAt: mapDate(record.postedAt), + snapshotTimestamp: mapDate(record.snapshotTimestamp), + complianceDeadline: mapDate(record.complianceDeadline), + suspended: record.suspended + }; +} + +function mapResourceSummary(summary: ResourceSummary): NonNullable["resourceSummary"]> { + return { + totalGpus: summary.totalGpus, + totalVcpus: summary.totalVcpus, + totalMemoryMb: summary.totalMemoryMb.toString(), + totalStorageMb: summary.totalStorageMb.toString(), + activeLeases: summary.activeLeases, + softwareVersion: summary.softwareVersion, + softwareSignature: mapBytes(summary.softwareSignature), + softwareIdentity: summary.softwareIdentity ? mapSoftwareIdentity(summary.softwareIdentity) : null + }; +} + +function mapSoftwareIdentity( + identity: SoftwareIdentity +): NonNullable["resourceSummary"]>["softwareIdentity"]> { + return { + version: identity.version, + artifactRef: identity.artifactRef, + digestAlgorithm: identity.digestAlgorithm, + digest: mapBytes(identity.digest), + signatureType: identity.signatureType, + signature: mapBytes(identity.signature), + signatureRef: identity.signatureRef, + publicKeyRef: identity.publicKeyRef + }; +} + +function mapAuditEscrow(record: AuditEscrowRecord): ProviderVerificationView["auditEscrows"][number] { + return { + id: record.id.toString(), + provider: record.provider, + consumedByAuditor: record.consumedByAuditor || null, + requestedTier: mapTier(record.requestedTier), + requestedCapabilities: record.requestedCapabilities.map(mapCapability), + fee: mapCoin(record.fee), + feeStatus: mapEnum(record.feeStatus, FEE_STATUS_BY_VALUE), + providerDeposit: mapCoin(record.providerDeposit), + providerDepositStatus: mapEnum(record.providerDepositStatus, PROVIDER_DEPOSIT_STATUS_BY_VALUE), + status: mapEnum(record.status, ESCROW_STATUS_BY_VALUE), + openedAt: mapDate(record.openedAt), + consumedAt: mapDate(record.consumedAt), + expiresAt: mapDate(record.expiresAt), + metadataHash: mapBytes(record.metadataHash), + settlementReason: mapEnum(record.settlementReason, ESCROW_SETTLEMENT_REASON_BY_VALUE), + faultAttribution: mapEnum(record.faultAttribution, FAULT_ATTRIBUTION_BY_VALUE) + }; +} + +function mapGrace(record: ProviderVerificationGraceRecord): NonNullable { + return { + id: record.id.toString(), + provider: record.provider, + preservedTier: mapTier(record.preservedTier), + sourceDiscrepancyIds: record.sourceDiscrepancyIds.map(id => id.toString()), + startedAt: mapDate(record.startedAt), + expiresAt: mapDate(record.expiresAt), + status: mapEnum(record.status, GRACE_STATUS_BY_VALUE) + }; +} + +function mapMaintenance(item: ProviderMaintenanceWithStatus): ProviderVerificationView["maintenance"][number] { + return { + record: item.record ? mapMaintenanceRecord(item.record) : null, + status: mapEnum(item.status, MAINTENANCE_STATUS_BY_VALUE) + }; +} + +function mapMaintenanceRecord(record: ProviderMaintenanceRecord): NonNullable { + return { + id: record.id.toString(), + provider: record.provider, + maintenanceType: mapEnum(record.maintenanceType, MAINTENANCE_TYPE_BY_VALUE), + startsAt: mapDate(record.startsAt), + expectedEndsAt: mapDate(record.expectedEndsAt), + openedAt: mapDate(record.openedAt), + closedAt: mapDate(record.closedAt), + metadataHash: mapBytes(record.metadataHash) + }; +} + +function mapDiscrepancy(record: DiscrepancyEvent): ProviderVerificationView["discrepancies"][number] { + return { + id: record.id.toString(), + provider: record.provider, + auditorA: record.auditorA, + auditorATier: mapTier(record.auditorATier), + auditorB: record.auditorB, + auditorBTier: mapTier(record.auditorBTier), + timestamp: mapDate(record.timestamp), + resolutionStatus: mapEnum(record.resolutionStatus, DISCREPANCY_STATUS_BY_VALUE), + resolutionProposalId: record.resolutionProposalId.toString(), + graceRecordId: record.graceRecordId.toString(), + resolutionReason: mapEnum(record.resolutionReason, DISCREPANCY_REASON_BY_VALUE), + faultAttribution: mapEnum(record.faultAttribution, FAULT_ATTRIBUTION_BY_VALUE), + resolutionEvidenceHash: mapBytes(record.resolutionEvidenceHash) + }; +} + +function deriveMaintenanceState( + maintenanceStatuses: readonly ProviderMaintenanceStatus[], + complete: boolean +): ProviderVerificationListView["summary"]["maintenanceState"] { + if (!complete) return "unknown"; + if (maintenanceStatuses.includes(ProviderMaintenanceStatus.provider_maintenance_status_active)) return "active"; + if (maintenanceStatuses.includes(ProviderMaintenanceStatus.provider_maintenance_status_scheduled)) return "scheduled"; + if ( + maintenanceStatuses.some( + status => status === ProviderMaintenanceStatus.provider_maintenance_status_unspecified || status === ProviderMaintenanceStatus.UNRECOGNIZED + ) + ) { + return "unknown"; + } + return "none"; +} + +function deriveReviewState( + discrepancyStatuses: readonly DiscrepancyStatus[], + graceStatus: VerificationGraceStatus | null, + completeness: Pick +): ProviderVerificationListView["summary"]["reviewState"] { + if (!completeness.discrepancies) return "unknown"; + if (discrepancyStatuses.includes(DiscrepancyStatus.discrepancy_status_pending)) return "under_review"; + if (!completeness.graces) return "unknown"; + if (graceStatus === VerificationGraceStatus.verification_grace_status_active) return "grace"; + return "none"; +} + +export function mapTier(tier: VerificationTier): ProviderVerificationTier { + switch (tier) { + case VerificationTier.verification_tier_unspecified: + return "L0"; + case VerificationTier.verification_tier_identified: + return "L1"; + case VerificationTier.verification_tier_verified: + return "L2"; + case VerificationTier.verification_tier_established: + return "L3"; + case VerificationTier.verification_tier_trusted: + return "L4"; + default: + return "unknown"; + } +} + +function mapCapability(capability: CapabilityFlag): ProviderVerificationCapability { + return mapEnum(capability, CAPABILITY_BY_VALUE); +} + +function mapEnum(value: number, values: Readonly>>): TValue | "unknown" { + return values[value] ?? "unknown"; +} + +function mapCoin(coin: { denom: string; amount: string } | undefined): { denom: string; amount: string } | null { + return coin ? { denom: coin.denom, amount: coin.amount } : null; +} + +function mapDate(value: Date | undefined): string | null { + return value?.toISOString() ?? null; +} + +function mapBytes(value: Uint8Array): string | null { + return value.length > 0 ? Buffer.from(value).toString("base64") : null; +} + +function compareDatesDesc(left: Date | undefined, right: Date | undefined): number { + return (right?.getTime() ?? 0) - (left?.getTime() ?? 0); +} + +function compareBigIntsDesc(left: bigint, right: bigint): number { + return left === right ? 0 : left > right ? -1 : 1; +} diff --git a/apps/api/src/provider/provider-verification/provider-verification.repository.spec.ts b/apps/api/src/provider/provider-verification/provider-verification.repository.spec.ts new file mode 100644 index 0000000000..0444361a07 --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.repository.spec.ts @@ -0,0 +1,81 @@ +import { + ProviderMaintenance, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import type { Sequelize, Transaction as DbTransaction } from "sequelize"; +import { Op, Transaction } from "sequelize"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ProviderVerificationRepository } from "./provider-verification.repository"; + +const originalSequelizeDescriptor = Object.getOwnPropertyDescriptor(VerificationParams, "sequelize"); + +describe(ProviderVerificationRepository.name, () => { + afterEach(() => { + vi.restoreAllMocks(); + if (originalSequelizeDescriptor) { + Object.defineProperty(VerificationParams, "sequelize", originalSequelizeDescriptor); + } else { + Reflect.deleteProperty(VerificationParams, "sequelize"); + } + }); + + it("loads list summaries in one transaction without querying detail-only tables", async () => { + const transaction = {} as DbTransaction; + const runTransaction = vi.fn(async (_options: unknown, callback: (value: DbTransaction) => unknown) => callback(transaction)); + Object.defineProperty(VerificationParams, "sequelize", { + configurable: true, + value: { transaction: runTransaction } as unknown as Sequelize + }); + + const params = vi.spyOn(VerificationParams, "findByPk").mockResolvedValue(null); + const attestations = vi.spyOn(VerificationAttestation, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationAttestationCapability, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationProviderObservation, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationGrace, "findAll").mockResolvedValue([]); + vi.spyOn(ProviderMaintenance, "findAll").mockResolvedValue([]); + const snapshots = vi.spyOn(VerificationProviderSnapshot, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationDiscrepancy, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationReconcileTarget, "findAll").mockResolvedValue([]); + vi.spyOn(VerificationBlockEvent, "count").mockResolvedValue(0); + + const auditEscrows = vi.spyOn(VerificationAuditEscrow, "findAll"); + const auditEscrowCapabilities = vi.spyOn(VerificationAuditEscrowCapability, "findAll"); + const bonds = vi.spyOn(VerificationProviderBond, "findAll"); + const bondUnbondingEntries = vi.spyOn(VerificationProviderBondUnbonding, "findAll"); + const graceDiscrepancies = vi.spyOn(VerificationGraceDiscrepancy, "findAll"); + + await new ProviderVerificationRepository().getSummaryState(["akash1provider-a", "akash1provider-b"]); + + expect(runTransaction).toHaveBeenCalledWith({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, expect.any(Function)); + expect(params).toHaveBeenCalledWith(1, { attributes: ["params"], transaction }); + expect(attestations).toHaveBeenCalledWith({ + attributes: ["provider", "auditor", "tier", "status"], + where: { provider: { [Op.in]: ["akash1provider-a", "akash1provider-b"] } }, + transaction + }); + expect(snapshots).toHaveBeenCalledWith({ + attributes: ["provider", "complianceDeadline", "suspended"], + where: expect.any(Object), + transaction + }); + expect(auditEscrows).not.toHaveBeenCalled(); + expect(auditEscrowCapabilities).not.toHaveBeenCalled(); + expect(bonds).not.toHaveBeenCalled(); + expect(bondUnbondingEntries).not.toHaveBeenCalled(); + expect(graceDiscrepancies).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/provider/provider-verification/provider-verification.repository.ts b/apps/api/src/provider/provider-verification/provider-verification.repository.ts new file mode 100644 index 0000000000..aeaca5a4ae --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.repository.ts @@ -0,0 +1,174 @@ +import { + ProviderMaintenance, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import type { Transaction as DbTransaction } from "sequelize"; +import { Op, Transaction } from "sequelize"; +import { singleton } from "tsyringe"; + +export interface ProviderVerificationSummaryIndexedRows { + params: VerificationParams | null; + attestations: VerificationAttestation[]; + attestationCapabilities: VerificationAttestationCapability[]; + providerObservations: VerificationProviderObservation[]; + graces: VerificationGrace[]; + maintenances: ProviderMaintenance[]; + snapshots: VerificationProviderSnapshot[]; + discrepancies: VerificationDiscrepancy[]; + pendingTargets: VerificationReconcileTarget[]; + hasUnprocessedBlockEvents: boolean; +} + +export interface ProviderVerificationIndexedRows extends ProviderVerificationSummaryIndexedRows { + auditEscrows: VerificationAuditEscrow[]; + auditEscrowCapabilities: VerificationAuditEscrowCapability[]; + bonds: VerificationProviderBond[]; + bondUnbondingEntries: VerificationProviderBondUnbonding[]; + graceDiscrepancies: VerificationGraceDiscrepancy[]; +} + +@singleton() +export class ProviderVerificationRepository { + async getSummaryState(providers: string[]): Promise { + const connection = VerificationParams.sequelize; + if (!connection) throw new Error("Provider verification models are not registered with a database connection"); + + return connection.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, transaction => + this.getSummaryStateInTransaction(providers, transaction) + ); + } + + async getCurrentState(providers: string[]): Promise { + const connection = VerificationParams.sequelize; + if (!connection) throw new Error("Provider verification models are not registered with a database connection"); + + return connection.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.REPEATABLE_READ }, transaction => + this.getCurrentStateInTransaction(providers, transaction) + ); + } + + private async getSummaryStateInTransaction(providers: string[], transaction: DbTransaction): Promise { + const providerWhere = { [Op.in]: providers }; + const params = await VerificationParams.findByPk(1, { attributes: ["params"], transaction }); + const attestations = await VerificationAttestation.findAll({ + attributes: ["provider", "auditor", "tier", "status"], + where: { provider: providerWhere }, + transaction + }); + const attestationCapabilities = await VerificationAttestationCapability.findAll({ + attributes: ["provider", "auditor", "capability"], + where: { provider: providerWhere }, + transaction + }); + const providerObservations = await VerificationProviderObservation.findAll({ + attributes: ["provider", "observedHeight", "observedBlockTime"], + where: { provider: providerWhere }, + transaction + }); + const graces = await VerificationGrace.findAll({ + attributes: ["provider", "preservedTier", "status", "observedHeight"], + where: { provider: providerWhere }, + transaction + }); + const maintenances = await ProviderMaintenance.findAll({ + attributes: ["provider", "status"], + where: { provider: providerWhere }, + transaction + }); + const snapshots = await VerificationProviderSnapshot.findAll({ + attributes: ["provider", "complianceDeadline", "suspended"], + where: { provider: providerWhere }, + transaction + }); + const discrepancies = await VerificationDiscrepancy.findAll({ + attributes: ["provider", "resolutionStatus"], + where: { provider: providerWhere }, + transaction + }); + const pendingTargets = await this.getPendingTargets(providers, transaction); + const unprocessedBlockEventCount = await VerificationBlockEvent.count({ where: { isProcessed: false }, transaction }); + + return { + params, + attestations, + attestationCapabilities, + providerObservations, + graces, + maintenances, + snapshots, + discrepancies, + pendingTargets, + hasUnprocessedBlockEvents: unprocessedBlockEventCount > 0 + }; + } + + private async getCurrentStateInTransaction(providers: string[], transaction: DbTransaction): Promise { + const providerWhere = { [Op.in]: providers }; + const params = await VerificationParams.findByPk(1, { transaction }); + const attestations = await VerificationAttestation.findAll({ where: { provider: providerWhere }, transaction }); + const attestationCapabilities = await VerificationAttestationCapability.findAll({ where: { provider: providerWhere }, transaction }); + const auditEscrows = await VerificationAuditEscrow.findAll({ where: { provider: providerWhere }, transaction }); + const auditEscrowCapabilities = await VerificationAuditEscrowCapability.findAll({ + where: { auditEscrowId: { [Op.in]: auditEscrows.map(escrow => escrow.id) } }, + transaction + }); + const bonds = await VerificationProviderBond.findAll({ where: { provider: providerWhere }, transaction }); + const bondUnbondingEntries = await VerificationProviderBondUnbonding.findAll({ where: { provider: providerWhere }, transaction }); + const providerObservations = await VerificationProviderObservation.findAll({ where: { provider: providerWhere }, transaction }); + const graces = await VerificationGrace.findAll({ where: { provider: providerWhere }, transaction }); + const graceDiscrepancies = await VerificationGraceDiscrepancy.findAll({ + where: { graceId: { [Op.in]: graces.map(grace => grace.id) } }, + transaction + }); + const maintenances = await ProviderMaintenance.findAll({ where: { provider: providerWhere }, transaction }); + const snapshots = await VerificationProviderSnapshot.findAll({ where: { provider: providerWhere }, transaction }); + const discrepancies = await VerificationDiscrepancy.findAll({ where: { provider: providerWhere }, transaction }); + const pendingTargets = await this.getPendingTargets(providers, transaction); + const unprocessedBlockEventCount = await VerificationBlockEvent.count({ where: { isProcessed: false }, transaction }); + + return { + params, + attestations, + attestationCapabilities, + auditEscrows, + auditEscrowCapabilities, + bonds, + bondUnbondingEntries, + providerObservations, + graces, + graceDiscrepancies, + maintenances, + snapshots, + discrepancies, + pendingTargets, + hasUnprocessedBlockEvents: unprocessedBlockEventCount > 0 + }; + } + + private async getPendingTargets(providers: string[], transaction: DbTransaction): Promise { + return VerificationReconcileTarget.findAll({ + where: { + invalidated: true, + [Op.or]: [ + { targetType: "all_providers" }, + { targetType: "provider", targetKey: { [Op.in]: providers } }, + { targetType: { [Op.in]: ["global", "auditor", "audit_escrow", "discrepancy"] } } + ] + }, + transaction + }); + } +} diff --git a/apps/api/src/provider/provider-verification/provider-verification.schema.ts b/apps/api/src/provider/provider-verification/provider-verification.schema.ts new file mode 100644 index 0000000000..b854a4df7e --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.schema.ts @@ -0,0 +1,223 @@ +import { z } from "@hono/zod-openapi"; + +const NullableTimestampSchema = z.string().datetime().nullable(); +const NullableBase64Schema = z.string().nullable().openapi({ description: "Base64-encoded bytes, or null when the chain field is empty" }); +const UIntStringSchema = z.string().regex(/^\d+$/); + +export const ProviderVerificationTierSchema = z.enum(["L0", "L1", "L2", "L3", "L4", "unknown"]); +export const ProviderVerificationCapabilitySchema = z.enum([ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown" +]); +export type ProviderVerificationTier = z.infer; +export type ProviderVerificationCapability = z.infer; + +const CoinSchema = z.object({ + denom: z.string(), + amount: UIntStringSchema +}); + +const AttestationSchema = z.object({ + provider: z.string(), + auditor: z.string(), + tier: ProviderVerificationTierSchema, + capabilities: z.array(ProviderVerificationCapabilitySchema), + evidenceHash: NullableBase64Schema, + fee: CoinSchema.nullable(), + feeStatus: z.enum(["unspecified", "escrowed", "released_to_auditor", "returned_to_provider", "unknown"]), + createdAt: NullableTimestampSchema, + expiresAt: NullableTimestampSchema, + status: z.enum(["unspecified", "valid", "voided", "expired", "revoked", "removed", "unknown"]), + voidedReason: z.enum(["unspecified", "discrepancy", "governance", "bond_withdrawn", "bond_slashed", "unknown"]), + deposit: CoinSchema.nullable(), + depositStatus: z.enum(["unspecified", "escrowed", "pending_discrepancy", "returned_to_auditor", "slashed", "unknown"]), + auditEscrowId: UIntStringSchema, + faultAttribution: z.enum(["unspecified", "provider_fault", "auditor_fault", "shared_fault", "no_fault", "inconclusive", "unknown"]) +}); + +const UnbondingEntrySchema = z.object({ + amount: CoinSchema.nullable(), + completionTime: NullableTimestampSchema +}); + +const ProviderBondSchema = z.object({ + provider: z.string(), + bondedAmount: CoinSchema.nullable(), + requiredForCurrentTier: CoinSchema, + unbondingEntries: z.array(UnbondingEntrySchema), + slashed: z.boolean(), + lastSlashTime: NullableTimestampSchema +}); + +const SoftwareIdentitySchema = z.object({ + version: z.string(), + artifactRef: z.string(), + digestAlgorithm: z.string(), + digest: NullableBase64Schema, + signatureType: z.string(), + signature: NullableBase64Schema, + signatureRef: z.string(), + publicKeyRef: z.string() +}); + +const ResourceSummarySchema = z.object({ + totalGpus: z.number().int().nonnegative(), + totalVcpus: z.number().int().nonnegative(), + totalMemoryMb: UIntStringSchema, + totalStorageMb: UIntStringSchema, + activeLeases: z.number().int().nonnegative(), + softwareVersion: z.string(), + softwareSignature: NullableBase64Schema, + softwareIdentity: SoftwareIdentitySchema.nullable() +}); + +const ProviderSnapshotSchema = z.object({ + provider: z.string(), + snapshotHash: NullableBase64Schema, + resourceSummary: ResourceSummarySchema.nullable(), + postedAt: NullableTimestampSchema, + snapshotTimestamp: NullableTimestampSchema, + complianceDeadline: NullableTimestampSchema, + suspended: z.boolean() +}); + +const AuditEscrowSchema = z.object({ + id: UIntStringSchema, + provider: z.string(), + consumedByAuditor: z.string().nullable(), + requestedTier: ProviderVerificationTierSchema, + requestedCapabilities: z.array(ProviderVerificationCapabilitySchema), + fee: CoinSchema.nullable(), + feeStatus: z.enum(["unspecified", "escrowed", "released_to_auditor", "returned_to_provider", "unknown"]), + providerDeposit: CoinSchema.nullable(), + providerDepositStatus: z.enum(["unspecified", "escrowed", "returned_to_provider", "slashed", "unknown"]), + status: z.enum(["unspecified", "open", "consumed", "cancelled", "expired", "settled", "unknown"]), + openedAt: NullableTimestampSchema, + consumedAt: NullableTimestampSchema, + expiresAt: NullableTimestampSchema, + metadataHash: NullableBase64Schema, + settlementReason: z.enum(["unspecified", "cancelled_unconsumed", "expired_unconsumed", "provider_fault", "no_fault", "unknown"]), + faultAttribution: z.enum(["unspecified", "provider_fault", "auditor_fault", "shared_fault", "no_fault", "inconclusive", "unknown"]) +}); + +const VerificationGraceSchema = z.object({ + id: UIntStringSchema, + provider: z.string(), + preservedTier: ProviderVerificationTierSchema, + sourceDiscrepancyIds: z.array(UIntStringSchema), + startedAt: NullableTimestampSchema, + expiresAt: NullableTimestampSchema, + status: z.enum(["unspecified", "active", "expired", "terminated", "unknown"]) +}); + +const ProviderMaintenanceRecordSchema = z.object({ + id: UIntStringSchema, + provider: z.string(), + maintenanceType: z.enum(["unspecified", "planned", "emergency", "security", "network", "capacity", "unknown"]), + startsAt: NullableTimestampSchema, + expectedEndsAt: NullableTimestampSchema, + openedAt: NullableTimestampSchema, + closedAt: NullableTimestampSchema, + metadataHash: NullableBase64Schema +}); + +const ProviderMaintenanceSchema = z.object({ + record: ProviderMaintenanceRecordSchema.nullable(), + status: z.enum(["unspecified", "scheduled", "active", "elapsed", "closed", "unknown"]) +}); + +const DiscrepancySchema = z.object({ + id: UIntStringSchema, + provider: z.string(), + auditorA: z.string(), + auditorATier: ProviderVerificationTierSchema, + auditorB: z.string(), + auditorBTier: ProviderVerificationTierSchema, + timestamp: NullableTimestampSchema, + resolutionStatus: z.enum(["unspecified", "pending", "resolved", "timed_out", "unknown"]), + resolutionProposalId: UIntStringSchema, + graceRecordId: UIntStringSchema, + resolutionReason: z.enum([ + "unspecified", + "auditor_a_correct", + "auditor_b_correct", + "both_auditors_wrong", + "provider_fault", + "shared_fault", + "evidence_inconclusive", + "governance_timeout_review", + "unknown" + ]), + faultAttribution: z.enum(["unspecified", "provider_fault", "auditor_fault", "shared_fault", "no_fault", "inconclusive", "unknown"]), + resolutionEvidenceHash: NullableBase64Schema +}); + +export const ProviderVerificationCompletenessSchema = z.object({ + params: z.boolean(), + attestations: z.boolean(), + graces: z.boolean(), + snapshot: z.boolean(), + bond: z.boolean(), + auditEscrows: z.boolean(), + maintenance: z.boolean(), + discrepancies: z.boolean() +}); + +export const ProviderVerificationSnapshotStateSchema = z.enum(["unknown", "not_posted", "current", "stale", "suspended"]); +const MaintenanceStateSchema = z.enum(["unknown", "none", "scheduled", "active"]); +const ReviewStateSchema = z.enum(["unknown", "none", "under_review", "grace"]); + +export const ProviderVerificationListViewSchema = z.object({ + provider: z.string(), + moduleActive: z.boolean().nullable(), + summary: z.object({ + effectiveTier: ProviderVerificationTierSchema.nullable().openapi({ description: "Tier used by the chain tier gate, including active discrepancy grace" }), + validAuditorCount: z.number().int().nonnegative().nullable(), + capabilities: z.array(ProviderVerificationCapabilitySchema).nullable(), + snapshotState: ProviderVerificationSnapshotStateSchema, + maintenanceState: MaintenanceStateSchema, + reviewState: ReviewStateSchema + }), + observedAt: z.string().datetime(), + observedHeight: UIntStringSchema +}); + +export const ProviderVerificationViewSchema = z.object({ + provider: z.string(), + providerDeclaredTier: z.string().nullable().openapi({ description: "Legacy, self-declared provider tier attribute; not an AEP-86 attestation" }), + moduleActive: z.boolean().nullable(), + provenance: z.object({ + providerTier: z.literal("provider self-declared"), + inventory: z.literal("provider-signed inventory"), + attestations: z.literal("auditor-attested") + }), + summary: z.object({ + bestAttestedTier: ProviderVerificationTierSchema.nullable(), + effectiveTier: ProviderVerificationTierSchema.nullable().openapi({ description: "Tier used by the chain tier gate, including active discrepancy grace" }), + capabilities: z.array(ProviderVerificationCapabilitySchema).nullable(), + validAttestationCount: z.number().int().nonnegative().nullable(), + validAuditorCount: z.number().int().nonnegative().nullable(), + validAuditors: z.array(z.string()).nullable(), + snapshotState: ProviderVerificationSnapshotStateSchema, + maintenanceState: MaintenanceStateSchema, + reviewState: ReviewStateSchema + }), + attestations: z.array(AttestationSchema), + bond: ProviderBondSchema.nullable(), + snapshot: ProviderSnapshotSchema.nullable(), + grace: VerificationGraceSchema.nullable(), + auditEscrows: z.array(AuditEscrowSchema), + maintenance: z.array(ProviderMaintenanceSchema), + discrepancies: z.array(DiscrepancySchema), + observedAt: z.string().datetime(), + observedHeight: UIntStringSchema, + completeness: ProviderVerificationCompletenessSchema +}); + +export type ProviderVerificationListView = z.infer; +export type ProviderVerificationView = z.infer; +export type ProviderVerificationViewCompleteness = z.infer; diff --git a/apps/api/src/provider/provider-verification/provider-verification.service.spec.ts b/apps/api/src/provider/provider-verification/provider-verification.service.spec.ts new file mode 100644 index 0000000000..11e92218bf --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.service.spec.ts @@ -0,0 +1,298 @@ +import { + AttestationStatus, + CapabilityFlag, + DiscrepancyStatus, + VerificationGraceStatus, + VerificationTier +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { ProviderMaintenanceStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import type { + ProviderMaintenance, + VerificationAttestation, + VerificationAttestationCapability, + VerificationDiscrepancy, + VerificationGrace, + VerificationParams, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { ProviderVerificationRepository } from "./provider-verification.repository"; +import { type ProviderVerificationIndexedRows, type ProviderVerificationSummaryIndexedRows } from "./provider-verification.repository"; +import { ProviderVerificationListViewSchema, ProviderVerificationViewSchema } from "./provider-verification.schema"; +import { ProviderVerificationService } from "./provider-verification.service"; + +const PROVIDER = "akash1provider"; + +describe(ProviderVerificationService.name, () => { + beforeEach(() => vi.useFakeTimers({ now: new Date("2026-08-24T12:00:00.000Z") })); + afterEach(() => vi.useRealTimers()); + + it("maps a batch list summary without loading detail state", async () => { + const { service, repository } = setup(); + repository.getSummaryState.mockResolvedValue( + summaryRows({ + attestations: [ + row({ + provider: PROVIDER, + auditor: "akash1auditor", + tier: VerificationTier.verification_tier_verified, + status: AttestationStatus.attestation_status_valid + }) + ], + attestationCapabilities: [ + row({ + provider: PROVIDER, + auditor: "akash1auditor", + capability: CapabilityFlag.capability_persistent_storage + }) + ], + graces: [ + row({ + provider: PROVIDER, + preservedTier: VerificationTier.verification_tier_established, + status: VerificationGraceStatus.verification_grace_status_active, + observedHeight: 125 + }) + ], + maintenances: [row({ provider: PROVIDER, status: ProviderMaintenanceStatus.provider_maintenance_status_scheduled })], + snapshots: [ + row({ + provider: PROVIDER, + complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), + suspended: false + }) + ], + discrepancies: [row({ provider: PROVIDER, resolutionStatus: DiscrepancyStatus.discrepancy_status_pending })] + }) + ); + + const result = await service.getSummaries([PROVIDER, PROVIDER]); + const view = result.get(PROVIDER); + + expect(repository.getSummaryState).toHaveBeenCalledWith([PROVIDER]); + expect(repository.getCurrentState).not.toHaveBeenCalled(); + expect(ProviderVerificationListViewSchema.parse(view)).toEqual(view); + expect(view).toEqual({ + provider: PROVIDER, + moduleActive: true, + summary: { + effectiveTier: "L3", + validAuditorCount: 1, + capabilities: ["persistent_storage"], + snapshotState: "current", + maintenanceState: "scheduled", + reviewState: "under_review" + }, + observedAt: "2026-08-24T11:59:00.000Z", + observedHeight: "125" + }); + }); + + it("returns null list summaries until canonical verification params have been indexed", async () => { + const { service, repository } = setup(); + repository.getSummaryState.mockResolvedValue(summaryRows({ params: null })); + + const result = await service.getSummaries([PROVIDER]); + + expect(result.get(PROVIDER)).toBeNull(); + }); + + it("returns null until canonical verification params have been indexed", async () => { + const { service, repository } = setup(); + repository.getCurrentState.mockResolvedValue(indexedRows({ params: null })); + + const result = await service.getViews([{ provider: PROVIDER, providerDeclaredTier: "community" }]); + + expect(result.get(PROVIDER)).toBeNull(); + }); + + it("maps a complete indexed provider state without chain queries", async () => { + const { service, repository } = setup(); + repository.getCurrentState.mockResolvedValue( + indexedRows({ + attestations: [ + row({ + provider: PROVIDER, + auditor: "akash1auditor", + tier: VerificationTier.verification_tier_identified, + evidenceHash: Buffer.from([1, 2, 3]), + feeDenom: "uakt", + feeAmount: "100", + feeStatus: 1, + createdAt: new Date("2026-08-23T12:00:00.000Z"), + expiresAt: new Date("2027-08-23T12:00:00.000Z"), + status: AttestationStatus.attestation_status_valid, + voidedReason: 0, + depositDenom: "uakt", + depositAmount: "1000", + depositStatus: 1, + auditEscrowId: "7", + faultAttribution: 0, + observedHeight: 125, + observedBlockTime: new Date("2026-08-24T11:59:00.000Z") + }) + ], + attestationCapabilities: [ + row({ + provider: PROVIDER, + auditor: "akash1auditor", + capability: CapabilityFlag.capability_persistent_storage, + observedHeight: 125, + observedBlockTime: new Date("2026-08-24T11:59:00.000Z") + }) + ] + }) + ); + + const result = await service.getViews([{ provider: PROVIDER, providerDeclaredTier: "community" }]); + const view = result.get(PROVIDER); + + expect(ProviderVerificationViewSchema.safeParse(view).success).toBe(true); + expect(view).toMatchObject({ + provider: PROVIDER, + providerDeclaredTier: "community", + moduleActive: true, + summary: { + bestAttestedTier: "L1", + effectiveTier: "L1", + capabilities: ["persistent_storage"], + validAuditorCount: 1, + snapshotState: "not_posted" + }, + observedHeight: "125", + completeness: { + params: true, + attestations: true, + graces: true, + snapshot: true + } + }); + }); + + it("marks facts unknown while provider reconciliation is pending", async () => { + const { service, repository } = setup(); + repository.getCurrentState.mockResolvedValue( + indexedRows({ + pendingTargets: [row({ targetType: "provider", targetKey: PROVIDER, requestedHeight: 126 })] + }) + ); + + const result = await service.getViews([{ provider: PROVIDER, providerDeclaredTier: null }]); + + expect(result.get(PROVIDER)).toMatchObject({ + summary: { + bestAttestedTier: null, + effectiveTier: null, + capabilities: null, + snapshotState: "unknown" + }, + completeness: { + attestations: false, + graces: false, + snapshot: false, + bond: false, + auditEscrows: false, + maintenance: false, + discrepancies: false + } + }); + }); + + it("marks an empty provider state incomplete until it has a reconciliation watermark", async () => { + const { service, repository } = setup(); + repository.getCurrentState.mockResolvedValue(indexedRows({ providerObservations: [] })); + + const result = await service.getViews([{ provider: PROVIDER, providerDeclaredTier: null }]); + + expect(result.get(PROVIDER)).toMatchObject({ + observedHeight: "0", + summary: { effectiveTier: null, snapshotState: "unknown" }, + completeness: { attestations: false, graces: false, snapshot: false } + }); + }); + + it("hides module activation while a canonical params refresh is pending", async () => { + const { service, repository } = setup(); + repository.getCurrentState.mockResolvedValue( + indexedRows({ + pendingTargets: [row({ targetType: "global", targetKey: "*", requestedHeight: 126, invalidated: true })] + }) + ); + + const result = await service.getViews([{ provider: PROVIDER, providerDeclaredTier: null }]); + + expect(result.get(PROVIDER)).toMatchObject({ moduleActive: null, completeness: { params: false } }); + }); +}); + +function setup() { + const repository = mock(); + return { repository, service: new ProviderVerificationService(repository) }; +} + +function indexedRows(overrides: Partial = {}): ProviderVerificationIndexedRows { + return { + params: row({ + id: 1, + params: { verification_module_active: true }, + observedHeight: 100, + observedBlockTime: new Date("2026-08-24T11:55:00.000Z") + }), + attestations: [], + attestationCapabilities: [], + auditEscrows: [], + auditEscrowCapabilities: [], + bonds: [], + bondUnbondingEntries: [], + providerObservations: [ + row({ + provider: PROVIDER, + observedHeight: 125, + observedBlockTime: new Date("2026-08-24T11:59:00.000Z") + }) + ], + graces: [], + graceDiscrepancies: [], + maintenances: [], + snapshots: [], + discrepancies: [], + pendingTargets: [], + hasUnprocessedBlockEvents: false, + ...overrides + }; +} + +function summaryRows(overrides: Partial = {}): ProviderVerificationSummaryIndexedRows { + return { + params: row({ + id: 1, + params: { verification_module_active: true }, + observedHeight: 100, + observedBlockTime: new Date("2026-08-24T11:55:00.000Z") + }), + attestations: [], + attestationCapabilities: [], + providerObservations: [ + row({ + provider: PROVIDER, + observedHeight: 125, + observedBlockTime: new Date("2026-08-24T11:59:00.000Z") + }) + ], + graces: [], + maintenances: [], + snapshots: [], + discrepancies: [], + pendingTargets: [], + hasUnprocessedBlockEvents: false, + ...overrides + }; +} + +function row(value: Partial): T { + return value as T; +} diff --git a/apps/api/src/provider/provider-verification/provider-verification.service.ts b/apps/api/src/provider/provider-verification/provider-verification.service.ts new file mode 100644 index 0000000000..1cbfe8b5cb --- /dev/null +++ b/apps/api/src/provider/provider-verification/provider-verification.service.ts @@ -0,0 +1,403 @@ +import type { + AttestationRecord, + AuditEscrowRecord, + DiscrepancyEvent, + ProviderSnapshotRecord, + ProviderVerificationGraceRecord, + SoftwareIdentity +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderMaintenanceWithStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import type { + ProviderMaintenance, + VerificationAttestation, + VerificationAuditEscrow, + VerificationDiscrepancy, + VerificationGrace, + VerificationProviderBond, + VerificationProviderSnapshot +} from "@akashnetwork/database/dbSchemas/akash"; +import type { ProviderVerificationFacts } from "@akashnetwork/provider-verification"; +import { singleton } from "tsyringe"; + +import { mapProviderVerificationListView, mapProviderVerificationView, type ProviderBondWithRequirement } from "./provider-verification.mapper"; +import { + type ProviderVerificationIndexedRows, + ProviderVerificationRepository, + type ProviderVerificationSummaryIndexedRows +} from "./provider-verification.repository"; +import type { ProviderVerificationListView, ProviderVerificationView } from "./provider-verification.schema"; + +export interface ProviderVerificationSubject { + provider: string; + providerDeclaredTier: string | null; +} + +@singleton() +export class ProviderVerificationService { + constructor(private readonly repository: ProviderVerificationRepository) {} + + async getSummaries(providers: readonly string[]): Promise> { + const distinctProviders = [...new Set(providers)]; + if (distinctProviders.length === 0) return new Map(); + + const rows = await this.repository.getSummaryState(distinctProviders); + const moduleActive = readModuleActive(rows.params?.params); + + if (moduleActive === null) { + return new Map(distinctProviders.map(provider => [provider, null])); + } + + const completeness = getCompleteness(rows); + const index = indexSummaryRows(rows); + + return new Map( + distinctProviders.map(provider => { + const observation = index.observationByProvider.get(provider); + const complete = !!observation && !completeness.globalIncomplete && !completeness.pendingProviders.has(provider); + const facts = toSummaryFacts(index, provider, complete, observation?.observedBlockTime ?? new Date(0), String(observation?.observedHeight ?? 0)); + const grace = index.gracesByProvider.get(provider)?.[0]; + + return [ + provider, + mapProviderVerificationListView({ + provider, + moduleActive, + facts, + maintenanceStatuses: index.maintenancesByProvider.get(provider)?.map(item => item.status) ?? [], + discrepancyStatuses: index.discrepanciesByProvider.get(provider)?.map(item => item.resolutionStatus) ?? [], + graceStatus: grace?.status ?? null, + completeness: { + params: !completeness.paramsIncomplete, + maintenance: complete, + discrepancies: complete + } + }) + ]; + }) + ); + } + + async getViews(subjects: readonly ProviderVerificationSubject[]): Promise> { + const providers = [...new Set(subjects.map(subject => subject.provider))]; + if (providers.length === 0) return new Map(); + + const rows = await this.repository.getCurrentState(providers); + const moduleActive = readModuleActive(rows.params?.params); + + if (moduleActive === null) { + return new Map(providers.map(provider => [provider, null])); + } + + const declaredTierByProvider = new Map(subjects.map(subject => [subject.provider, subject.providerDeclaredTier])); + const completeness = getCompleteness(rows); + + return new Map( + providers.map(provider => { + const observation = rows.providerObservations.find(item => item.provider === provider); + const complete = !!observation && !completeness.globalIncomplete && !completeness.pendingProviders.has(provider); + const records = toCurrentRecords(rows, provider); + const facts = toFacts(rows, provider, records, complete, observation?.observedBlockTime ?? new Date(0), String(observation?.observedHeight ?? 0)); + + return [ + provider, + mapProviderVerificationView({ + provider, + providerDeclaredTier: declaredTierByProvider.get(provider) ?? null, + moduleActive, + facts, + records, + completeness: { + params: !completeness.paramsIncomplete, + bond: complete, + auditEscrows: complete, + maintenance: complete, + discrepancies: complete + } + }) + ]; + }) + ); + } +} + +interface ProviderVerificationSummaryIndex { + observationByProvider: Map; + attestationsByProvider: Map; + capabilitiesByProviderAndAuditor: Map>; + gracesByProvider: Map; + maintenancesByProvider: Map; + snapshotByProvider: Map; + discrepanciesByProvider: Map; +} + +function indexSummaryRows(rows: ProviderVerificationSummaryIndexedRows): ProviderVerificationSummaryIndex { + const gracesByProvider = groupByProvider(rows.graces); + for (const graces of gracesByProvider.values()) graces.sort(compareObservedDesc); + + const capabilitiesByProviderAndAuditor = new Map>(); + for (const capability of rows.attestationCapabilities) { + const capabilitiesByAuditor = capabilitiesByProviderAndAuditor.get(capability.provider) ?? new Map(); + const capabilities = capabilitiesByAuditor.get(capability.auditor) ?? []; + capabilities.push(capability.capability); + capabilitiesByAuditor.set(capability.auditor, capabilities); + capabilitiesByProviderAndAuditor.set(capability.provider, capabilitiesByAuditor); + } + + return { + observationByProvider: new Map(rows.providerObservations.map(item => [item.provider, item])), + attestationsByProvider: groupByProvider(rows.attestations), + capabilitiesByProviderAndAuditor, + gracesByProvider, + maintenancesByProvider: groupByProvider(rows.maintenances), + snapshotByProvider: new Map(rows.snapshots.map(item => [item.provider, item])), + discrepanciesByProvider: groupByProvider(rows.discrepancies) + }; +} + +function toSummaryFacts( + index: ProviderVerificationSummaryIndex, + provider: string, + complete: boolean, + observedAt: Date, + observedHeight: string +): ProviderVerificationFacts { + const capabilitiesByAuditor = index.capabilitiesByProviderAndAuditor.get(provider); + const attestations = (index.attestationsByProvider.get(provider) ?? []).map(attestation => ({ + auditor: attestation.auditor, + capabilities: capabilitiesByAuditor?.get(attestation.auditor) ?? [], + status: attestation.status, + tier: attestation.tier + })); + const snapshot = index.snapshotByProvider.get(provider); + + return { + attestations, + graces: (index.gracesByProvider.get(provider) ?? []).map(grace => ({ preservedTier: grace.preservedTier, status: grace.status })), + snapshot: snapshot ? { complianceDeadline: snapshot.complianceDeadline, suspended: snapshot.suspended } : null, + completeness: { attestations: complete, graces: complete, snapshot: complete }, + observedAt, + observedHeight + }; +} + +function groupByProvider(records: readonly T[]): Map { + const grouped = new Map(); + for (const record of records) { + const providerRecords = grouped.get(record.provider) ?? []; + providerRecords.push(record); + grouped.set(record.provider, providerRecords); + } + return grouped; +} + +function toFacts( + rows: ProviderVerificationIndexedRows, + provider: string, + records: ReturnType, + complete: boolean, + observedAt: Date, + observedHeight: string +): ProviderVerificationFacts { + return { + attestations: records.attestations, + graces: rows.graces.filter(grace => grace.provider === provider).map(grace => toGrace(grace, rows)), + snapshot: records.snapshot, + completeness: { attestations: complete, graces: complete, snapshot: complete }, + observedAt, + observedHeight + }; +} + +function toCurrentRecords(rows: ProviderVerificationIndexedRows, provider: string) { + const graces = rows.graces.filter(grace => grace.provider === provider).sort(compareObservedDesc); + + return { + attestations: rows.attestations.filter(attestation => attestation.provider === provider).map(attestation => toAttestation(attestation, rows)), + bond: optionalOne(rows.bonds, provider, record => toBond(record, rows)), + snapshot: optionalOne(rows.snapshots, provider, toSnapshot), + grace: graces[0] ? toGrace(graces[0], rows) : null, + auditEscrows: rows.auditEscrows.filter(escrow => escrow.provider === provider).map(escrow => toAuditEscrow(escrow, rows)), + maintenance: rows.maintenances.filter(maintenance => maintenance.provider === provider).map(toMaintenance), + discrepancies: rows.discrepancies.filter(discrepancy => discrepancy.provider === provider).map(toDiscrepancy) + }; +} + +function toAttestation(record: VerificationAttestation, rows: ProviderVerificationIndexedRows): AttestationRecord { + return { + provider: record.provider, + auditor: record.auditor, + tier: record.tier, + capabilities: rows.attestationCapabilities + .filter(capability => capability.provider === record.provider && capability.auditor === record.auditor) + .map(capability => capability.capability), + evidenceHash: toBytes(record.evidenceHash), + fee: toCoin(record.feeDenom, record.feeAmount), + feeStatus: record.feeStatus, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + status: record.status, + voidedReason: record.voidedReason, + deposit: toCoin(record.depositDenom, record.depositAmount), + depositStatus: record.depositStatus, + auditEscrowId: BigInt(record.auditEscrowId), + faultAttribution: record.faultAttribution + }; +} + +function toBond(record: VerificationProviderBond, rows: ProviderVerificationIndexedRows): ProviderBondWithRequirement { + return { + provider: record.provider, + bondedAmount: toCoin(record.bondedDenom, record.bondedAmount), + requiredForCurrentTier: { denom: record.requiredForCurrentTierDenom, amount: record.requiredForCurrentTierAmount }, + unbondingEntries: rows.bondUnbondingEntries + .filter(entry => entry.provider === record.provider) + .sort((left, right) => left.entryIndex - right.entryIndex) + .map(entry => ({ amount: toCoin(entry.denom, entry.amount), completionTime: entry.completionTime })), + slashed: record.slashed, + lastSlashTime: record.lastSlashTime + }; +} + +function toSnapshot(record: VerificationProviderSnapshot): ProviderSnapshotRecord { + return { + provider: record.provider, + snapshotHash: toBytes(record.snapshotHash), + resourceSummary: { + totalGpus: record.totalGpus, + totalVcpus: record.totalVcpus, + totalMemoryMb: BigInt(record.totalMemoryMb), + totalStorageMb: BigInt(record.totalStorageMb), + activeLeases: record.activeLeases, + softwareVersion: record.softwareVersion, + softwareSignature: toBytes(record.softwareSignature), + softwareIdentity: toSoftwareIdentity(record) + }, + postedAt: record.postedAt, + snapshotTimestamp: record.snapshotTimestamp, + complianceDeadline: record.complianceDeadline, + suspended: record.suspended + }; +} + +function toSoftwareIdentity(record: VerificationProviderSnapshot): SoftwareIdentity | undefined { + if ( + !record.softwareIdentityVersion && + !record.softwareArtifactRef && + !record.softwareDigestAlgorithm && + !record.softwareDigest && + !record.softwareSignatureType && + !record.softwareIdentitySignature && + !record.softwareSignatureRef && + !record.softwarePublicKeyRef + ) { + return undefined; + } + + return { + version: record.softwareIdentityVersion ?? "", + artifactRef: record.softwareArtifactRef ?? "", + digestAlgorithm: record.softwareDigestAlgorithm ?? "", + digest: toBytes(record.softwareDigest), + signatureType: record.softwareSignatureType ?? "", + signature: toBytes(record.softwareIdentitySignature), + signatureRef: record.softwareSignatureRef ?? "", + publicKeyRef: record.softwarePublicKeyRef ?? "" + }; +} + +function toGrace(record: VerificationGrace, rows: ProviderVerificationIndexedRows): ProviderVerificationGraceRecord { + return { + id: BigInt(record.id), + provider: record.provider, + preservedTier: record.preservedTier, + sourceDiscrepancyIds: rows.graceDiscrepancies.filter(source => source.graceId === record.id).map(source => BigInt(source.discrepancyId)), + startedAt: record.startedAt, + expiresAt: record.expiresAt, + status: record.status + }; +} + +function toAuditEscrow(record: VerificationAuditEscrow, rows: ProviderVerificationIndexedRows): AuditEscrowRecord { + return { + id: BigInt(record.id), + provider: record.provider, + consumedByAuditor: record.consumedByAuditor, + requestedTier: record.requestedTier, + requestedCapabilities: rows.auditEscrowCapabilities.filter(capability => capability.auditEscrowId === record.id).map(capability => capability.capability), + fee: toCoin(record.feeDenom, record.feeAmount), + feeStatus: record.feeStatus, + providerDeposit: toCoin(record.providerDepositDenom, record.providerDepositAmount), + providerDepositStatus: record.providerDepositStatus, + status: record.status, + openedAt: record.openedAt, + consumedAt: record.consumedAt, + expiresAt: record.expiresAt, + metadataHash: toBytes(record.metadataHash), + settlementReason: record.settlementReason, + faultAttribution: record.faultAttribution + }; +} + +function toMaintenance(record: ProviderMaintenance): ProviderMaintenanceWithStatus { + return { + record: { + id: BigInt(record.id), + provider: record.provider, + maintenanceType: record.maintenanceType, + startsAt: record.startsAt, + expectedEndsAt: record.expectedEndsAt, + openedAt: record.openedAt, + closedAt: record.closedAt, + metadataHash: toBytes(record.metadataHash) + }, + status: record.status + }; +} + +function toDiscrepancy(record: VerificationDiscrepancy): DiscrepancyEvent { + return { + id: BigInt(record.id), + provider: record.provider, + auditorA: record.auditorA, + auditorATier: record.auditorATier, + auditorB: record.auditorB, + auditorBTier: record.auditorBTier, + timestamp: record.detectedAt, + resolutionStatus: record.resolutionStatus, + resolutionProposalId: BigInt(record.resolutionProposalId), + graceRecordId: BigInt(record.graceRecordId), + resolutionReason: record.resolutionReason, + faultAttribution: record.faultAttribution, + resolutionEvidenceHash: toBytes(record.resolutionEvidenceHash) + }; +} + +function readModuleActive(params: Record | undefined): boolean | null { + const value = params?.verification_module_active; + return typeof value === "boolean" ? value : null; +} + +function getCompleteness(rows: ProviderVerificationSummaryIndexedRows) { + return { + paramsIncomplete: rows.hasUnprocessedBlockEvents || rows.pendingTargets.some(target => target.targetType === "global" && target.invalidated), + globalIncomplete: rows.hasUnprocessedBlockEvents || rows.pendingTargets.some(target => target.targetType !== "provider"), + pendingProviders: new Set(rows.pendingTargets.filter(target => target.targetType === "provider").map(target => target.targetKey)) + }; +} + +function optionalOne(records: T[], provider: string, map: (record: T) => R): R | null { + const record = records.find(item => item.provider === provider); + return record ? map(record) : null; +} + +function toCoin(denom: string, amount: string): { denom: string; amount: string } | undefined { + return denom || amount !== "0" ? { denom, amount } : undefined; +} + +function toBytes(value: Uint8Array | null | undefined): Uint8Array { + return value ? Uint8Array.from(value) : new Uint8Array(); +} + +function compareObservedDesc(left: { observedHeight: number }, right: { observedHeight: number }): number { + return right.observedHeight - left.observedHeight; +} diff --git a/apps/api/src/provider/routes/providers/providers.router.ts b/apps/api/src/provider/routes/providers/providers.router.ts index a36a661976..2a0fa46cbf 100644 --- a/apps/api/src/provider/routes/providers/providers.router.ts +++ b/apps/api/src/provider/routes/providers/providers.router.ts @@ -2,6 +2,7 @@ import type { TypedResponse } from "hono"; import { container } from "tsyringe"; import { createRoute } from "@src/core/lib/create-route/create-route"; +import { CoreConfigService } from "@src/core/services/core-config/core-config.service"; import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; import { ProviderController } from "@src/provider/controllers/provider/provider.controller"; @@ -17,6 +18,8 @@ import { export const providersRouter = new OpenApiHonoHandler(); +const isProviderVerificationEnabled = () => container.resolve(CoreConfigService).get("AEP86_PROVIDER_VERIFICATION_ENABLED"); + const providerListRoute = createRoute({ method: "get", path: "/v1/providers", @@ -45,13 +48,17 @@ providersRouter.openapi(providerListRoute, async function routeListProviders(c) if (addresses) { const data = await controller.getFilteredProviderList(scope, addresses); + if (isProviderVerificationEnabled()) c.header("Cache-Control", "public, no-store"); return c.json(data) as TypedResponse; } const buffer = await controller.getProviderListBuffer(scope); return new Response(buffer, { status: 200, - headers: { "Content-Type": "application/json" } + headers: { + "Content-Type": "application/json", + ...(isProviderVerificationEnabled() ? { "Cache-Control": "public, no-store" } : {}) + } }) as unknown as TypedResponse; }); @@ -94,6 +101,7 @@ providersRouter.openapi(providerRoute, async function routeGetProvider(c) { return c.text("Provider not found.", 404); } + if (isProviderVerificationEnabled()) c.header("Cache-Control", "public, no-store"); return c.json(provider); }); diff --git a/apps/api/src/provider/services/provider/provider.service.spec.ts b/apps/api/src/provider/services/provider/provider.service.spec.ts index 46041e0615..0575a5861a 100644 --- a/apps/api/src/provider/services/provider/provider.service.spec.ts +++ b/apps/api/src/provider/services/provider/provider.service.spec.ts @@ -1,5 +1,5 @@ import type { JwtTokenPayload } from "@akashnetwork/chain-sdk"; -import type { Provider } from "@akashnetwork/database/dbSchemas/akash"; +import { type Provider, ProviderSnapshot } from "@akashnetwork/database/dbSchemas/akash"; import type { ProviderAttributesSchema } from "@akashnetwork/http-sdk"; import { faker } from "@faker-js/faker"; import { AxiosError } from "axios"; @@ -8,7 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import { cacheEngine } from "@src/caching/helpers"; +import type { CoreConfigService } from "@src/core/services/core-config/core-config.service"; import { AUDITOR } from "@src/deployment/config/provider.config"; +import type { ProviderVerificationListView, ProviderVerificationView } from "@src/provider/provider-verification/provider-verification.schema"; +import type { ProviderVerificationService } from "@src/provider/provider-verification/provider-verification.service"; +import type { ProviderVerificationReadinessService } from "@src/provider/provider-verification/provider-verification-readiness.service"; import { createLeaseStatus } from "../../../../test/seeders/lease-status.seeder"; import { createProviderSeed, createProviderWithAttributeSignatures } from "../../../../test/seeders/provider.seeder"; import { createUserWallet } from "../../../../test/seeders/user-wallet.seeder"; @@ -19,6 +23,8 @@ import type { ProviderJwtTokenService } from "../provider-jwt-token/provider-jwt import { ProviderService } from "./provider.service"; import type { ProviderProxyService } from "./provider-proxy.service"; +import { mockConfigService } from "@test/mocks/config-service.mock"; + const schemaDetail = { key: "test", type: "string" as const, required: false, description: "test", values: null }; const providerAttributeSchemaStub: ProviderAttributesSchema = { host: schemaDetail, @@ -441,6 +447,77 @@ describe(ProviderService.name, () => { expect(result).toHaveLength(1); }); + + it("returns a stable null verification field while AEP-86 is disabled", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService } = setup(); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([provider]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + + const result = await service.getProviderList(); + + expect(result[0].verification).toBeNull(); + expect(providerVerificationService.getSummaries).not.toHaveBeenCalled(); + expect(providerVerificationService.getViews).not.toHaveBeenCalled(); + }); + + it("batch-attaches indexed verification when AEP-86 is enabled", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService } = setup({ + verificationEnabled: true + }); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + const verification = { provider: provider.owner } as ProviderVerificationListView; + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([provider]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + providerVerificationService.getSummaries.mockResolvedValue(new Map([[provider.owner, verification]])); + + const result = await service.getProviderList(); + + expect(providerVerificationService.getSummaries).toHaveBeenCalledWith([provider.owner]); + expect(providerVerificationService.getViews).not.toHaveBeenCalled(); + expect(result[0].verification).toBe(verification); + }); + + it("returns null verification while the indexer is behind the connected chain", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService, providerVerificationReadiness } = + setup({ verificationEnabled: true, verificationReady: false }); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([provider]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + + const result = await service.getProviderList(); + + expect(providerVerificationReadiness.isReady).toHaveBeenCalledOnce(); + expect(providerVerificationService.getSummaries).not.toHaveBeenCalled(); + expect(result[0].verification).toBeNull(); + }); + + it("attaches verification immediately after indexer readiness recovers", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService, providerVerificationReadiness } = + setup({ verificationEnabled: true }); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + const verification = { provider: provider.owner } as ProviderVerificationListView; + providerRepository.getWithAttributesAndAuditors.mockResolvedValue([provider]); + providerRepository.getProviderWithNodes.mockResolvedValue([]); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + providerVerificationReadiness.isReady.mockResolvedValueOnce(false).mockResolvedValue(true); + providerVerificationService.getSummaries.mockResolvedValue(new Map([[provider.owner, verification]])); + + const beforeRecovery = await service.getProviderList(); + const afterRecovery = await service.getProviderList(); + + expect(beforeRecovery[0].verification).toBeNull(); + expect(afterRecovery[0].verification).toBe(verification); + expect(providerRepository.getWithAttributesAndAuditors).toHaveBeenCalledOnce(); + expect(providerVerificationService.getSummaries).toHaveBeenCalledOnce(); + }); }); describe("getProviderListByAddresses", () => { @@ -508,7 +585,58 @@ describe(ProviderService.name, () => { }); }); - function setup() { + describe("getProvider", () => { + beforeEach(() => { + cacheEngine.clearAllKeyInCache(); + }); + + it("attaches the same persisted verification view to provider detail", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService } = setup({ + verificationEnabled: true + }); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + const verification = { provider: provider.owner } as ProviderVerificationView; + providerRepository.getProviderByAddressWithAttributes.mockResolvedValue(provider); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + providerVerificationService.getViews.mockResolvedValue(new Map([[provider.owner, verification]])); + const findSnapshots = vi.spyOn(ProviderSnapshot, "findAll").mockResolvedValue([]); + const findSnapshot = vi.spyOn(ProviderSnapshot, "findOne").mockResolvedValue(null); + + const result = await service.getProvider(provider.owner); + + expect(result?.verification).toBe(verification); + expect(providerVerificationService.getViews).toHaveBeenCalledWith([{ provider: provider.owner, providerDeclaredTier: result?.tier ?? null }]); + findSnapshots.mockRestore(); + findSnapshot.mockRestore(); + }); + + it("attaches verification immediately after indexer readiness recovers", async () => { + const { service, providerRepository, auditorsService, providerAttributesSchemaService, providerVerificationService, providerVerificationReadiness } = + setup({ verificationEnabled: true }); + const provider = createProviderWithAttributeSignatures(AUDITOR) as unknown as Provider; + const verification = { provider: provider.owner } as ProviderVerificationView; + providerRepository.getProviderByAddressWithAttributes.mockResolvedValue(provider); + auditorsService.getAuditors.mockResolvedValue([]); + providerAttributesSchemaService.getProviderAttributesSchema.mockResolvedValue(providerAttributeSchemaStub); + providerVerificationReadiness.isReady.mockResolvedValueOnce(false).mockResolvedValue(true); + providerVerificationService.getViews.mockResolvedValue(new Map([[provider.owner, verification]])); + const findSnapshots = vi.spyOn(ProviderSnapshot, "findAll").mockResolvedValue([]); + const findSnapshot = vi.spyOn(ProviderSnapshot, "findOne").mockResolvedValue(null); + + const beforeRecovery = await service.getProvider(provider.owner); + const afterRecovery = await service.getProvider(provider.owner); + + expect(beforeRecovery?.verification).toBeNull(); + expect(afterRecovery?.verification).toBe(verification); + expect(providerRepository.getProviderByAddressWithAttributes).toHaveBeenCalledOnce(); + expect(providerVerificationService.getViews).toHaveBeenCalledOnce(); + findSnapshots.mockRestore(); + findSnapshot.mockRestore(); + }); + }); + + function setup({ verificationEnabled = false, verificationReady = true }: { verificationEnabled?: boolean; verificationReady?: boolean } = {}) { const providerProxyService = mock(); const providerRepository = mock(); const providerAttributesSchemaService = mock(); @@ -516,8 +644,23 @@ describe(ProviderService.name, () => { const jwtTokenService = mock({ generateJwtToken: vi.fn().mockResolvedValue(Ok("mock-jwt-token")) }); + const providerVerificationService = mock({ + getSummaries: vi.fn().mockResolvedValue(new Map()), + getViews: vi.fn().mockResolvedValue(new Map()) + }); + const providerVerificationReadiness = mock({ isReady: vi.fn().mockResolvedValue(verificationReady) }); + const coreConfig = mockConfigService({ AEP86_PROVIDER_VERIFICATION_ENABLED: verificationEnabled }); - const service = new ProviderService(providerProxyService, providerRepository, providerAttributesSchemaService, auditorsService, jwtTokenService); + const service = new ProviderService( + providerProxyService, + providerRepository, + providerAttributesSchemaService, + auditorsService, + jwtTokenService, + providerVerificationService, + providerVerificationReadiness, + coreConfig + ); return { service, @@ -525,7 +668,9 @@ describe(ProviderService.name, () => { providerAttributesSchemaService, auditorsService, jwtTokenService, - providerProxyService + providerProxyService, + providerVerificationService, + providerVerificationReadiness }; } }); diff --git a/apps/api/src/provider/services/provider/provider.service.ts b/apps/api/src/provider/services/provider/provider.service.ts index fa5a57f933..d2ad23d1f2 100644 --- a/apps/api/src/provider/services/provider/provider.service.ts +++ b/apps/api/src/provider/services/provider/provider.service.ts @@ -8,8 +8,12 @@ import { Op } from "sequelize"; import { singleton } from "tsyringe"; import { Memoize } from "@src/caching/helpers"; +import { CoreConfigService } from "@src/core/services/core-config/core-config.service"; import { LeaseStatusResponse } from "@src/deployment/http-schemas/lease.schema"; import type { Auditor } from "@src/provider/http-schemas/auditor.schema"; +import type { ProviderVerificationListView, ProviderVerificationView } from "@src/provider/provider-verification/provider-verification.schema"; +import { ProviderVerificationService } from "@src/provider/provider-verification/provider-verification.service"; +import { ProviderVerificationReadinessService } from "@src/provider/provider-verification/provider-verification-readiness.service"; import { ProviderRepository } from "@src/provider/repositories/provider/provider.repository"; import { ProviderAuth, ProviderIdentity, ProviderProxyService } from "@src/provider/services/provider/provider-proxy.service"; import { ProviderJwtTokenService } from "@src/provider/services/provider-jwt-token/provider-jwt-token.service"; @@ -21,6 +25,8 @@ import { AuditorService } from "../auditors/auditors.service"; import { ProviderAttributesSchemaService } from "../provider-attributes-schema/provider-attributes-schema.service"; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +type ProviderListWithVerificationSummary = Omit & { verification: ProviderVerificationListView | null }; +type WithVerificationView = Omit & { verification: ProviderVerificationView | null }; @singleton() export class ProviderService { @@ -32,7 +38,10 @@ export class ProviderService { private readonly providerRepository: ProviderRepository, private readonly providerAttributesSchemaService: ProviderAttributesSchemaService, private readonly auditorsService: AuditorService, - private readonly jwtTokenService: ProviderJwtTokenService + private readonly jwtTokenService: ProviderJwtTokenService, + private readonly providerVerificationService: ProviderVerificationService, + private readonly providerVerificationReadiness: ProviderVerificationReadinessService, + private readonly coreConfig: CoreConfigService ) {} async sendManifest(options: { provider: string; dseq: string; manifest: string; auth: ProviderAuth }) { @@ -127,8 +136,13 @@ export class ProviderService { }); } + async getProviderList(trial = false): Promise { + const providers = await this.getProviderListBase(trial); + return this.attachVerificationSummaries(providers); + } + @Memoize({ ttlInSeconds: 60 }) - async getProviderList(trial = false): Promise { + private async getProviderListBase(trial = false): Promise { // Fetch providers in batches to avoid blocking event loop during Sequelize hydration const BATCH_SIZE = 200; const providersWithAttributesAndAuditors: Provider[] = []; @@ -180,7 +194,7 @@ export class ProviderService { return finalProviders; } - async getProviderListByAddresses(addresses: string[], trial = false): Promise { + async getProviderListByAddresses(addresses: string[], trial = false): Promise { const [providersWithAttributesAndAuditors, providerWithNodes, auditors, providerAttributeSchema] = await Promise.all([ this.providerRepository.getWithAttributesAndAuditors({ trial, addresses }), this.providerRepository.getProviderWithNodes({ addresses }), @@ -188,7 +202,7 @@ export class ProviderService { this.providerAttributesSchemaService.getProviderAttributesSchema() ]); - return this.mapProviderResults(providersWithAttributesAndAuditors, providerWithNodes, auditors, providerAttributeSchema); + return this.attachVerificationSummaries(this.mapProviderResults(providersWithAttributesAndAuditors, providerWithNodes, auditors, providerAttributeSchema)); } private mapProviderResults( @@ -217,8 +231,17 @@ export class ProviderService { }); } - @Memoize({ ttlInSeconds: 30 }) async getProvider(address: string) { + const provider = await this.getProviderBase(address); + + if (!provider) return null; + + const [withVerification] = await this.attachVerificationView([provider]); + return withVerification; + } + + @Memoize({ ttlInSeconds: 30 }) + private async getProviderBase(address: string) { const nowUtc = toUTC(new Date()); const provider = await this.providerRepository.getProviderByAddressWithAttributes(address); @@ -254,7 +277,7 @@ export class ProviderService { this.providerAttributesSchemaService.getProviderAttributesSchema() ]); - return { + const mappedProvider = { ...mapProviderToList(provider, providerAttributeSchema, auditors, lastSuccessfulSnapshot ?? undefined), uptime: uptimeSnapshots.map(ps => ({ id: ps.id, @@ -262,5 +285,29 @@ export class ProviderService { checkDate: ps.checkDate })) }; + + return mappedProvider; + } + + private async attachVerificationSummaries(providers: ProviderList[]): Promise { + if (!this.coreConfig.get("AEP86_PROVIDER_VERIFICATION_ENABLED") || !(await this.providerVerificationReadiness.isReady())) { + return providers.map(provider => ({ ...provider, verification: null })); + } + + const verificationByProvider = await this.providerVerificationService.getSummaries(providers.map(provider => provider.owner)); + + return providers.map(provider => ({ ...provider, verification: verificationByProvider.get(provider.owner) ?? null })); + } + + private async attachVerificationView(providers: T[]): Promise[]> { + if (!this.coreConfig.get("AEP86_PROVIDER_VERIFICATION_ENABLED") || !(await this.providerVerificationReadiness.isReady())) { + return providers.map(provider => ({ ...provider, verification: null })); + } + + const verificationByProvider = await this.providerVerificationService.getViews( + providers.map(provider => ({ provider: provider.owner, providerDeclaredTier: provider.tier })) + ); + + return providers.map(provider => ({ ...provider, verification: verificationByProvider.get(provider.owner) ?? null })); } } diff --git a/apps/api/src/routers/internalRouter.ts b/apps/api/src/routers/internalRouter.ts index 538ea08574..55c88be195 100644 --- a/apps/api/src/routers/internalRouter.ts +++ b/apps/api/src/routers/internalRouter.ts @@ -23,5 +23,6 @@ const swaggerInstance = swaggerUI({ url: `/internal/doc` }); internalRouter.get(`/swagger`, swaggerInstance); internalRouter.use("/financial", privateMiddleware); +internalRouter.use("/v1/provider-verification/tier-demotions", privateMiddleware); routes.forEach(route => internalRouter.route(`/`, route)); diff --git a/apps/api/src/routes/internal/index.ts b/apps/api/src/routes/internal/index.ts index d4e3af2baf..c1d8ec95b9 100644 --- a/apps/api/src/routes/internal/index.ts +++ b/apps/api/src/routes/internal/index.ts @@ -1,5 +1,13 @@ import { leasesDurationInternalRouter } from "@src/dashboard"; import { getGpuPricesInternalRouter, listGpuModelsInternalRouter, listGpusInternalRouter } from "@src/gpu"; import financial from "./financial"; +import providerVerificationTierDemotions from "./providerVerificationTierDemotions"; -export default [listGpusInternalRouter, listGpuModelsInternalRouter, leasesDurationInternalRouter, getGpuPricesInternalRouter, financial]; +export default [ + listGpusInternalRouter, + listGpuModelsInternalRouter, + leasesDurationInternalRouter, + getGpuPricesInternalRouter, + financial, + providerVerificationTierDemotions +]; diff --git a/apps/api/src/routes/internal/providerVerificationTierDemotions.ts b/apps/api/src/routes/internal/providerVerificationTierDemotions.ts new file mode 100644 index 0000000000..85d6fd4c4e --- /dev/null +++ b/apps/api/src/routes/internal/providerVerificationTierDemotions.ts @@ -0,0 +1,36 @@ +import { OpenAPIHono, z } from "@hono/zod-openapi"; +import { container } from "tsyringe"; + +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; +import { ProviderVerificationTierDemotionFeedSchema } from "@src/provider/provider-verification/provider-verification-tier-demotion.schema"; +import { ProviderVerificationTierDemotionService } from "@src/provider/provider-verification/provider-verification-tier-demotion.service"; + +const route = createRoute({ + method: "get", + path: "/v1/provider-verification/tier-demotions", + summary: "Read provider verification tier demotions", + security: SECURITY_NONE, + request: { + query: z.object({ + after: z.string().regex(/^\d+$/).default("0"), + limit: z.number({ coerce: true }).int().min(1).max(100).default(100) + }) + }, + responses: { + 200: { + description: "Ordered provider tier demotions", + content: { "application/json": { schema: ProviderVerificationTierDemotionFeedSchema } } + }, + 503: { + description: "Provider verification state is not caught up", + content: { "application/json": { schema: z.object({ error: z.literal("provider_verification_not_ready") }) } } + } + } +}); + +export default new OpenAPIHono().openapi(route, async c => { + const { after, limit } = c.req.valid("query"); + const feed = await container.resolve(ProviderVerificationTierDemotionService).getFeed(after, limit); + return feed ? c.json(feed, 200) : c.json({ error: "provider_verification_not_ready" as const }, 503); +}); diff --git a/apps/api/src/types/provider.ts b/apps/api/src/types/provider.ts index 2cc20efc4f..483070b4dc 100644 --- a/apps/api/src/types/provider.ts +++ b/apps/api/src/types/provider.ts @@ -1,3 +1,5 @@ +import type { ProviderVerificationView } from "@src/provider/provider-verification/provider-verification.schema"; + export interface ProviderList { owner: string; name: string | null; @@ -62,6 +64,7 @@ export interface ProviderList { workloadSupportChia: boolean; workloadSupportChiaCapabilities: string[] | null; featEndpointIp: boolean; + verification: ProviderVerificationView | null; } export interface ProviderCapacityStats { diff --git a/apps/api/src/utils/map/provider.ts b/apps/api/src/utils/map/provider.ts index ab75e52f1a..4fe70b62a3 100644 --- a/apps/api/src/utils/map/provider.ts +++ b/apps/api/src/utils/map/provider.ts @@ -103,7 +103,8 @@ export const mapProviderToList = ( featEndpointCustomDomain: getBooleanAttribute("feat-endpoint-custom-domain", attrMap, providerAttributeSchema), workloadSupportChia: getBooleanAttribute("workload-support-chia", attrMap, providerAttributeSchema), workloadSupportChiaCapabilities: getStringArrayAttribute("workload-support-chia-capabilities", provider, providerAttributeSchema), - featEndpointIp: getBooleanAttribute("feat-endpoint-ip", attrMap, providerAttributeSchema) + featEndpointIp: getBooleanAttribute("feat-endpoint-ip", attrMap, providerAttributeSchema), + verification: null }; }; diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index 2bef7483b6..ee9c2f5fcc 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -3083,7 +3083,7 @@ "properties": { "autoTopUpEnabled": { "type": "boolean", - "description": "Whether auto top-up is enabled for this deployment" + "description": "Whether auto top-up is enabled for this deployment. An explicit false is rejected once always-on funding is rolled out" }, "runtimeLimitHours": { "type": "integer", @@ -3265,8 +3265,7 @@ }, "autoTopUpEnabled": { "type": "boolean", - "default": false, - "description": "Whether auto top-up is enabled for this deployment" + "description": "Whether auto top-up is enabled for this deployment. Defaults to enabled when omitted; an explicit false is rejected once always-on funding is rolled out" } }, "required": [ @@ -3531,7 +3530,7 @@ "properties": { "autoTopUpEnabled": { "type": "boolean", - "description": "Whether auto top-up is enabled for this deployment" + "description": "Whether auto top-up is enabled for this deployment. An explicit false is rejected once always-on funding is rolled out" }, "runtimeLimitHours": { "type": "integer", @@ -3709,8 +3708,7 @@ }, "autoTopUpEnabled": { "type": "boolean", - "default": false, - "description": "Whether auto top-up is enabled for this deployment" + "description": "Whether auto top-up is enabled for this deployment. Defaults to enabled when omitted; an explicit false is rejected once always-on funding is rolled out" }, "userId": { "type": "string", @@ -7653,6 +7651,108 @@ } } }, + "/v1/sdl-secrets-context": { + "get": { + "summary": "Get SDL secrets encryption context", + "tags": [ + "SDL Secrets" + ], + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns SDL secrets context", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sub": { + "type": "string", + "description": "The subject of the SDL secrets context" + }, + "kid": { + "type": "string", + "description": "The key ID of the SDL secrets context" + }, + "jwk": { + "type": "object", + "properties": { + "kty": { + "type": "string" + }, + "n": { + "type": "string" + }, + "e": { + "type": "string" + }, + "use": { + "type": "string" + }, + "alg": { + "type": "string" + } + }, + "required": [ + "kty", + "n", + "e", + "use", + "alg" + ], + "description": "The JSON Web Key used to encrypt the SDL secrets" + }, + "requiredClaims": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "kid", + "sub", + "exp" + ] + }, + "description": "The required claims for the SDL secrets context" + } + }, + "required": [ + "sub", + "kid", + "jwk", + "requiredClaims" + ] + } + } + } + }, + "503": { + "description": "SDL secrets encryption is unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, "/v1/leases": { "post": { "summary": "Create leases and send manifest", @@ -10160,6 +10260,108 @@ }, "featEndpointIp": { "type": "boolean" + }, + "verification": { + "type": "object", + "nullable": true, + "properties": { + "provider": { + "type": "string" + }, + "moduleActive": { + "type": "boolean", + "nullable": true + }, + "summary": { + "type": "object", + "properties": { + "effectiveTier": { + "type": "string", + "nullable": true, + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ], + "description": "Tier used by the chain tier gate, including active discrepancy grace" + }, + "validAuditorCount": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "capabilities": { + "type": "array", + "nullable": true, + "items": { + "type": "string", + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown" + ] + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "maintenanceState": { + "type": "string", + "enum": [ + "unknown", + "none", + "scheduled", + "active" + ] + }, + "reviewState": { + "type": "string", + "enum": [ + "unknown", + "none", + "under_review", + "grace" + ] + } + }, + "required": [ + "effectiveTier", + "validAuditorCount", + "capabilities", + "snapshotState", + "maintenanceState", + "reviewState" + ] + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "observedHeight": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "provider", + "moduleActive", + "summary", + "observedAt", + "observedHeight" + ] } }, "required": [ @@ -10208,7 +10410,8 @@ "featEndpointCustomDomain", "workloadSupportChia", "workloadSupportChiaCapabilities", - "featEndpointIp" + "featEndpointIp", + "verification" ] } } @@ -10596,67 +10799,1090 @@ "featEndpointIp": { "type": "boolean" }, - "uptime": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" + "verification": { + "type": "object", + "nullable": true, + "properties": { + "provider": { + "type": "string" + }, + "providerDeclaredTier": { + "type": "string", + "nullable": true, + "description": "Legacy, self-declared provider tier attribute; not an AEP-86 attestation" + }, + "moduleActive": { + "type": "boolean", + "nullable": true + }, + "provenance": { + "type": "object", + "properties": { + "providerTier": { + "type": "string", + "enum": [ + "provider self-declared" + ] + }, + "inventory": { + "type": "string", + "enum": [ + "provider-signed inventory" + ] + }, + "attestations": { + "type": "string", + "enum": [ + "auditor-attested" + ] + } }, - "isOnline": { - "type": "boolean" + "required": [ + "providerTier", + "inventory", + "attestations" + ] + }, + "summary": { + "type": "object", + "properties": { + "bestAttestedTier": { + "type": "string", + "nullable": true, + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "effectiveTier": { + "type": "string", + "nullable": true, + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ], + "description": "Tier used by the chain tier gate, including active discrepancy grace" + }, + "capabilities": { + "type": "array", + "nullable": true, + "items": { + "type": "string", + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown" + ] + } + }, + "validAttestationCount": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "validAuditorCount": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "validAuditors": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "maintenanceState": { + "type": "string", + "enum": [ + "unknown", + "none", + "scheduled", + "active" + ] + }, + "reviewState": { + "type": "string", + "enum": [ + "unknown", + "none", + "under_review", + "grace" + ] + } }, - "checkDate": { - "type": "string" - } + "required": [ + "bestAttestedTier", + "effectiveTier", + "capabilities", + "validAttestationCount", + "validAuditorCount", + "validAuditors", + "snapshotState", + "maintenanceState", + "reviewState" + ] }, - "required": [ - "id", - "isOnline", - "checkDate" - ] - } - } - }, - "required": [ - "owner", - "name", - "hostUri", - "createdHeight", - "email", - "website", - "lastCheckDate", - "deploymentCount", - "leaseCount", - "cosmosSdkVersion", - "akashVersion", - "ipRegion", - "ipRegionCode", - "ipCountry", - "ipCountryCode", - "ipLat", - "ipLon", - "uptime1d", - "uptime7d", - "uptime30d", - "isValidVersion", - "isOnline", - "lastOnlineDate", - "isAudited", - "stats", - "gpuModels", - "attributes", - "host", - "organization", - "statusPage", - "locationRegion", - "country", - "city", - "timezone", - "locationType", - "hostingProvider", - "hardwareCpu", + "attestations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "auditor": { + "type": "string" + }, + "tier": { + "type": "string", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown" + ] + } + }, + "evidenceHash": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "fee": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "feeStatus": { + "type": "string", + "enum": [ + "unspecified", + "escrowed", + "released_to_auditor", + "returned_to_provider", + "unknown" + ] + }, + "createdAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "unspecified", + "valid", + "voided", + "expired", + "revoked", + "removed", + "unknown" + ] + }, + "voidedReason": { + "type": "string", + "enum": [ + "unspecified", + "discrepancy", + "governance", + "bond_withdrawn", + "bond_slashed", + "unknown" + ] + }, + "deposit": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "depositStatus": { + "type": "string", + "enum": [ + "unspecified", + "escrowed", + "pending_discrepancy", + "returned_to_auditor", + "slashed", + "unknown" + ] + }, + "auditEscrowId": { + "type": "string", + "pattern": "^\\d+$" + }, + "faultAttribution": { + "type": "string", + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown" + ] + } + }, + "required": [ + "provider", + "auditor", + "tier", + "capabilities", + "evidenceHash", + "fee", + "feeStatus", + "createdAt", + "expiresAt", + "status", + "voidedReason", + "deposit", + "depositStatus", + "auditEscrowId", + "faultAttribution" + ] + } + }, + "bond": { + "type": "object", + "nullable": true, + "properties": { + "provider": { + "type": "string" + }, + "bondedAmount": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "requiredForCurrentTier": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "unbondingEntries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "amount": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "completionTime": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "amount", + "completionTime" + ] + } + }, + "slashed": { + "type": "boolean" + }, + "lastSlashTime": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "provider", + "bondedAmount", + "requiredForCurrentTier", + "unbondingEntries", + "slashed", + "lastSlashTime" + ] + }, + "snapshot": { + "type": "object", + "nullable": true, + "properties": { + "provider": { + "type": "string" + }, + "snapshotHash": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "resourceSummary": { + "type": "object", + "nullable": true, + "properties": { + "totalGpus": { + "type": "integer", + "minimum": 0 + }, + "totalVcpus": { + "type": "integer", + "minimum": 0 + }, + "totalMemoryMb": { + "type": "string", + "pattern": "^\\d+$" + }, + "totalStorageMb": { + "type": "string", + "pattern": "^\\d+$" + }, + "activeLeases": { + "type": "integer", + "minimum": 0 + }, + "softwareVersion": { + "type": "string" + }, + "softwareSignature": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "softwareIdentity": { + "type": "object", + "nullable": true, + "properties": { + "version": { + "type": "string" + }, + "artifactRef": { + "type": "string" + }, + "digestAlgorithm": { + "type": "string" + }, + "digest": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "signatureType": { + "type": "string" + }, + "signature": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "signatureRef": { + "type": "string" + }, + "publicKeyRef": { + "type": "string" + } + }, + "required": [ + "version", + "artifactRef", + "digestAlgorithm", + "digest", + "signatureType", + "signature", + "signatureRef", + "publicKeyRef" + ] + } + }, + "required": [ + "totalGpus", + "totalVcpus", + "totalMemoryMb", + "totalStorageMb", + "activeLeases", + "softwareVersion", + "softwareSignature", + "softwareIdentity" + ] + }, + "postedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "snapshotTimestamp": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "complianceDeadline": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "suspended": { + "type": "boolean" + } + }, + "required": [ + "provider", + "snapshotHash", + "resourceSummary", + "postedAt", + "snapshotTimestamp", + "complianceDeadline", + "suspended" + ] + }, + "grace": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$" + }, + "provider": { + "type": "string" + }, + "preservedTier": { + "type": "string", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "sourceDiscrepancyIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "startedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "unspecified", + "active", + "expired", + "terminated", + "unknown" + ] + } + }, + "required": [ + "id", + "provider", + "preservedTier", + "sourceDiscrepancyIds", + "startedAt", + "expiresAt", + "status" + ] + }, + "auditEscrows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$" + }, + "provider": { + "type": "string" + }, + "consumedByAuditor": { + "type": "string", + "nullable": true + }, + "requestedTier": { + "type": "string", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "requestedCapabilities": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown" + ] + } + }, + "fee": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "feeStatus": { + "type": "string", + "enum": [ + "unspecified", + "escrowed", + "released_to_auditor", + "returned_to_provider", + "unknown" + ] + }, + "providerDeposit": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + }, + "providerDepositStatus": { + "type": "string", + "enum": [ + "unspecified", + "escrowed", + "returned_to_provider", + "slashed", + "unknown" + ] + }, + "status": { + "type": "string", + "enum": [ + "unspecified", + "open", + "consumed", + "cancelled", + "expired", + "settled", + "unknown" + ] + }, + "openedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "consumedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "metadataHash": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + }, + "settlementReason": { + "type": "string", + "enum": [ + "unspecified", + "cancelled_unconsumed", + "expired_unconsumed", + "provider_fault", + "no_fault", + "unknown" + ] + }, + "faultAttribution": { + "type": "string", + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown" + ] + } + }, + "required": [ + "id", + "provider", + "consumedByAuditor", + "requestedTier", + "requestedCapabilities", + "fee", + "feeStatus", + "providerDeposit", + "providerDepositStatus", + "status", + "openedAt", + "consumedAt", + "expiresAt", + "metadataHash", + "settlementReason", + "faultAttribution" + ] + } + }, + "maintenance": { + "type": "array", + "items": { + "type": "object", + "properties": { + "record": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$" + }, + "provider": { + "type": "string" + }, + "maintenanceType": { + "type": "string", + "enum": [ + "unspecified", + "planned", + "emergency", + "security", + "network", + "capacity", + "unknown" + ] + }, + "startsAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "expectedEndsAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "openedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "metadataHash": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + } + }, + "required": [ + "id", + "provider", + "maintenanceType", + "startsAt", + "expectedEndsAt", + "openedAt", + "closedAt", + "metadataHash" + ] + }, + "status": { + "type": "string", + "enum": [ + "unspecified", + "scheduled", + "active", + "elapsed", + "closed", + "unknown" + ] + } + }, + "required": [ + "record", + "status" + ] + } + }, + "discrepancies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$" + }, + "provider": { + "type": "string" + }, + "auditorA": { + "type": "string" + }, + "auditorATier": { + "type": "string", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "auditorB": { + "type": "string" + }, + "auditorBTier": { + "type": "string", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown" + ] + }, + "timestamp": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "resolutionStatus": { + "type": "string", + "enum": [ + "unspecified", + "pending", + "resolved", + "timed_out", + "unknown" + ] + }, + "resolutionProposalId": { + "type": "string", + "pattern": "^\\d+$" + }, + "graceRecordId": { + "type": "string", + "pattern": "^\\d+$" + }, + "resolutionReason": { + "type": "string", + "enum": [ + "unspecified", + "auditor_a_correct", + "auditor_b_correct", + "both_auditors_wrong", + "provider_fault", + "shared_fault", + "evidence_inconclusive", + "governance_timeout_review", + "unknown" + ] + }, + "faultAttribution": { + "type": "string", + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown" + ] + }, + "resolutionEvidenceHash": { + "type": "string", + "nullable": true, + "description": "Base64-encoded bytes, or null when the chain field is empty" + } + }, + "required": [ + "id", + "provider", + "auditorA", + "auditorATier", + "auditorB", + "auditorBTier", + "timestamp", + "resolutionStatus", + "resolutionProposalId", + "graceRecordId", + "resolutionReason", + "faultAttribution", + "resolutionEvidenceHash" + ] + } + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "observedHeight": { + "type": "string", + "pattern": "^\\d+$" + }, + "completeness": { + "type": "object", + "properties": { + "params": { + "type": "boolean" + }, + "attestations": { + "type": "boolean" + }, + "graces": { + "type": "boolean" + }, + "snapshot": { + "type": "boolean" + }, + "bond": { + "type": "boolean" + }, + "auditEscrows": { + "type": "boolean" + }, + "maintenance": { + "type": "boolean" + }, + "discrepancies": { + "type": "boolean" + } + }, + "required": [ + "params", + "attestations", + "graces", + "snapshot", + "bond", + "auditEscrows", + "maintenance", + "discrepancies" + ] + } + }, + "required": [ + "provider", + "providerDeclaredTier", + "moduleActive", + "provenance", + "summary", + "attestations", + "bond", + "snapshot", + "grace", + "auditEscrows", + "maintenance", + "discrepancies", + "observedAt", + "observedHeight", + "completeness" + ] + }, + "uptime": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "isOnline": { + "type": "boolean" + }, + "checkDate": { + "type": "string" + } + }, + "required": [ + "id", + "isOnline", + "checkDate" + ] + } + } + }, + "required": [ + "owner", + "name", + "hostUri", + "createdHeight", + "email", + "website", + "lastCheckDate", + "deploymentCount", + "leaseCount", + "cosmosSdkVersion", + "akashVersion", + "ipRegion", + "ipRegionCode", + "ipCountry", + "ipCountryCode", + "ipLat", + "ipLon", + "uptime1d", + "uptime7d", + "uptime30d", + "isValidVersion", + "isOnline", + "lastOnlineDate", + "isAudited", + "stats", + "gpuModels", + "attributes", + "host", + "organization", + "statusPage", + "locationRegion", + "country", + "city", + "timezone", + "locationType", + "hostingProvider", + "hardwareCpu", "hardwareCpuArch", "hardwareGpuVendor", "hardwareGpuModels", @@ -10672,6 +11898,7 @@ "workloadSupportChia", "workloadSupportChiaCapabilities", "featEndpointIp", + "verification", "uptime" ] } @@ -16545,6 +17772,117 @@ ] }, "default": [] + }, + "verification": { + "type": "object", + "properties": { + "minTier": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0 + ] + }, + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "number", + "enum": [ + 2 + ] + }, + { + "type": "number", + "enum": [ + 3 + ] + }, + { + "type": "number", + "enum": [ + 4 + ] + } + ] + }, + "requiredCapabilities": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "number", + "enum": [ + 2 + ] + }, + { + "type": "number", + "enum": [ + 3 + ] + }, + { + "type": "number", + "enum": [ + 4 + ] + } + ] + }, + "default": [] + }, + "requiredAuditors": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [] + }, + "auditorMode": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0 + ] + }, + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "number", + "enum": [ + 2 + ] + } + ], + "default": 0 + }, + "minAuditorCount": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "default": 0 + } + }, + "required": [ + "minTier" + ] } }, "default": {} @@ -16754,7 +18092,23 @@ "endpoints": { "type": "array", "items": { - "nullable": true + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "SHARED_HTTP", + "RANDOM_PORT", + "LEASED_IP", + "UNRECOGNIZED" + ] + }, + "sequenceNumber": { + "type": "integer", + "nullable": true, + "minimum": 0 + } + } } } }, @@ -16863,6 +18217,200 @@ "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", "example": "Akash" }, + "verification": { + "oneOf": [ + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "pass" + ] + }, + "summary": { + "type": "object", + "properties": { + "bestStatusValidTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "tierGateTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "validAttestationCount": { + "type": "integer", + "minimum": 0 + }, + "validAuditors": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "observedHeight": { + "type": "string" + } + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight" + ] + } + }, + "required": [ + "outcome", + "summary" + ] + }, + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "not_evaluated" + ] + }, + "incompleteFacts": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "params", + "attestations", + "graces", + "snapshot", + "module_inactive" + ] + } + }, + "summary": { + "type": "object", + "properties": { + "bestStatusValidTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "tierGateTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "validAttestationCount": { + "type": "integer", + "minimum": 0 + }, + "validAuditors": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "observedHeight": { + "type": "string" + } + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight" + ] + } + }, + "required": [ + "outcome", + "incompleteFacts", + "summary" + ] + } + ] + }, "incidents": { "type": "array", "items": { @@ -16906,6 +18454,430 @@ "incidents" ] } + }, + "exclusions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "firstFailure": { + "oneOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_not_posted" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_suspended" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_stale" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_tier" + ] + }, + "actual": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "required": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "required": [ + "code", + "actual", + "required" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "missing_capability" + ] + }, + "capability": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "required": [ + "code", + "capability" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_auditor_count" + ] + }, + "actual": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "code", + "actual", + "required" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "required_auditor_not_found" + ] + }, + "mode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + -1 + ] + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "code", + "mode", + "missing" + ] + } + ] + }, + "failures": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_not_posted" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_suspended" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "snapshot_stale" + ] + } + }, + "required": [ + "code" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_tier" + ] + }, + "actual": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "required": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "required": [ + "code", + "actual", + "required" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "missing_capability" + ] + }, + "capability": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "required": [ + "code", + "capability" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_auditor_count" + ] + }, + "actual": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "code", + "actual", + "required" + ] + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "required_auditor_not_found" + ] + }, + "mode": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + -1 + ] + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "code", + "mode", + "missing" + ] + } + ] + }, + "minItems": 1 + }, + "summary": { + "type": "object", + "properties": { + "bestStatusValidTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "tierGateTier": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + }, + "capabilities": { + "type": "array", + "items": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } + }, + "validAttestationCount": { + "type": "integer", + "minimum": 0 + }, + "validAuditors": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "observedHeight": { + "type": "string" + } + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight" + ] + } + }, + "required": [ + "owner", + "firstFailure", + "failures", + "summary" + ] + } } }, "required": [ diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index db7ce545f8..5b47b60b9b 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -2813,6 +2813,117 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "type": "object", }, + "verification": { + "properties": { + "auditorMode": { + "anyOf": [ + { + "enum": [ + 0, + ], + "type": "number", + }, + { + "enum": [ + 1, + ], + "type": "number", + }, + { + "enum": [ + 2, + ], + "type": "number", + }, + ], + "default": 0, + }, + "minAuditorCount": { + "default": 0, + "maximum": 4294967295, + "minimum": 0, + "type": "integer", + }, + "minTier": { + "anyOf": [ + { + "enum": [ + 0, + ], + "type": "number", + }, + { + "enum": [ + 1, + ], + "type": "number", + }, + { + "enum": [ + 2, + ], + "type": "number", + }, + { + "enum": [ + 3, + ], + "type": "number", + }, + { + "enum": [ + 4, + ], + "type": "number", + }, + ], + }, + "requiredAuditors": { + "default": [], + "items": { + "minLength": 1, + "type": "string", + }, + "type": "array", + }, + "requiredCapabilities": { + "default": [], + "items": { + "anyOf": [ + { + "enum": [ + 1, + ], + "type": "number", + }, + { + "enum": [ + 2, + ], + "type": "number", + }, + { + "enum": [ + 3, + ], + "type": "number", + }, + { + "enum": [ + 4, + ], + "type": "number", + }, + ], + }, + "type": "array", + }, + }, + "required": [ + "minTier", + ], + "type": "object", + }, }, "type": "object", }, @@ -2891,7 +3002,23 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "endpoints": { "items": { - "nullable": true, + "properties": { + "kind": { + "enum": [ + "SHARED_HTTP", + "RANDOM_PORT", + "LEASED_IP", + "UNRECOGNIZED", + ], + "type": "string", + }, + "sequenceNumber": { + "minimum": 0, + "nullable": true, + "type": "integer", + }, + }, + "type": "object", }, "type": "array", }, @@ -3085,237 +3212,855 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "application/json": { "schema": { "properties": { - "providers": { + "exclusions": { "items": { "properties": { - "createdAt": { - "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", - "example": "2026-01-01T00:00:00.000Z", - "format": "date-time", - "type": "string", - }, - "hostUri": { - "description": "Provider HTTPS endpoint", - "example": "https://provider.europlots.com:8443", - "type": "string", - }, - "incidents": { - "description": "Per-day downtime over a rolling 7-day window", + "failures": { "items": { - "properties": { - "date": { - "description": "Local calendar day, YYYY-MM-DD", - "example": "2026-06-01", - "type": "string", + "oneOf": [ + { + "properties": { + "code": { + "enum": [ + "snapshot_not_posted", + ], + "type": "string", + }, + }, + "required": [ + "code", + ], + "type": "object", }, - "downtimeSeconds": { - "description": "Downtime clipped to that day, in seconds (max 86400)", - "type": "integer", + { + "properties": { + "code": { + "enum": [ + "snapshot_suspended", + ], + "type": "string", + }, + }, + "required": [ + "code", + ], + "type": "object", }, - "hasOpenIncident": { - "description": "True if the provider currently has any open incident", - "type": "boolean", + { + "properties": { + "code": { + "enum": [ + "snapshot_stale", + ], + "type": "string", + }, + }, + "required": [ + "code", + ], + "type": "object", }, - "incidentCount": { - "description": "Number of incident intervals overlapping that day", - "type": "integer", + { + "properties": { + "actual": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "code": { + "enum": [ + "insufficient_tier", + ], + "type": "string", + }, + "required": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + }, + "required": [ + "code", + "actual", + "required", + ], + "type": "object", + }, + { + "properties": { + "capability": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "code": { + "enum": [ + "missing_capability", + ], + "type": "string", + }, + }, + "required": [ + "code", + "capability", + ], + "type": "object", + }, + { + "properties": { + "actual": { + "minimum": 0, + "type": "integer", + }, + "code": { + "enum": [ + "insufficient_auditor_count", + ], + "type": "string", + }, + "required": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "code", + "actual", + "required", + ], + "type": "object", + }, + { + "properties": { + "code": { + "enum": [ + "required_auditor_not_found", + ], + "type": "string", + }, + "missing": { + "items": { + "type": "string", + }, + "type": "array", + }, + "mode": { + "enum": [ + 0, + 1, + 2, + -1, + ], + "type": "integer", + }, + }, + "required": [ + "code", + "mode", + "missing", + ], + "type": "object", }, - }, - "required": [ - "date", - "hasOpenIncident", - "incidentCount", - "downtimeSeconds", ], - "type": "object", }, + "minItems": 1, "type": "array", }, - "isAudited": { - "description": "True if signed by a known auditor", - "type": "boolean", - }, - "location": { - "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", - "example": "us-west", - "nullable": true, - "type": "string", - }, - "organization": { - "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", - "example": "Akash", - "nullable": true, - "type": "string", - }, - "owner": { - "description": "Provider address", - "example": "akash1q7spv2cw06yszgfp4f9ed59lkka6ytn8g4tkjf", - "type": "string", - }, - }, - "required": [ - "owner", - "hostUri", - "isAudited", - "createdAt", - "location", - "organization", - "incidents", - ], - "type": "object", - }, - "type": "array", - }, - }, - "required": [ - "providers", - ], - "type": "object", - }, - }, - }, - "description": "Returns matching providers", - }, - "400": { - "description": "Invalid request body", - }, - }, - "security": [], - "summary": "Screen providers by deployment resource requirements", - "tags": [ - "Bid Screening", - ], - }, - }, - "/v1/bids": { - "get": { - "operationId": "listBids", - "parameters": [ - { - "in": "query", - "name": "dseq", - "required": true, - "schema": { - "pattern": "^d+$", - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "properties": { - "data": { - "items": { - "properties": { - "bid": { - "properties": { - "created_at": { - "type": "string", - }, - "id": { + "firstFailure": { + "oneOf": [ + { "properties": { - "bseq": { - "type": "number", + "code": { + "enum": [ + "snapshot_not_posted", + ], + "type": "string", }, - "dseq": { - "pattern": "^d+$", + }, + "required": [ + "code", + ], + "type": "object", + }, + { + "properties": { + "code": { + "enum": [ + "snapshot_suspended", + ], "type": "string", }, - "gseq": { - "type": "number", + }, + "required": [ + "code", + ], + "type": "object", + }, + { + "properties": { + "code": { + "enum": [ + "snapshot_stale", + ], + "type": "string", }, - "oseq": { - "type": "number", + }, + "required": [ + "code", + ], + "type": "object", + }, + { + "properties": { + "actual": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", }, - "owner": { + "code": { + "enum": [ + "insufficient_tier", + ], "type": "string", }, - "provider": { + "required": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + }, + "required": [ + "code", + "actual", + "required", + ], + "type": "object", + }, + { + "properties": { + "capability": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "code": { + "enum": [ + "missing_capability", + ], "type": "string", }, }, "required": [ - "owner", - "dseq", - "gseq", - "oseq", - "provider", - "bseq", + "code", + "capability", ], "type": "object", }, - "price": { + { "properties": { - "amount": { + "actual": { + "minimum": 0, + "type": "integer", + }, + "code": { + "enum": [ + "insufficient_auditor_count", + ], "type": "string", }, - "denom": { + "required": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "code", + "actual", + "required", + ], + "type": "object", + }, + { + "properties": { + "code": { + "enum": [ + "required_auditor_not_found", + ], "type": "string", }, + "missing": { + "items": { + "type": "string", + }, + "type": "array", + }, + "mode": { + "enum": [ + 0, + 1, + 2, + -1, + ], + "type": "integer", + }, }, "required": [ - "denom", - "amount", + "code", + "mode", + "missing", ], "type": "object", }, - "reclamation_window": { + ], + }, + "owner": { + "type": "string", + }, + "summary": { + "properties": { + "bestStatusValidTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "capabilities": { + "items": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "type": "array", + }, + "observedHeight": { "type": "string", }, - "resources_offer": { + "snapshotState": { + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended", + ], + "type": "string", + }, + "tierGateTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "validAttestationCount": { + "minimum": 0, + "type": "integer", + }, + "validAuditors": { "items": { - "properties": { - "count": { - "type": "number", - }, - "resources": { - "properties": { - "cpu": { - "properties": { - "attributes": { - "items": { - "properties": { - "key": { - "type": "string", - }, - "value": { - "type": "string", - }, - }, - "required": [ - "key", - "value", - ], - "type": "object", - }, - "type": "array", - }, - "units": { - "properties": { - "val": { - "type": "string", - }, - }, - "required": [ - "val", - ], - "type": "object", - }, - }, - "required": [ - "units", - "attributes", + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight", + ], + "type": "object", + }, + }, + "required": [ + "owner", + "firstFailure", + "failures", + "summary", + ], + "type": "object", + }, + "type": "array", + }, + "providers": { + "items": { + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", + "example": "2026-01-01T00:00:00.000Z", + "format": "date-time", + "type": "string", + }, + "hostUri": { + "description": "Provider HTTPS endpoint", + "example": "https://provider.europlots.com:8443", + "type": "string", + }, + "incidents": { + "description": "Per-day downtime over a rolling 7-day window", + "items": { + "properties": { + "date": { + "description": "Local calendar day, YYYY-MM-DD", + "example": "2026-06-01", + "type": "string", + }, + "downtimeSeconds": { + "description": "Downtime clipped to that day, in seconds (max 86400)", + "type": "integer", + }, + "hasOpenIncident": { + "description": "True if the provider currently has any open incident", + "type": "boolean", + }, + "incidentCount": { + "description": "Number of incident intervals overlapping that day", + "type": "integer", + }, + }, + "required": [ + "date", + "hasOpenIncident", + "incidentCount", + "downtimeSeconds", + ], + "type": "object", + }, + "type": "array", + }, + "isAudited": { + "description": "True if signed by a known auditor", + "type": "boolean", + }, + "location": { + "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", + "example": "us-west", + "nullable": true, + "type": "string", + }, + "organization": { + "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", + "example": "Akash", + "nullable": true, + "type": "string", + }, + "owner": { + "description": "Provider address", + "example": "akash1q7spv2cw06yszgfp4f9ed59lkka6ytn8g4tkjf", + "type": "string", + }, + "verification": { + "oneOf": [ + { + "properties": { + "outcome": { + "enum": [ + "pass", + ], + "type": "string", + }, + "summary": { + "properties": { + "bestStatusValidTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "capabilities": { + "items": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, ], - "type": "object", + "type": "integer", }, - "endpoints": { - "items": { - "properties": { - "kind": { - "type": "string", + "type": "array", + }, + "observedHeight": { + "type": "string", + }, + "snapshotState": { + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended", + ], + "type": "string", + }, + "tierGateTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "validAttestationCount": { + "minimum": 0, + "type": "integer", + }, + "validAuditors": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight", + ], + "type": "object", + }, + }, + "required": [ + "outcome", + "summary", + ], + "type": "object", + }, + { + "properties": { + "incompleteFacts": { + "items": { + "enum": [ + "params", + "attestations", + "graces", + "snapshot", + "module_inactive", + ], + "type": "string", + }, + "type": "array", + }, + "outcome": { + "enum": [ + "not_evaluated", + ], + "type": "string", + }, + "summary": { + "properties": { + "bestStatusValidTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "capabilities": { + "items": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "type": "array", + }, + "observedHeight": { + "type": "string", + }, + "snapshotState": { + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended", + ], + "type": "string", + }, + "tierGateTier": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1, + ], + "type": "integer", + }, + "validAttestationCount": { + "minimum": 0, + "type": "integer", + }, + "validAuditors": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight", + ], + "type": "object", + }, + }, + "required": [ + "outcome", + "incompleteFacts", + "summary", + ], + "type": "object", + }, + ], + }, + }, + "required": [ + "owner", + "hostUri", + "isAudited", + "createdAt", + "location", + "organization", + "incidents", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "providers", + ], + "type": "object", + }, + }, + }, + "description": "Returns matching providers", + }, + "400": { + "description": "Invalid request body", + }, + }, + "security": [], + "summary": "Screen providers by deployment resource requirements", + "tags": [ + "Bid Screening", + ], + }, + }, + "/v1/bids": { + "get": { + "operationId": "listBids", + "parameters": [ + { + "in": "query", + "name": "dseq", + "required": true, + "schema": { + "pattern": "^d+$", + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "items": { + "properties": { + "bid": { + "properties": { + "created_at": { + "type": "string", + }, + "id": { + "properties": { + "bseq": { + "type": "number", + }, + "dseq": { + "pattern": "^d+$", + "type": "string", + }, + "gseq": { + "type": "number", + }, + "oseq": { + "type": "number", + }, + "owner": { + "type": "string", + }, + "provider": { + "type": "string", + }, + }, + "required": [ + "owner", + "dseq", + "gseq", + "oseq", + "provider", + "bseq", + ], + "type": "object", + }, + "price": { + "properties": { + "amount": { + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "reclamation_window": { + "type": "string", + }, + "resources_offer": { + "items": { + "properties": { + "count": { + "type": "number", + }, + "resources": { + "properties": { + "cpu": { + "properties": { + "attributes": { + "items": { + "properties": { + "key": { + "type": "string", + }, + "value": { + "type": "string", + }, + }, + "required": [ + "key", + "value", + ], + "type": "object", + }, + "type": "array", + }, + "units": { + "properties": { + "val": { + "type": "string", + }, + }, + "required": [ + "val", + ], + "type": "object", + }, + }, + "required": [ + "units", + "attributes", + ], + "type": "object", + }, + "endpoints": { + "items": { + "properties": { + "kind": { + "type": "string", }, "sequence_number": { "type": "number", @@ -12414,31 +13159,133 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "nullable": true, "type": "number", }, - "website": { - "nullable": true, - "type": "string", - }, - "workloadSupportChia": { - "type": "boolean", - }, - "workloadSupportChiaCapabilities": { - "items": { - "type": "string", - }, + "verification": { "nullable": true, - "type": "array", - }, - }, - "required": [ - "owner", - "name", - "hostUri", - "createdHeight", - "cosmosSdkVersion", - "akashVersion", - "ipRegion", - "ipRegionCode", - "ipCountry", + "properties": { + "moduleActive": { + "nullable": true, + "type": "boolean", + }, + "observedAt": { + "format": "date-time", + "type": "string", + }, + "observedHeight": { + "pattern": "^\\d+$", + "type": "string", + }, + "provider": { + "type": "string", + }, + "summary": { + "properties": { + "capabilities": { + "items": { + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown", + ], + "type": "string", + }, + "nullable": true, + "type": "array", + }, + "effectiveTier": { + "description": "Tier used by the chain tier gate, including active discrepancy grace", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "nullable": true, + "type": "string", + }, + "maintenanceState": { + "enum": [ + "unknown", + "none", + "scheduled", + "active", + ], + "type": "string", + }, + "reviewState": { + "enum": [ + "unknown", + "none", + "under_review", + "grace", + ], + "type": "string", + }, + "snapshotState": { + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended", + ], + "type": "string", + }, + "validAuditorCount": { + "minimum": 0, + "nullable": true, + "type": "integer", + }, + }, + "required": [ + "effectiveTier", + "validAuditorCount", + "capabilities", + "snapshotState", + "maintenanceState", + "reviewState", + ], + "type": "object", + }, + }, + "required": [ + "provider", + "moduleActive", + "summary", + "observedAt", + "observedHeight", + ], + "type": "object", + }, + "website": { + "nullable": true, + "type": "string", + }, + "workloadSupportChia": { + "type": "boolean", + }, + "workloadSupportChiaCapabilities": { + "items": { + "type": "string", + }, + "nullable": true, + "type": "array", + }, + }, + "required": [ + "owner", + "name", + "hostUri", + "createdHeight", + "cosmosSdkVersion", + "akashVersion", + "ipRegion", + "ipRegionCode", + "ipCountry", "ipCountryCode", "ipLat", "ipLon", @@ -12476,6 +13323,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "workloadSupportChia", "workloadSupportChiaCapabilities", "featEndpointIp", + "verification", ], "type": "object", }, @@ -12741,138 +13589,1161 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "active": { "type": "number", }, - "available": { - "type": "number", + "available": { + "type": "number", + }, + "pending": { + "type": "number", + }, + }, + "required": [ + "active", + "available", + "pending", + ], + "type": "object", + }, + "memory": { + "properties": { + "active": { + "type": "number", + }, + "available": { + "type": "number", + }, + "pending": { + "type": "number", + }, + }, + "required": [ + "active", + "available", + "pending", + ], + "type": "object", + }, + "storage": { + "properties": { + "ephemeral": { + "properties": { + "active": { + "type": "number", + }, + "available": { + "type": "number", + }, + "pending": { + "type": "number", + }, + }, + "required": [ + "active", + "available", + "pending", + ], + "type": "object", + }, + "persistent": { + "properties": { + "active": { + "type": "number", + }, + "available": { + "type": "number", + }, + "pending": { + "type": "number", + }, + }, + "required": [ + "active", + "available", + "pending", + ], + "type": "object", + }, + }, + "required": [ + "ephemeral", + "persistent", + ], + "type": "object", + }, + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage", + ], + "type": "object", + }, + "statusPage": { + "nullable": true, + "type": "string", + }, + "tier": { + "nullable": true, + "type": "string", + }, + "timezone": { + "nullable": true, + "type": "string", + }, + "uptime": { + "items": { + "properties": { + "checkDate": { + "type": "string", + }, + "id": { + "type": "string", + }, + "isOnline": { + "type": "boolean", + }, + }, + "required": [ + "id", + "isOnline", + "checkDate", + ], + "type": "object", + }, + "type": "array", + }, + "uptime1d": { + "type": "number", + }, + "uptime30d": { + "type": "number", + }, + "uptime7d": { + "type": "number", + }, + "verification": { + "nullable": true, + "properties": { + "attestations": { + "items": { + "properties": { + "auditEscrowId": { + "pattern": "^\\d+$", + "type": "string", + }, + "auditor": { + "type": "string", + }, + "capabilities": { + "items": { + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown", + ], + "type": "string", + }, + "type": "array", + }, + "createdAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "deposit": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "depositStatus": { + "enum": [ + "unspecified", + "escrowed", + "pending_discrepancy", + "returned_to_auditor", + "slashed", + "unknown", + ], + "type": "string", + }, + "evidenceHash": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "expiresAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "faultAttribution": { + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown", + ], + "type": "string", + }, + "fee": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "feeStatus": { + "enum": [ + "unspecified", + "escrowed", + "released_to_auditor", + "returned_to_provider", + "unknown", + ], + "type": "string", + }, + "provider": { + "type": "string", + }, + "status": { + "enum": [ + "unspecified", + "valid", + "voided", + "expired", + "revoked", + "removed", + "unknown", + ], + "type": "string", + }, + "tier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "type": "string", + }, + "voidedReason": { + "enum": [ + "unspecified", + "discrepancy", + "governance", + "bond_withdrawn", + "bond_slashed", + "unknown", + ], + "type": "string", + }, + }, + "required": [ + "provider", + "auditor", + "tier", + "capabilities", + "evidenceHash", + "fee", + "feeStatus", + "createdAt", + "expiresAt", + "status", + "voidedReason", + "deposit", + "depositStatus", + "auditEscrowId", + "faultAttribution", + ], + "type": "object", + }, + "type": "array", + }, + "auditEscrows": { + "items": { + "properties": { + "consumedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "consumedByAuditor": { + "nullable": true, + "type": "string", + }, + "expiresAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "faultAttribution": { + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown", + ], + "type": "string", + }, + "fee": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "feeStatus": { + "enum": [ + "unspecified", + "escrowed", + "released_to_auditor", + "returned_to_provider", + "unknown", + ], + "type": "string", + }, + "id": { + "pattern": "^\\d+$", + "type": "string", + }, + "metadataHash": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "openedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "provider": { + "type": "string", + }, + "providerDeposit": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "providerDepositStatus": { + "enum": [ + "unspecified", + "escrowed", + "returned_to_provider", + "slashed", + "unknown", + ], + "type": "string", + }, + "requestedCapabilities": { + "items": { + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown", + ], + "type": "string", + }, + "type": "array", + }, + "requestedTier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "type": "string", + }, + "settlementReason": { + "enum": [ + "unspecified", + "cancelled_unconsumed", + "expired_unconsumed", + "provider_fault", + "no_fault", + "unknown", + ], + "type": "string", + }, + "status": { + "enum": [ + "unspecified", + "open", + "consumed", + "cancelled", + "expired", + "settled", + "unknown", + ], + "type": "string", + }, + }, + "required": [ + "id", + "provider", + "consumedByAuditor", + "requestedTier", + "requestedCapabilities", + "fee", + "feeStatus", + "providerDeposit", + "providerDepositStatus", + "status", + "openedAt", + "consumedAt", + "expiresAt", + "metadataHash", + "settlementReason", + "faultAttribution", + ], + "type": "object", + }, + "type": "array", + }, + "bond": { + "nullable": true, + "properties": { + "bondedAmount": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "lastSlashTime": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "provider": { + "type": "string", + }, + "requiredForCurrentTier": { + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "slashed": { + "type": "boolean", + }, + "unbondingEntries": { + "items": { + "properties": { + "amount": { + "nullable": true, + "properties": { + "amount": { + "pattern": "^\\d+$", + "type": "string", + }, + "denom": { + "type": "string", + }, + }, + "required": [ + "denom", + "amount", + ], + "type": "object", + }, + "completionTime": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + }, + "required": [ + "amount", + "completionTime", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "provider", + "bondedAmount", + "requiredForCurrentTier", + "unbondingEntries", + "slashed", + "lastSlashTime", + ], + "type": "object", + }, + "completeness": { + "properties": { + "attestations": { + "type": "boolean", + }, + "auditEscrows": { + "type": "boolean", + }, + "bond": { + "type": "boolean", + }, + "discrepancies": { + "type": "boolean", + }, + "graces": { + "type": "boolean", + }, + "maintenance": { + "type": "boolean", + }, + "params": { + "type": "boolean", + }, + "snapshot": { + "type": "boolean", + }, + }, + "required": [ + "params", + "attestations", + "graces", + "snapshot", + "bond", + "auditEscrows", + "maintenance", + "discrepancies", + ], + "type": "object", + }, + "discrepancies": { + "items": { + "properties": { + "auditorA": { + "type": "string", + }, + "auditorATier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "type": "string", + }, + "auditorB": { + "type": "string", + }, + "auditorBTier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "type": "string", + }, + "faultAttribution": { + "enum": [ + "unspecified", + "provider_fault", + "auditor_fault", + "shared_fault", + "no_fault", + "inconclusive", + "unknown", + ], + "type": "string", + }, + "graceRecordId": { + "pattern": "^\\d+$", + "type": "string", + }, + "id": { + "pattern": "^\\d+$", + "type": "string", + }, + "provider": { + "type": "string", + }, + "resolutionEvidenceHash": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "resolutionProposalId": { + "pattern": "^\\d+$", + "type": "string", + }, + "resolutionReason": { + "enum": [ + "unspecified", + "auditor_a_correct", + "auditor_b_correct", + "both_auditors_wrong", + "provider_fault", + "shared_fault", + "evidence_inconclusive", + "governance_timeout_review", + "unknown", + ], + "type": "string", + }, + "resolutionStatus": { + "enum": [ + "unspecified", + "pending", + "resolved", + "timed_out", + "unknown", + ], + "type": "string", + }, + "timestamp": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + }, + "required": [ + "id", + "provider", + "auditorA", + "auditorATier", + "auditorB", + "auditorBTier", + "timestamp", + "resolutionStatus", + "resolutionProposalId", + "graceRecordId", + "resolutionReason", + "faultAttribution", + "resolutionEvidenceHash", + ], + "type": "object", + }, + "type": "array", + }, + "grace": { + "nullable": true, + "properties": { + "expiresAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "id": { + "pattern": "^\\d+$", + "type": "string", + }, + "preservedTier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "type": "string", + }, + "provider": { + "type": "string", + }, + "sourceDiscrepancyIds": { + "items": { + "pattern": "^\\d+$", + "type": "string", + }, + "type": "array", + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string", }, - "pending": { - "type": "number", + "status": { + "enum": [ + "unspecified", + "active", + "expired", + "terminated", + "unknown", + ], + "type": "string", }, }, "required": [ - "active", - "available", - "pending", + "id", + "provider", + "preservedTier", + "sourceDiscrepancyIds", + "startedAt", + "expiresAt", + "status", ], "type": "object", }, - "memory": { + "maintenance": { + "items": { + "properties": { + "record": { + "nullable": true, + "properties": { + "closedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "expectedEndsAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "id": { + "pattern": "^\\d+$", + "type": "string", + }, + "maintenanceType": { + "enum": [ + "unspecified", + "planned", + "emergency", + "security", + "network", + "capacity", + "unknown", + ], + "type": "string", + }, + "metadataHash": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "openedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "provider": { + "type": "string", + }, + "startsAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + }, + "required": [ + "id", + "provider", + "maintenanceType", + "startsAt", + "expectedEndsAt", + "openedAt", + "closedAt", + "metadataHash", + ], + "type": "object", + }, + "status": { + "enum": [ + "unspecified", + "scheduled", + "active", + "elapsed", + "closed", + "unknown", + ], + "type": "string", + }, + }, + "required": [ + "record", + "status", + ], + "type": "object", + }, + "type": "array", + }, + "moduleActive": { + "nullable": true, + "type": "boolean", + }, + "observedAt": { + "format": "date-time", + "type": "string", + }, + "observedHeight": { + "pattern": "^\\d+$", + "type": "string", + }, + "provenance": { "properties": { - "active": { - "type": "number", + "attestations": { + "enum": [ + "auditor-attested", + ], + "type": "string", }, - "available": { - "type": "number", + "inventory": { + "enum": [ + "provider-signed inventory", + ], + "type": "string", }, - "pending": { - "type": "number", + "providerTier": { + "enum": [ + "provider self-declared", + ], + "type": "string", }, }, "required": [ - "active", - "available", - "pending", + "providerTier", + "inventory", + "attestations", ], "type": "object", }, - "storage": { + "provider": { + "type": "string", + }, + "providerDeclaredTier": { + "description": "Legacy, self-declared provider tier attribute; not an AEP-86 attestation", + "nullable": true, + "type": "string", + }, + "snapshot": { + "nullable": true, "properties": { - "ephemeral": { + "complianceDeadline": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "postedAt": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "provider": { + "type": "string", + }, + "resourceSummary": { + "nullable": true, "properties": { - "active": { - "type": "number", + "activeLeases": { + "minimum": 0, + "type": "integer", }, - "available": { - "type": "number", + "softwareIdentity": { + "nullable": true, + "properties": { + "artifactRef": { + "type": "string", + }, + "digest": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "digestAlgorithm": { + "type": "string", + }, + "publicKeyRef": { + "type": "string", + }, + "signature": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "signatureRef": { + "type": "string", + }, + "signatureType": { + "type": "string", + }, + "version": { + "type": "string", + }, + }, + "required": [ + "version", + "artifactRef", + "digestAlgorithm", + "digest", + "signatureType", + "signature", + "signatureRef", + "publicKeyRef", + ], + "type": "object", }, - "pending": { - "type": "number", + "softwareSignature": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "softwareVersion": { + "type": "string", + }, + "totalGpus": { + "minimum": 0, + "type": "integer", + }, + "totalMemoryMb": { + "pattern": "^\\d+$", + "type": "string", + }, + "totalStorageMb": { + "pattern": "^\\d+$", + "type": "string", + }, + "totalVcpus": { + "minimum": 0, + "type": "integer", }, }, "required": [ - "active", - "available", - "pending", + "totalGpus", + "totalVcpus", + "totalMemoryMb", + "totalStorageMb", + "activeLeases", + "softwareVersion", + "softwareSignature", + "softwareIdentity", ], "type": "object", }, - "persistent": { - "properties": { - "active": { - "type": "number", - }, - "available": { - "type": "number", - }, - "pending": { - "type": "number", - }, + "snapshotHash": { + "description": "Base64-encoded bytes, or null when the chain field is empty", + "nullable": true, + "type": "string", + }, + "snapshotTimestamp": { + "format": "date-time", + "nullable": true, + "type": "string", + }, + "suspended": { + "type": "boolean", + }, + }, + "required": [ + "provider", + "snapshotHash", + "resourceSummary", + "postedAt", + "snapshotTimestamp", + "complianceDeadline", + "suspended", + ], + "type": "object", + }, + "summary": { + "properties": { + "bestAttestedTier": { + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "nullable": true, + "type": "string", + }, + "capabilities": { + "items": { + "enum": [ + "unspecified", + "tee_hardware_attestation", + "confidential_computing", + "persistent_storage", + "bare_metal", + "unknown", + ], + "type": "string", }, - "required": [ + "nullable": true, + "type": "array", + }, + "effectiveTier": { + "description": "Tier used by the chain tier gate, including active discrepancy grace", + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "unknown", + ], + "nullable": true, + "type": "string", + }, + "maintenanceState": { + "enum": [ + "unknown", + "none", + "scheduled", "active", - "available", - "pending", ], - "type": "object", + "type": "string", + }, + "reviewState": { + "enum": [ + "unknown", + "none", + "under_review", + "grace", + ], + "type": "string", + }, + "snapshotState": { + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended", + ], + "type": "string", + }, + "validAttestationCount": { + "minimum": 0, + "nullable": true, + "type": "integer", + }, + "validAuditorCount": { + "minimum": 0, + "nullable": true, + "type": "integer", + }, + "validAuditors": { + "items": { + "type": "string", + }, + "nullable": true, + "type": "array", }, }, "required": [ - "ephemeral", - "persistent", + "bestAttestedTier", + "effectiveTier", + "capabilities", + "validAttestationCount", + "validAuditorCount", + "validAuditors", + "snapshotState", + "maintenanceState", + "reviewState", ], "type": "object", }, }, "required": [ - "cpu", - "gpu", - "memory", - "storage", + "provider", + "providerDeclaredTier", + "moduleActive", + "provenance", + "summary", + "attestations", + "bond", + "snapshot", + "grace", + "auditEscrows", + "maintenance", + "discrepancies", + "observedAt", + "observedHeight", + "completeness", ], "type": "object", }, - "statusPage": { - "nullable": true, - "type": "string", - }, - "tier": { - "nullable": true, - "type": "string", - }, - "timezone": { - "nullable": true, - "type": "string", - }, - "uptime": { - "items": { - "properties": { - "checkDate": { - "type": "string", - }, - "id": { - "type": "string", - }, - "isOnline": { - "type": "boolean", - }, - }, - "required": [ - "id", - "isOnline", - "checkDate", - ], - "type": "object", - }, - "type": "array", - }, - "uptime1d": { - "type": "number", - }, - "uptime30d": { - "type": "number", - }, - "uptime7d": { - "type": "number", - }, "website": { "nullable": true, "type": "string", @@ -12940,6 +14811,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "workloadSupportChia", "workloadSupportChiaCapabilities", "featEndpointIp", + "verification", "uptime", ], "type": "object", 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/drizzle/0010_add_provider_verification.sql b/apps/indexer/drizzle/0010_add_provider_verification.sql new file mode 100644 index 0000000000..ff0757270d --- /dev/null +++ b/apps/indexer/drizzle/0010_add_provider_verification.sql @@ -0,0 +1,296 @@ +CREATE TABLE IF NOT EXISTS "verification_auditor" ( + "address" varchar(255) PRIMARY KEY NOT NULL, + "status" integer NOT NULL, + "max_attestation_tier" integer NOT NULL, + "bond_denom" varchar(255) NOT NULL, + "bond_amount" numeric(30, 0) NOT NULL, + "bond_status" integer NOT NULL, + "metadata_hash" bytea, + "registered_at" timestamp with time zone NOT NULL, + "renewal_deadline" timestamp with time zone NOT NULL, + "discrepancy_count" numeric(20, 0) NOT NULL, + "bond_unbonding_completion_time" timestamp with time zone, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS "verification_auditor_status" ON "verification_auditor" ("status"); +CREATE INDEX IF NOT EXISTS "verification_auditor_renewal_deadline" ON "verification_auditor" ("renewal_deadline"); + +CREATE TABLE IF NOT EXISTS "verification_attestation" ( + "provider" varchar(255) NOT NULL, + "auditor" varchar(255) NOT NULL, + "tier" integer NOT NULL, + "evidence_hash" bytea NOT NULL, + "fee_denom" varchar(255) NOT NULL, + "fee_amount" numeric(30, 0) NOT NULL, + "fee_status" integer NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "status" integer NOT NULL, + "voided_reason" integer NOT NULL, + "deposit_denom" varchar(255) NOT NULL, + "deposit_amount" numeric(30, 0) NOT NULL, + "deposit_status" integer NOT NULL, + "audit_escrow_id" numeric(20, 0) NOT NULL, + "fault_attribution" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "verification_attestation_provider_auditor" PRIMARY KEY ("provider", "auditor") +); + +CREATE INDEX IF NOT EXISTS "verification_attestation_provider_status_tier" ON "verification_attestation" ("provider", "status", "tier"); +CREATE INDEX IF NOT EXISTS "verification_attestation_expires_at_status" ON "verification_attestation" ("expires_at", "status"); +CREATE INDEX IF NOT EXISTS "verification_attestation_audit_escrow_id" ON "verification_attestation" ("audit_escrow_id"); + +CREATE TABLE IF NOT EXISTS "verification_attestation_capability" ( + "provider" varchar(255) NOT NULL, + "auditor" varchar(255) NOT NULL, + "capability" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "verification_attestation_capability_identity" PRIMARY KEY ("provider", "auditor", "capability"), + CONSTRAINT "verification_attestation_capability_attestation_fkey" + FOREIGN KEY ("provider", "auditor") REFERENCES "verification_attestation" ("provider", "auditor") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "verification_attestation_capability_capability_provider" + ON "verification_attestation_capability" ("capability", "provider"); + +CREATE TABLE IF NOT EXISTS "verification_audit_escrow" ( + "id" numeric(20, 0) PRIMARY KEY NOT NULL, + "provider" varchar(255) NOT NULL, + "consumed_by_auditor" varchar(255) NOT NULL, + "requested_tier" integer NOT NULL, + "fee_denom" varchar(255) NOT NULL, + "fee_amount" numeric(30, 0) NOT NULL, + "fee_status" integer NOT NULL, + "provider_deposit_denom" varchar(255) NOT NULL, + "provider_deposit_amount" numeric(30, 0) NOT NULL, + "provider_deposit_status" integer NOT NULL, + "status" integer NOT NULL, + "opened_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "expires_at" timestamp with time zone NOT NULL, + "metadata_hash" bytea, + "settlement_reason" integer NOT NULL, + "fault_attribution" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS "verification_audit_escrow_provider_status" ON "verification_audit_escrow" ("provider", "status"); +CREATE INDEX IF NOT EXISTS "verification_audit_escrow_expires_at_status" ON "verification_audit_escrow" ("expires_at", "status"); + +CREATE TABLE IF NOT EXISTS "verification_audit_escrow_capability" ( + "audit_escrow_id" numeric(20, 0) NOT NULL, + "capability" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "verification_audit_escrow_capability_identity" PRIMARY KEY ("audit_escrow_id", "capability"), + CONSTRAINT "verification_audit_escrow_capability_escrow_fkey" + FOREIGN KEY ("audit_escrow_id") REFERENCES "verification_audit_escrow" ("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "verification_audit_escrow_capability_capability" ON "verification_audit_escrow_capability" ("capability"); + +CREATE TABLE IF NOT EXISTS "verification_discrepancy" ( + "id" numeric(20, 0) PRIMARY KEY NOT NULL, + "provider" varchar(255) NOT NULL, + "auditor_a" varchar(255) NOT NULL, + "auditor_a_tier" integer NOT NULL, + "auditor_b" varchar(255) NOT NULL, + "auditor_b_tier" integer NOT NULL, + "detected_at" timestamp with time zone NOT NULL, + "resolution_status" integer NOT NULL, + "resolution_proposal_id" numeric(20, 0) NOT NULL, + "grace_record_id" numeric(20, 0) NOT NULL, + "resolution_reason" integer NOT NULL, + "fault_attribution" integer NOT NULL, + "resolution_evidence_hash" bytea, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS "verification_discrepancy_provider_resolution_status" ON "verification_discrepancy" ("provider", "resolution_status"); +CREATE INDEX IF NOT EXISTS "verification_discrepancy_auditor_a" ON "verification_discrepancy" ("auditor_a"); +CREATE INDEX IF NOT EXISTS "verification_discrepancy_auditor_b" ON "verification_discrepancy" ("auditor_b"); + +CREATE TABLE IF NOT EXISTS "verification_grace" ( + "id" numeric(20, 0) PRIMARY KEY NOT NULL, + "provider" varchar(255) NOT NULL, + "preserved_tier" integer NOT NULL, + "started_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "status" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS "verification_grace_provider_status" ON "verification_grace" ("provider", "status"); +CREATE INDEX IF NOT EXISTS "verification_grace_expires_at_status" ON "verification_grace" ("expires_at", "status"); + +CREATE TABLE IF NOT EXISTS "verification_grace_discrepancy" ( + "grace_id" numeric(20, 0) NOT NULL, + "discrepancy_id" numeric(20, 0) NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "verification_grace_discrepancy_identity" PRIMARY KEY ("grace_id", "discrepancy_id"), + CONSTRAINT "verification_grace_discrepancy_grace_fkey" + FOREIGN KEY ("grace_id") REFERENCES "verification_grace" ("id") ON DELETE CASCADE, + CONSTRAINT "verification_grace_discrepancy_discrepancy_fkey" + FOREIGN KEY ("discrepancy_id") REFERENCES "verification_discrepancy" ("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "verification_grace_discrepancy_discrepancy_id" ON "verification_grace_discrepancy" ("discrepancy_id"); + +CREATE TABLE IF NOT EXISTS "verification_provider_bond" ( + "provider" varchar(255) PRIMARY KEY NOT NULL, + "bonded_denom" varchar(255) NOT NULL, + "bonded_amount" numeric(30, 0) NOT NULL, + "required_for_current_tier_denom" varchar(255) NOT NULL, + "required_for_current_tier_amount" numeric(30, 0) NOT NULL, + "slashed" boolean NOT NULL, + "last_slash_time" timestamp with time zone, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE TABLE IF NOT EXISTS "verification_provider_bond_unbonding" ( + "provider" varchar(255) NOT NULL, + "entry_index" integer NOT NULL, + "denom" varchar(255) NOT NULL, + "amount" numeric(30, 0) NOT NULL, + "completion_time" timestamp with time zone NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "verification_provider_bond_unbonding_identity" PRIMARY KEY ("provider", "entry_index"), + CONSTRAINT "verification_provider_bond_unbonding_bond_fkey" + FOREIGN KEY ("provider") REFERENCES "verification_provider_bond" ("provider") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "verification_provider_bond_unbonding_completion_time" + ON "verification_provider_bond_unbonding" ("completion_time"); + +CREATE TABLE IF NOT EXISTS "verification_provider_observation" ( + "provider" varchar(255) PRIMARY KEY NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + "effective_tier" integer NOT NULL, + "max_placement_tier" integer NOT NULL, + "snapshot_state" varchar(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS "verification_provider_tier_stream" ( + "id" smallint PRIMARY KEY NOT NULL, + "stream_id" uuid NOT NULL DEFAULT gen_random_uuid() +); + +INSERT INTO "verification_provider_tier_stream" ("id") +VALUES (1) +ON CONFLICT ("id") DO NOTHING; + +CREATE TABLE IF NOT EXISTS "verification_provider_tier_demotion" ( + "id" bigserial PRIMARY KEY NOT NULL, + "provider" varchar(255) NOT NULL, + "previous_effective_tier" integer NOT NULL, + "previous_max_placement_tier" integer NOT NULL, + "previous_snapshot_state" varchar(255) NOT NULL, + "current_effective_tier" integer NOT NULL, + "current_max_placement_tier" integer NOT NULL, + "current_snapshot_state" varchar(255) NOT NULL, + "changes" varchar(255)[] NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "verification_provider_tier_demotion_provider_id" + ON "verification_provider_tier_demotion" ("provider", "id"); +CREATE INDEX IF NOT EXISTS "verification_provider_tier_demotion_observed_height" + ON "verification_provider_tier_demotion" ("observed_height"); + +CREATE TABLE IF NOT EXISTS "verification_provider_snapshot" ( + "provider" varchar(255) PRIMARY KEY NOT NULL, + "snapshot_hash" bytea NOT NULL, + "total_gpus" integer NOT NULL, + "total_vcpus" integer NOT NULL, + "total_memory_mb" numeric(20, 0) NOT NULL, + "total_storage_mb" numeric(20, 0) NOT NULL, + "active_leases" integer NOT NULL, + "software_version" varchar(255) NOT NULL, + "software_signature" bytea, + "software_identity_version" varchar(255), + "software_artifact_ref" text, + "software_digest_algorithm" varchar(255), + "software_digest" bytea, + "software_signature_type" varchar(255), + "software_identity_signature" bytea, + "software_signature_ref" text, + "software_public_key_ref" text, + "posted_at" timestamp with time zone NOT NULL, + "snapshot_timestamp" timestamp with time zone NOT NULL, + "compliance_deadline" timestamp with time zone NOT NULL, + "suspended" boolean NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE INDEX IF NOT EXISTS "verification_provider_snapshot_compliance_deadline_suspended" + ON "verification_provider_snapshot" ("compliance_deadline", "suspended"); +CREATE INDEX IF NOT EXISTS "verification_provider_snapshot_snapshot_timestamp" ON "verification_provider_snapshot" ("snapshot_timestamp"); + +CREATE TABLE IF NOT EXISTS "provider_maintenance" ( + "provider" varchar(255) NOT NULL, + "id" numeric(20, 0) NOT NULL, + "maintenance_type" integer NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "expected_ends_at" timestamp with time zone NOT NULL, + "opened_at" timestamp with time zone NOT NULL, + "closed_at" timestamp with time zone, + "metadata_hash" bytea, + "status" integer NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL, + CONSTRAINT "provider_maintenance_provider_id" PRIMARY KEY ("provider", "id") +); + +CREATE INDEX IF NOT EXISTS "provider_maintenance_provider_status" ON "provider_maintenance" ("provider", "status"); +CREATE INDEX IF NOT EXISTS "provider_maintenance_starts_at_expected_ends_at" ON "provider_maintenance" ("starts_at", "expected_ends_at"); + +CREATE TABLE IF NOT EXISTS "verification_params" ( + "id" smallint PRIMARY KEY NOT NULL, + "params" jsonb NOT NULL, + "observed_height" integer NOT NULL, + "observed_block_time" timestamp with time zone NOT NULL +); + +CREATE TABLE IF NOT EXISTS "verification_reconcile_target" ( + "target_type" varchar(255) NOT NULL, + "target_key" varchar(255) NOT NULL, + "requested_height" integer NOT NULL, + "invalidated" boolean NOT NULL DEFAULT true, + "claimed_at" timestamp with time zone, + "attempt_count" integer NOT NULL DEFAULT 0, + "next_attempt_at" timestamp with time zone, + "last_error" text, + CONSTRAINT "verification_reconcile_target_identity" PRIMARY KEY ("target_type", "target_key") +); + +CREATE INDEX IF NOT EXISTS "verification_reconcile_target_claimed_at_next_attempt_at" + ON "verification_reconcile_target" ("claimed_at" NULLS FIRST, "next_attempt_at" NULLS FIRST); +CREATE INDEX IF NOT EXISTS "verification_reconcile_target_requested_height" ON "verification_reconcile_target" ("requested_height"); + +CREATE TABLE IF NOT EXISTS "verification_block_event" ( + "id" uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), + "height" integer NOT NULL, + "index" integer NOT NULL, + "type" varchar(255) NOT NULL, + "data" jsonb NOT NULL, + "is_processed" boolean NOT NULL DEFAULT false, + CONSTRAINT "verification_block_event_height_fkey" + FOREIGN KEY ("height") REFERENCES "block" ("height") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "verification_block_event_height_index" ON "verification_block_event" ("height", "index"); +CREATE INDEX IF NOT EXISTS "verification_block_event_height_is_processed" ON "verification_block_event" ("height", "is_processed"); diff --git a/apps/indexer/drizzle/meta/_journal.json b/apps/indexer/drizzle/meta/_journal.json index 447e376f33..d8b9f196e9 100644 --- a/apps/indexer/drizzle/meta/_journal.json +++ b/apps/indexer/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1763000000000, "tag": "0009_drop_monitored_value", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1787570000000, + "tag": "0010_add_provider_verification", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/indexer/drizzle/relations.ts b/apps/indexer/drizzle/relations.ts index 97ab289d59..4a33d1e4b3 100644 --- a/apps/indexer/drizzle/relations.ts +++ b/apps/indexer/drizzle/relations.ts @@ -11,8 +11,23 @@ import { provider, providerAttribute, providerAttributeSignature, + providerMaintenance, providerSnapshot, - transaction + transaction, + verificationAttestation, + verificationAttestationCapability, + verificationAuditEscrow, + verificationAuditEscrowCapability, + verificationAuditor, + verificationBlockEvent, + verificationDiscrepancy, + verificationGrace, + verificationGraceDiscrepancy, + verificationProviderBond, + verificationProviderBondUnbonding, + verificationProviderObservation, + verificationProviderSnapshot, + verificationProviderTierDemotion } from "./schema"; export const deploymentGroupRelations = relations(deploymentGroup, ({ one, many }) => ({ @@ -36,11 +51,20 @@ export const providerAttributeSignatureRelations = relations(providerAttributeSi }) })); -export const providerRelations = relations(provider, ({ many }) => ({ +export const providerRelations = relations(provider, ({ one, many }) => ({ providerAttributeSignatures: many(providerAttributeSignature), leases: many(lease), providerAttributes: many(providerAttribute), - providerSnapshots: many(providerSnapshot) + providerSnapshots: many(providerSnapshot), + verificationAttestations: many(verificationAttestation), + verificationAuditEscrows: many(verificationAuditEscrow), + verificationDiscrepancies: many(verificationDiscrepancy), + verificationGraceRecords: many(verificationGrace), + verificationBond: one(verificationProviderBond), + verificationObservation: one(verificationProviderObservation), + verificationSnapshot: one(verificationProviderSnapshot), + verificationTierDemotions: many(verificationProviderTierDemotion), + maintenanceRecords: many(providerMaintenance) })); export const leaseRelations = relations(lease, ({ one }) => ({ @@ -93,7 +117,8 @@ export const messageRelations = relations(message, ({ one, many }) => ({ export const blockRelations = relations(block, ({ many }) => ({ messages: many(message), - transactions: many(transaction) + transactions: many(transaction), + verificationBlockEvents: many(verificationBlockEvent) })); export const transactionRelations = relations(transaction, ({ one, many }) => ({ @@ -115,3 +140,118 @@ export const addressReferenceRelations = relations(addressReference, ({ one }) = references: [transaction.id] }) })); + +export const verificationAuditorRelations = relations(verificationAuditor, ({ many }) => ({ + attestations: many(verificationAttestation) +})); + +export const verificationAttestationRelations = relations(verificationAttestation, ({ one, many }) => ({ + provider: one(provider, { + fields: [verificationAttestation.provider], + references: [provider.owner] + }), + auditor: one(verificationAuditor, { + fields: [verificationAttestation.auditor], + references: [verificationAuditor.address] + }), + capabilities: many(verificationAttestationCapability) +})); + +export const verificationAttestationCapabilityRelations = relations(verificationAttestationCapability, ({ one }) => ({ + attestation: one(verificationAttestation, { + fields: [verificationAttestationCapability.provider, verificationAttestationCapability.auditor], + references: [verificationAttestation.provider, verificationAttestation.auditor] + }) +})); + +export const verificationAuditEscrowRelations = relations(verificationAuditEscrow, ({ one, many }) => ({ + provider: one(provider, { + fields: [verificationAuditEscrow.provider], + references: [provider.owner] + }), + capabilities: many(verificationAuditEscrowCapability) +})); + +export const verificationAuditEscrowCapabilityRelations = relations(verificationAuditEscrowCapability, ({ one }) => ({ + auditEscrow: one(verificationAuditEscrow, { + fields: [verificationAuditEscrowCapability.audit_escrow_id], + references: [verificationAuditEscrow.id] + }) +})); + +export const verificationDiscrepancyRelations = relations(verificationDiscrepancy, ({ one, many }) => ({ + provider: one(provider, { + fields: [verificationDiscrepancy.provider], + references: [provider.owner] + }), + graceRecords: many(verificationGraceDiscrepancy) +})); + +export const verificationGraceRelations = relations(verificationGrace, ({ one, many }) => ({ + provider: one(provider, { + fields: [verificationGrace.provider], + references: [provider.owner] + }), + sourceDiscrepancies: many(verificationGraceDiscrepancy) +})); + +export const verificationGraceDiscrepancyRelations = relations(verificationGraceDiscrepancy, ({ one }) => ({ + grace: one(verificationGrace, { + fields: [verificationGraceDiscrepancy.grace_id], + references: [verificationGrace.id] + }), + discrepancy: one(verificationDiscrepancy, { + fields: [verificationGraceDiscrepancy.discrepancy_id], + references: [verificationDiscrepancy.id] + }) +})); + +export const verificationProviderBondRelations = relations(verificationProviderBond, ({ one, many }) => ({ + provider: one(provider, { + fields: [verificationProviderBond.provider], + references: [provider.owner] + }), + unbondingEntries: many(verificationProviderBondUnbonding) +})); + +export const verificationProviderBondUnbondingRelations = relations(verificationProviderBondUnbonding, ({ one }) => ({ + providerBond: one(verificationProviderBond, { + fields: [verificationProviderBondUnbonding.provider], + references: [verificationProviderBond.provider] + }) +})); + +export const verificationProviderObservationRelations = relations(verificationProviderObservation, ({ one }) => ({ + provider: one(provider, { + fields: [verificationProviderObservation.provider], + references: [provider.owner] + }) +})); + +export const verificationProviderTierDemotionRelations = relations(verificationProviderTierDemotion, ({ one }) => ({ + provider: one(provider, { + fields: [verificationProviderTierDemotion.provider], + references: [provider.owner] + }) +})); + +export const verificationProviderSnapshotRelations = relations(verificationProviderSnapshot, ({ one }) => ({ + provider: one(provider, { + fields: [verificationProviderSnapshot.provider], + references: [provider.owner] + }) +})); + +export const providerMaintenanceRelations = relations(providerMaintenance, ({ one }) => ({ + provider: one(provider, { + fields: [providerMaintenance.provider], + references: [provider.owner] + }) +})); + +export const verificationBlockEventRelations = relations(verificationBlockEvent, ({ one }) => ({ + block: one(block, { + fields: [verificationBlockEvent.height], + references: [block.height] + }) +})); diff --git a/apps/indexer/drizzle/schema.ts b/apps/indexer/drizzle/schema.ts index ab6de98da7..85e5ed6cc5 100644 --- a/apps/indexer/drizzle/schema.ts +++ b/apps/indexer/drizzle/schema.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import { bigint, + bigserial, boolean, customType, doublePrecision, @@ -10,6 +11,7 @@ import { jsonb, numeric, pgTable, + primaryKey, serial, smallint, text, @@ -729,3 +731,441 @@ export const bmeStatusChange = pgTable( }) ] ); + +export const verificationAuditor = pgTable( + "verification_auditor", + { + address: varchar({ length: 255 }).primaryKey().notNull(), + status: integer().notNull(), + max_attestation_tier: integer().notNull(), + bond_denom: varchar({ length: 255 }).notNull(), + bond_amount: numeric({ precision: 30, scale: 0 }).notNull(), + bond_status: integer().notNull(), + metadata_hash: bytea("metadata_hash"), + registered_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + renewal_deadline: timestamp({ withTimezone: true, mode: "string" }).notNull(), + discrepancy_count: numeric({ precision: 20, scale: 0 }).notNull(), + bond_unbonding_completion_time: timestamp({ withTimezone: true, mode: "string" }), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + index("verification_auditor_status").using("btree", table.status.asc().nullsLast().op("int4_ops")), + index("verification_auditor_renewal_deadline").using("btree", table.renewal_deadline.asc().nullsLast().op("timestamptz_ops")) + ] +); + +export const verificationAttestation = pgTable( + "verification_attestation", + { + provider: varchar({ length: 255 }).notNull(), + auditor: varchar({ length: 255 }).notNull(), + tier: integer().notNull(), + evidence_hash: bytea("evidence_hash").notNull(), + fee_denom: varchar({ length: 255 }).notNull(), + fee_amount: numeric({ precision: 30, scale: 0 }).notNull(), + fee_status: integer().notNull(), + created_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + expires_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + status: integer().notNull(), + voided_reason: integer().notNull(), + deposit_denom: varchar({ length: 255 }).notNull(), + deposit_amount: numeric({ precision: 30, scale: 0 }).notNull(), + deposit_status: integer().notNull(), + audit_escrow_id: numeric({ precision: 20, scale: 0 }).notNull(), + fault_attribution: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ columns: [table.provider, table.auditor], name: "verification_attestation_provider_auditor" }), + index("verification_attestation_provider_status_tier").using( + "btree", + table.provider.asc().nullsLast().op("text_ops"), + table.status.asc().nullsLast().op("int4_ops"), + table.tier.asc().nullsLast().op("int4_ops") + ), + index("verification_attestation_expires_at_status").using( + "btree", + table.expires_at.asc().nullsLast().op("timestamptz_ops"), + table.status.asc().nullsLast().op("int4_ops") + ), + index("verification_attestation_audit_escrow_id").using("btree", table.audit_escrow_id.asc().nullsLast().op("numeric_ops")) + ] +); + +export const verificationAttestationCapability = pgTable( + "verification_attestation_capability", + { + provider: varchar({ length: 255 }).notNull(), + auditor: varchar({ length: 255 }).notNull(), + capability: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ + columns: [table.provider, table.auditor, table.capability], + name: "verification_attestation_capability_identity" + }), + index("verification_attestation_capability_capability_provider").using( + "btree", + table.capability.asc().nullsLast().op("int4_ops"), + table.provider.asc().nullsLast().op("text_ops") + ), + foreignKey({ + columns: [table.provider, table.auditor], + foreignColumns: [verificationAttestation.provider, verificationAttestation.auditor], + name: "verification_attestation_capability_attestation_fkey" + }).onDelete("cascade") + ] +); + +export const verificationAuditEscrow = pgTable( + "verification_audit_escrow", + { + id: numeric({ precision: 20, scale: 0 }).primaryKey().notNull(), + provider: varchar({ length: 255 }).notNull(), + consumed_by_auditor: varchar({ length: 255 }).notNull(), + requested_tier: integer().notNull(), + fee_denom: varchar({ length: 255 }).notNull(), + fee_amount: numeric({ precision: 30, scale: 0 }).notNull(), + fee_status: integer().notNull(), + provider_deposit_denom: varchar({ length: 255 }).notNull(), + provider_deposit_amount: numeric({ precision: 30, scale: 0 }).notNull(), + provider_deposit_status: integer().notNull(), + status: integer().notNull(), + opened_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + consumed_at: timestamp({ withTimezone: true, mode: "string" }), + expires_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + metadata_hash: bytea("metadata_hash"), + settlement_reason: integer().notNull(), + fault_attribution: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + index("verification_audit_escrow_provider_status").using( + "btree", + table.provider.asc().nullsLast().op("text_ops"), + table.status.asc().nullsLast().op("int4_ops") + ), + index("verification_audit_escrow_expires_at_status").using( + "btree", + table.expires_at.asc().nullsLast().op("timestamptz_ops"), + table.status.asc().nullsLast().op("int4_ops") + ) + ] +); + +export const verificationAuditEscrowCapability = pgTable( + "verification_audit_escrow_capability", + { + audit_escrow_id: numeric({ precision: 20, scale: 0 }).notNull(), + capability: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ + columns: [table.audit_escrow_id, table.capability], + name: "verification_audit_escrow_capability_identity" + }), + index("verification_audit_escrow_capability_capability").using("btree", table.capability.asc().nullsLast().op("int4_ops")), + foreignKey({ + columns: [table.audit_escrow_id], + foreignColumns: [verificationAuditEscrow.id], + name: "verification_audit_escrow_capability_escrow_fkey" + }).onDelete("cascade") + ] +); + +export const verificationDiscrepancy = pgTable( + "verification_discrepancy", + { + id: numeric({ precision: 20, scale: 0 }).primaryKey().notNull(), + provider: varchar({ length: 255 }).notNull(), + auditor_a: varchar({ length: 255 }).notNull(), + auditor_a_tier: integer().notNull(), + auditor_b: varchar({ length: 255 }).notNull(), + auditor_b_tier: integer().notNull(), + detected_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + resolution_status: integer().notNull(), + resolution_proposal_id: numeric({ precision: 20, scale: 0 }).notNull(), + grace_record_id: numeric({ precision: 20, scale: 0 }).notNull(), + resolution_reason: integer().notNull(), + fault_attribution: integer().notNull(), + resolution_evidence_hash: bytea("resolution_evidence_hash"), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + index("verification_discrepancy_provider_resolution_status").using( + "btree", + table.provider.asc().nullsLast().op("text_ops"), + table.resolution_status.asc().nullsLast().op("int4_ops") + ), + index("verification_discrepancy_auditor_a").using("btree", table.auditor_a.asc().nullsLast().op("text_ops")), + index("verification_discrepancy_auditor_b").using("btree", table.auditor_b.asc().nullsLast().op("text_ops")) + ] +); + +export const verificationGrace = pgTable( + "verification_grace", + { + id: numeric({ precision: 20, scale: 0 }).primaryKey().notNull(), + provider: varchar({ length: 255 }).notNull(), + preserved_tier: integer().notNull(), + started_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + expires_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + status: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + index("verification_grace_provider_status").using("btree", table.provider.asc().nullsLast().op("text_ops"), table.status.asc().nullsLast().op("int4_ops")), + index("verification_grace_expires_at_status").using( + "btree", + table.expires_at.asc().nullsLast().op("timestamptz_ops"), + table.status.asc().nullsLast().op("int4_ops") + ) + ] +); + +export const verificationGraceDiscrepancy = pgTable( + "verification_grace_discrepancy", + { + grace_id: numeric({ precision: 20, scale: 0 }).notNull(), + discrepancy_id: numeric({ precision: 20, scale: 0 }).notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ + columns: [table.grace_id, table.discrepancy_id], + name: "verification_grace_discrepancy_identity" + }), + index("verification_grace_discrepancy_discrepancy_id").using("btree", table.discrepancy_id.asc().nullsLast().op("numeric_ops")), + foreignKey({ + columns: [table.grace_id], + foreignColumns: [verificationGrace.id], + name: "verification_grace_discrepancy_grace_fkey" + }).onDelete("cascade"), + foreignKey({ + columns: [table.discrepancy_id], + foreignColumns: [verificationDiscrepancy.id], + name: "verification_grace_discrepancy_discrepancy_fkey" + }).onDelete("cascade") + ] +); + +export const verificationProviderBond = pgTable("verification_provider_bond", { + provider: varchar({ length: 255 }).primaryKey().notNull(), + bonded_denom: varchar({ length: 255 }).notNull(), + bonded_amount: numeric({ precision: 30, scale: 0 }).notNull(), + required_for_current_tier_denom: varchar({ length: 255 }).notNull(), + required_for_current_tier_amount: numeric({ precision: 30, scale: 0 }).notNull(), + slashed: boolean().notNull(), + last_slash_time: timestamp({ withTimezone: true, mode: "string" }), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() +}); + +export const verificationProviderBondUnbonding = pgTable( + "verification_provider_bond_unbonding", + { + provider: varchar({ length: 255 }).notNull(), + entry_index: integer().notNull(), + denom: varchar({ length: 255 }).notNull(), + amount: numeric({ precision: 30, scale: 0 }).notNull(), + completion_time: timestamp({ withTimezone: true, mode: "string" }).notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ + columns: [table.provider, table.entry_index], + name: "verification_provider_bond_unbonding_identity" + }), + index("verification_provider_bond_unbonding_completion_time").using("btree", table.completion_time.asc().nullsLast().op("timestamptz_ops")), + foreignKey({ + columns: [table.provider], + foreignColumns: [verificationProviderBond.provider], + name: "verification_provider_bond_unbonding_bond_fkey" + }).onDelete("cascade") + ] +); + +export const verificationProviderObservation = pgTable("verification_provider_observation", { + provider: varchar({ length: 255 }).primaryKey().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull(), + effective_tier: integer().notNull(), + max_placement_tier: integer().notNull(), + snapshot_state: varchar({ length: 255 }).notNull() +}); + +export const verificationProviderTierStream = pgTable("verification_provider_tier_stream", { + id: smallint().primaryKey().notNull(), + stream_id: uuid() + .default(sql`gen_random_uuid()`) + .notNull() +}); + +export const verificationProviderTierDemotion = pgTable( + "verification_provider_tier_demotion", + { + id: bigserial({ mode: "bigint" }).primaryKey().notNull(), + provider: varchar({ length: 255 }).notNull(), + previous_effective_tier: integer().notNull(), + previous_max_placement_tier: integer().notNull(), + previous_snapshot_state: varchar({ length: 255 }).notNull(), + current_effective_tier: integer().notNull(), + current_max_placement_tier: integer().notNull(), + current_snapshot_state: varchar({ length: 255 }).notNull(), + changes: varchar({ length: 255 }).array().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull(), + created_at: timestamp({ withTimezone: true, mode: "string" }).defaultNow().notNull() + }, + table => [ + index("verification_provider_tier_demotion_provider_id").using( + "btree", + table.provider.asc().nullsLast().op("text_ops"), + table.id.asc().nullsLast().op("int8_ops") + ), + index("verification_provider_tier_demotion_observed_height").using("btree", table.observed_height.asc().nullsLast().op("int4_ops")) + ] +); + +export const verificationProviderSnapshot = pgTable( + "verification_provider_snapshot", + { + provider: varchar({ length: 255 }).primaryKey().notNull(), + snapshot_hash: bytea("snapshot_hash").notNull(), + total_gpus: integer().notNull(), + total_vcpus: integer().notNull(), + total_memory_mb: numeric({ precision: 20, scale: 0 }).notNull(), + total_storage_mb: numeric({ precision: 20, scale: 0 }).notNull(), + active_leases: integer().notNull(), + software_version: varchar({ length: 255 }).notNull(), + software_signature: bytea("software_signature"), + software_identity_version: varchar({ length: 255 }), + software_artifact_ref: text(), + software_digest_algorithm: varchar({ length: 255 }), + software_digest: bytea("software_digest"), + software_signature_type: varchar({ length: 255 }), + software_identity_signature: bytea("software_identity_signature"), + software_signature_ref: text(), + software_public_key_ref: text(), + posted_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + snapshot_timestamp: timestamp({ withTimezone: true, mode: "string" }).notNull(), + compliance_deadline: timestamp({ withTimezone: true, mode: "string" }).notNull(), + suspended: boolean().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + index("verification_provider_snapshot_compliance_deadline_suspended").using( + "btree", + table.compliance_deadline.asc().nullsLast().op("timestamptz_ops"), + table.suspended.asc().nullsLast().op("bool_ops") + ), + index("verification_provider_snapshot_snapshot_timestamp").using("btree", table.snapshot_timestamp.asc().nullsLast().op("timestamptz_ops")) + ] +); + +export const providerMaintenance = pgTable( + "provider_maintenance", + { + provider: varchar({ length: 255 }).notNull(), + id: numeric({ precision: 20, scale: 0 }).notNull(), + maintenance_type: integer().notNull(), + starts_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + expected_ends_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + opened_at: timestamp({ withTimezone: true, mode: "string" }).notNull(), + closed_at: timestamp({ withTimezone: true, mode: "string" }), + metadata_hash: bytea("metadata_hash"), + status: integer().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() + }, + table => [ + primaryKey({ columns: [table.provider, table.id], name: "provider_maintenance_provider_id" }), + index("provider_maintenance_provider_status").using( + "btree", + table.provider.asc().nullsLast().op("text_ops"), + table.status.asc().nullsLast().op("int4_ops") + ), + index("provider_maintenance_starts_at_expected_ends_at").using( + "btree", + table.starts_at.asc().nullsLast().op("timestamptz_ops"), + table.expected_ends_at.asc().nullsLast().op("timestamptz_ops") + ) + ] +); + +export const verificationParams = pgTable("verification_params", { + id: smallint().primaryKey().notNull(), + params: jsonb().notNull(), + observed_height: integer().notNull(), + observed_block_time: timestamp({ withTimezone: true, mode: "string" }).notNull() +}); + +export const verificationReconcileTarget = pgTable( + "verification_reconcile_target", + { + target_type: varchar({ length: 255 }).notNull(), + target_key: varchar({ length: 255 }).notNull(), + requested_height: integer().notNull(), + invalidated: boolean().default(true).notNull(), + claimed_at: timestamp({ withTimezone: true, mode: "string" }), + attempt_count: integer().default(0).notNull(), + next_attempt_at: timestamp({ withTimezone: true, mode: "string" }), + last_error: text() + }, + table => [ + primaryKey({ + columns: [table.target_type, table.target_key], + name: "verification_reconcile_target_identity" + }), + index("verification_reconcile_target_claimed_at_next_attempt_at").using( + "btree", + table.claimed_at.asc().nullsFirst().op("timestamptz_ops"), + table.next_attempt_at.asc().nullsFirst().op("timestamptz_ops") + ), + index("verification_reconcile_target_requested_height").using("btree", table.requested_height.asc().nullsLast().op("int4_ops")) + ] +); + +export const verificationBlockEvent = pgTable( + "verification_block_event", + { + id: uuid() + .primaryKey() + .notNull() + .default(sql`gen_random_uuid()`), + height: integer().notNull(), + index: integer().notNull(), + type: varchar({ length: 255 }).notNull(), + data: jsonb().notNull(), + is_processed: boolean().default(false).notNull() + }, + table => [ + uniqueIndex("verification_block_event_height_index").using( + "btree", + table.height.asc().nullsLast().op("int4_ops"), + table.index.asc().nullsLast().op("int4_ops") + ), + index("verification_block_event_height_is_processed").using( + "btree", + table.height.asc().nullsLast().op("int4_ops"), + table.is_processed.asc().nullsLast().op("bool_ops") + ), + foreignKey({ + columns: [table.height], + foreignColumns: [block.height], + name: "verification_block_event_height_fkey" + }).onDelete("cascade") + ] +); 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/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/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/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..e582789278 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": "*", @@ -51,6 +51,7 @@ "@akashnetwork/logging": "*", "@akashnetwork/net": "*", "@akashnetwork/openapi-sdk": "*", + "@akashnetwork/provider-verification": "*", "@amplitude/analytics-node": "^1.3.8", "@casl/ability": "^6.8.1", "@chain-registry/assets": "^1.64.79", @@ -542,7 +543,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 +2169,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 +2559,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 +3702,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 +4042,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 +4219,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 +4983,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 +5474,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", @@ -5657,6 +5658,10 @@ "resolved": "apps/provider-proxy", "link": true }, + "node_modules/@akashnetwork/provider-verification": { + "resolved": "packages/provider-verification", + "link": true + }, "node_modules/@akashnetwork/react-query-proxy": { "resolved": "packages/react-query-proxy", "link": true @@ -21799,7 +21804,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -44976,7 +44980,8 @@ "sequelize-typescript": "^2.1.5" }, "devDependencies": { - "@akashnetwork/dev-config": "*" + "@akashnetwork/dev-config": "*", + "vitest": "^4.1.5" } }, "packages/dev-config": { @@ -45932,7 +45937,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" }, @@ -45949,6 +45954,19 @@ "vitest": "^4.0.0" } }, + "packages/provider-verification": { + "name": "@akashnetwork/provider-verification", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@akashnetwork/chain-sdk": "1.0.0-alpha.43" + }, + "devDependencies": { + "@akashnetwork/dev-config": "*", + "typescript": "~5.8.2", + "vitest": "^4.1.5" + } + }, "packages/react-query-proxy": { "name": "@akashnetwork/react-query-proxy", "version": "1.0.0", diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index bb9c72a8ec..a07b20a1d8 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -1436,6 +1436,34 @@ export interface paths { }; content?: never; }; + /** @description The email or password was rejected */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Account could not be created */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Too many attempts */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Account creation is temporarily unavailable */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; delete?: never; @@ -1646,8 +1674,10 @@ export interface paths { content: { "application/json": { data: { - /** @description Whether auto top-up is enabled for this deployment */ - autoTopUpEnabled: boolean; + /** @description Whether auto top-up is enabled for this deployment. An explicit false is rejected once always-on funding is rolled out */ + autoTopUpEnabled?: boolean; + /** @description Runtime limit in hours, counted from lease start. On a deployment with no limit yet it may be at most 48. Extending an existing limit must raise it by at most 48 hours per request; send the new total rather than the increment. Lowering a limit is not supported. Send null to remove the limit and return the deployment to always-on funding. */ + runtimeLimitHours?: number | null; }; }; }; @@ -1683,6 +1713,17 @@ export interface paths { }; }; }; + /** @description Invalid runtime limit change */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; /** @description Deployment settings not found */ 404: { headers: { @@ -1694,6 +1735,17 @@ export interface paths { }; }; }; + /** @description Runtime limit changed concurrently */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; }; }; trace?: never; @@ -1723,10 +1775,7 @@ export interface paths { userId: string; /** @description Deployment sequence number */ dseq: string; - /** - * @description Whether auto top-up is enabled for this deployment - * @default false - */ + /** @description Whether auto top-up is enabled for this deployment. Defaults to enabled when omitted; an explicit false is rejected once always-on funding is rolled out */ autoTopUpEnabled?: boolean; }; }; @@ -1856,8 +1905,10 @@ export interface paths { content: { "application/json": { data: { - /** @description Whether auto top-up is enabled for this deployment */ - autoTopUpEnabled: boolean; + /** @description Whether auto top-up is enabled for this deployment. An explicit false is rejected once always-on funding is rolled out */ + autoTopUpEnabled?: boolean; + /** @description Runtime limit in hours, counted from lease start. On a deployment with no limit yet it may be at most 48. Extending an existing limit must raise it by at most 48 hours per request; send the new total rather than the increment. Lowering a limit is not supported. Send null to remove the limit and return the deployment to always-on funding. */ + runtimeLimitHours?: number | null; }; }; }; @@ -1893,6 +1944,17 @@ export interface paths { }; }; }; + /** @description Invalid runtime limit change */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; /** @description Deployment settings not found */ 404: { headers: { @@ -1904,6 +1966,17 @@ export interface paths { }; }; }; + /** @description Runtime limit changed concurrently */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; }; }; trace?: never; @@ -1931,10 +2004,7 @@ export interface paths { data: { /** @description Deployment sequence number */ dseq: string; - /** - * @description Whether auto top-up is enabled for this deployment - * @default false - */ + /** @description Whether auto top-up is enabled for this deployment. Defaults to enabled when omitted; an explicit false is rejected once always-on funding is rolled out */ autoTopUpEnabled?: boolean; /** * Format: uuid @@ -2681,6 +2751,68 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/sdl-secrets-context": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get SDL secrets encryption context */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Returns SDL secrets context */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description The subject of the SDL secrets context */ + sub: string; + /** @description The key ID of the SDL secrets context */ + kid: string; + /** @description The JSON Web Key used to encrypt the SDL secrets */ + jwk: { + kty: string; + n: string; + e: string; + use: string; + alg: string; + }; + /** @description The required claims for the SDL secrets context */ + requiredClaims: ("kid" | "sub" | "exp")[]; + }; + }; + }; + /** @description SDL secrets encryption is unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/leases": { parameters: { query?: never; @@ -3333,6 +3465,30 @@ export interface paths { workloadSupportChia: boolean; workloadSupportChiaCapabilities: string[] | null; featEndpointIp: boolean; + verification: { + provider: string; + moduleActive: boolean | null; + summary: { + /** + * @description Tier used by the chain tier gate, including active discrepancy grace + * @enum {string|null} + */ + effectiveTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown" | null; + validAuditorCount: number | null; + capabilities: + | ("unspecified" | "tee_hardware_attestation" | "confidential_computing" | "persistent_storage" | "bare_metal" | "unknown")[] + | null; + /** @enum {string} */ + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + /** @enum {string} */ + maintenanceState: "unknown" | "none" | "scheduled" | "active"; + /** @enum {string} */ + reviewState: "unknown" | "none" | "under_review" | "grace"; + }; + /** Format: date-time */ + observedAt: string; + observedHeight: string; + } | null; }[]; }; }; @@ -3461,6 +3617,247 @@ export interface paths { workloadSupportChia: boolean; workloadSupportChiaCapabilities: string[]; featEndpointIp: boolean; + verification: { + provider: string; + /** @description Legacy, self-declared provider tier attribute; not an AEP-86 attestation */ + providerDeclaredTier: string | null; + moduleActive: boolean | null; + provenance: { + /** @enum {string} */ + providerTier: "provider self-declared"; + /** @enum {string} */ + inventory: "provider-signed inventory"; + /** @enum {string} */ + attestations: "auditor-attested"; + }; + summary: { + /** @enum {string|null} */ + bestAttestedTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown" | null; + /** + * @description Tier used by the chain tier gate, including active discrepancy grace + * @enum {string|null} + */ + effectiveTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown" | null; + capabilities: + | ("unspecified" | "tee_hardware_attestation" | "confidential_computing" | "persistent_storage" | "bare_metal" | "unknown")[] + | null; + validAttestationCount: number | null; + validAuditorCount: number | null; + validAuditors: string[] | null; + /** @enum {string} */ + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + /** @enum {string} */ + maintenanceState: "unknown" | "none" | "scheduled" | "active"; + /** @enum {string} */ + reviewState: "unknown" | "none" | "under_review" | "grace"; + }; + attestations: { + provider: string; + auditor: string; + /** @enum {string} */ + tier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + capabilities: ("unspecified" | "tee_hardware_attestation" | "confidential_computing" | "persistent_storage" | "bare_metal" | "unknown")[]; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + evidenceHash: string | null; + fee: { + denom: string; + amount: string; + } | null; + /** @enum {string} */ + feeStatus: "unspecified" | "escrowed" | "released_to_auditor" | "returned_to_provider" | "unknown"; + /** Format: date-time */ + createdAt: string | null; + /** Format: date-time */ + expiresAt: string | null; + /** @enum {string} */ + status: "unspecified" | "valid" | "voided" | "expired" | "revoked" | "removed" | "unknown"; + /** @enum {string} */ + voidedReason: "unspecified" | "discrepancy" | "governance" | "bond_withdrawn" | "bond_slashed" | "unknown"; + deposit: { + denom: string; + amount: string; + } | null; + /** @enum {string} */ + depositStatus: "unspecified" | "escrowed" | "pending_discrepancy" | "returned_to_auditor" | "slashed" | "unknown"; + auditEscrowId: string; + /** @enum {string} */ + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + }[]; + bond: { + provider: string; + bondedAmount: { + denom: string; + amount: string; + } | null; + requiredForCurrentTier: { + denom: string; + amount: string; + }; + unbondingEntries: { + amount: { + denom: string; + amount: string; + } | null; + /** Format: date-time */ + completionTime: string | null; + }[]; + slashed: boolean; + /** Format: date-time */ + lastSlashTime: string | null; + } | null; + snapshot: { + provider: string; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + snapshotHash: string | null; + resourceSummary: { + totalGpus: number; + totalVcpus: number; + totalMemoryMb: string; + totalStorageMb: string; + activeLeases: number; + softwareVersion: string; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + softwareSignature: string | null; + softwareIdentity: { + version: string; + artifactRef: string; + digestAlgorithm: string; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + digest: string | null; + signatureType: string; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + signature: string | null; + signatureRef: string; + publicKeyRef: string; + } | null; + } | null; + /** Format: date-time */ + postedAt: string | null; + /** Format: date-time */ + snapshotTimestamp: string | null; + /** Format: date-time */ + complianceDeadline: string | null; + suspended: boolean; + } | null; + grace: { + id: string; + provider: string; + /** @enum {string} */ + preservedTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + sourceDiscrepancyIds: string[]; + /** Format: date-time */ + startedAt: string | null; + /** Format: date-time */ + expiresAt: string | null; + /** @enum {string} */ + status: "unspecified" | "active" | "expired" | "terminated" | "unknown"; + } | null; + auditEscrows: { + id: string; + provider: string; + consumedByAuditor: string | null; + /** @enum {string} */ + requestedTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + requestedCapabilities: ( + | "unspecified" + | "tee_hardware_attestation" + | "confidential_computing" + | "persistent_storage" + | "bare_metal" + | "unknown" + )[]; + fee: { + denom: string; + amount: string; + } | null; + /** @enum {string} */ + feeStatus: "unspecified" | "escrowed" | "released_to_auditor" | "returned_to_provider" | "unknown"; + providerDeposit: { + denom: string; + amount: string; + } | null; + /** @enum {string} */ + providerDepositStatus: "unspecified" | "escrowed" | "returned_to_provider" | "slashed" | "unknown"; + /** @enum {string} */ + status: "unspecified" | "open" | "consumed" | "cancelled" | "expired" | "settled" | "unknown"; + /** Format: date-time */ + openedAt: string | null; + /** Format: date-time */ + consumedAt: string | null; + /** Format: date-time */ + expiresAt: string | null; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + metadataHash: string | null; + /** @enum {string} */ + settlementReason: "unspecified" | "cancelled_unconsumed" | "expired_unconsumed" | "provider_fault" | "no_fault" | "unknown"; + /** @enum {string} */ + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + }[]; + maintenance: { + record: { + id: string; + provider: string; + /** @enum {string} */ + maintenanceType: "unspecified" | "planned" | "emergency" | "security" | "network" | "capacity" | "unknown"; + /** Format: date-time */ + startsAt: string | null; + /** Format: date-time */ + expectedEndsAt: string | null; + /** Format: date-time */ + openedAt: string | null; + /** Format: date-time */ + closedAt: string | null; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + metadataHash: string | null; + } | null; + /** @enum {string} */ + status: "unspecified" | "scheduled" | "active" | "elapsed" | "closed" | "unknown"; + }[]; + discrepancies: { + id: string; + provider: string; + auditorA: string; + /** @enum {string} */ + auditorATier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + auditorB: string; + /** @enum {string} */ + auditorBTier: "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + /** Format: date-time */ + timestamp: string | null; + /** @enum {string} */ + resolutionStatus: "unspecified" | "pending" | "resolved" | "timed_out" | "unknown"; + resolutionProposalId: string; + graceRecordId: string; + /** @enum {string} */ + resolutionReason: + | "unspecified" + | "auditor_a_correct" + | "auditor_b_correct" + | "both_auditors_wrong" + | "provider_fault" + | "shared_fault" + | "evidence_inconclusive" + | "governance_timeout_review" + | "unknown"; + /** @enum {string} */ + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + /** @description Base64-encoded bytes, or null when the chain field is empty */ + resolutionEvidenceHash: string | null; + }[]; + /** Format: date-time */ + observedAt: string; + observedHeight: string; + completeness: { + params: boolean; + attestations: boolean; + graces: boolean; + snapshot: boolean; + bond: boolean; + auditEscrows: boolean; + maintenance: boolean; + discrepancies: boolean; + }; + } | null; uptime: { id: string; isOnline: boolean; @@ -8127,7 +8524,7 @@ export interface operations { * @description Deposit in dollars. Ignored when managed deposits are enabled for your account, in which case the platform sets it automatically; otherwise it is required. */ deposit?: number; - /** @description Optional runtime limit in hours, counted from lease start. Automatic funding keeps the deployment running until the limit, then stops so the deployment drains and closes. Omit for always-on funding. */ + /** @description Optional runtime limit in hours (1 to 48), counted from lease start. Automatic funding keeps the deployment running until the limit, then the deployment is closed automatically and unused funds are returned. Extend a limit with PATCH /v2/deployment-settings/{dseq}. Omit for always-on funding. */ runtimeLimitHours?: number; }; }; @@ -8627,6 +9024,17 @@ export interface operations { */ value: string; }[]; + verification?: { + minTier: 0 | 1 | 2 | 3 | 4; + /** @default [] */ + requiredCapabilities?: (1 | 2 | 3 | 4)[]; + /** @default [] */ + requiredAuditors?: string[]; + /** @default 0 */ + auditorMode?: 0 | 1 | 2; + /** @default 0 */ + minAuditorCount?: number; + }; }; /** @description Resource units with replica counts */ resources: { @@ -8709,7 +9117,11 @@ export interface operations { value: string; }[]; }[]; - endpoints?: unknown[]; + endpoints?: { + /** @enum {string} */ + kind?: "SHARED_HTTP" | "RANDOM_PORT" | "LEASED_IP" | "UNRECOGNIZED"; + sequenceNumber?: number | null; + }[]; }; /** * @description Replica count @@ -8771,6 +9183,40 @@ export interface operations { * @example Akash */ organization: string | null; + verification?: + | { + /** @enum {string} */ + outcome: "pass"; + summary: { + /** @enum {integer} */ + bestStatusValidTier: 0 | 1 | 2 | 3 | 4 | -1; + /** @enum {integer} */ + tierGateTier: 0 | 1 | 2 | 3 | 4 | -1; + capabilities: (0 | 1 | 2 | 3 | 4 | -1)[]; + validAttestationCount: number; + validAuditors: string[]; + /** @enum {string} */ + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + observedHeight: string; + }; + } + | { + /** @enum {string} */ + outcome: "not_evaluated"; + incompleteFacts: ("params" | "attestations" | "graces" | "snapshot" | "module_inactive")[]; + summary: { + /** @enum {integer} */ + bestStatusValidTier: 0 | 1 | 2 | 3 | 4 | -1; + /** @enum {integer} */ + tierGateTier: 0 | 1 | 2 | 3 | 4 | -1; + capabilities: (0 | 1 | 2 | 3 | 4 | -1)[]; + validAttestationCount: number; + validAuditors: string[]; + /** @enum {string} */ + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + observedHeight: string; + }; + }; /** @description Per-day downtime over a rolling 7-day window */ incidents: { /** @@ -8786,6 +9232,102 @@ export interface operations { downtimeSeconds: number; }[]; }[]; + exclusions?: { + owner: string; + firstFailure: + | { + /** @enum {string} */ + code: "snapshot_not_posted"; + } + | { + /** @enum {string} */ + code: "snapshot_suspended"; + } + | { + /** @enum {string} */ + code: "snapshot_stale"; + } + | { + /** @enum {string} */ + code: "insufficient_tier"; + /** @enum {integer} */ + actual: 0 | 1 | 2 | 3 | 4 | -1; + /** @enum {integer} */ + required: 0 | 1 | 2 | 3 | 4 | -1; + } + | { + /** @enum {string} */ + code: "missing_capability"; + /** @enum {integer} */ + capability: 0 | 1 | 2 | 3 | 4 | -1; + } + | { + /** @enum {string} */ + code: "insufficient_auditor_count"; + actual: number; + required: number; + } + | { + /** @enum {string} */ + code: "required_auditor_not_found"; + /** @enum {integer} */ + mode: 0 | 1 | 2 | -1; + missing: string[]; + }; + failures: ( + | { + /** @enum {string} */ + code: "snapshot_not_posted"; + } + | { + /** @enum {string} */ + code: "snapshot_suspended"; + } + | { + /** @enum {string} */ + code: "snapshot_stale"; + } + | { + /** @enum {string} */ + code: "insufficient_tier"; + /** @enum {integer} */ + actual: 0 | 1 | 2 | 3 | 4 | -1; + /** @enum {integer} */ + required: 0 | 1 | 2 | 3 | 4 | -1; + } + | { + /** @enum {string} */ + code: "missing_capability"; + /** @enum {integer} */ + capability: 0 | 1 | 2 | 3 | 4 | -1; + } + | { + /** @enum {string} */ + code: "insufficient_auditor_count"; + actual: number; + required: number; + } + | { + /** @enum {string} */ + code: "required_auditor_not_found"; + /** @enum {integer} */ + mode: 0 | 1 | 2 | -1; + missing: string[]; + } + )[]; + summary: { + /** @enum {integer} */ + bestStatusValidTier: 0 | 1 | 2 | 3 | 4 | -1; + /** @enum {integer} */ + tierGateTier: 0 | 1 | 2 | 3 | 4 | -1; + capabilities: (0 | 1 | 2 | 3 | 4 | -1)[]; + validAttestationCount: number; + validAuditors: string[]; + /** @enum {string} */ + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + observedHeight: string; + }; + }[]; }; }; }; diff --git a/packages/database/chainDefinitions.spec.ts b/packages/database/chainDefinitions.spec.ts new file mode 100644 index 0000000000..4a85723cb1 --- /dev/null +++ b/packages/database/chainDefinitions.spec.ts @@ -0,0 +1,35 @@ +import { netConfig } from "@akashnetwork/net"; +import { describe, expect, it } from "vitest"; + +import { chainDefinitions, resolveAkashSandboxChainOverride } from "./chainDefinitions"; + +describe("Akash sandbox chain definition", () => { + it("preserves the standard sandbox configuration by default", () => { + expect(chainDefinitions.akashSandbox).toMatchObject({ + chainId: "sandbox-2", + rpcNodes: netConfig.getAllBaseRpcUrls("sandbox"), + apiUrl: netConfig.getBaseAPIUrl("sandbox"), + genesisFileUrl: `https://raw.githubusercontent.com/akash-network/net/main/${netConfig.mapped("sandbox")}/genesis.json` + }); + }); + + it("parses a complete private sandbox configuration", () => { + expect( + resolveAkashSandboxChainOverride({ + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + }) + ).toEqual({ + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + }); + }); + + it("rejects a partial private sandbox configuration", () => { + expect(() => resolveAkashSandboxChainOverride({ chainId: "aep-86" })).toThrow("must be set together"); + }); +}); diff --git a/packages/database/chainDefinitions.ts b/packages/database/chainDefinitions.ts index ef8b9fa752..fa6f894b4e 100644 --- a/packages/database/chainDefinitions.ts +++ b/packages/database/chainDefinitions.ts @@ -16,11 +16,29 @@ import { Provider, ProviderAttribute, ProviderAttributeSignature, + ProviderMaintenance, ProviderSnapshot, ProviderSnapshotNode, ProviderSnapshotNodeCPU, ProviderSnapshotNodeGPU, - ProviderSnapshotStorage + ProviderSnapshotStorage, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget } from "./dbSchemas/akash"; import type { Block, Message } from "./dbSchemas/base"; dotenv.config({ path: ".env.local" }); @@ -36,8 +54,10 @@ export const IBC_USDC_DENOMS = [ ]; export interface ChainDef { + chainId: string; code: string; rpcNodes: string[]; + apiUrl: string; cosmosDirectoryId: string; connectionString: string | undefined; genesisFileUrl: string; @@ -54,17 +74,62 @@ export interface ChainDef { customModels?: ModelCtor>[]; } +interface AkashSandboxChainOverrideInput { + chainId?: string; + genesisUrl?: string; + restApiUrl?: string; + rpcUrl?: string; +} + +export interface AkashSandboxChainOverride { + chainId: string; + genesisUrl: string; + restApiUrl: string; + rpcUrl: string; +} + +const AKASH_SANDBOX_OVERRIDE_ENV_NAMES = [ + "NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID", + "NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL", + "NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL", + "NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL" +] as const; + +export function resolveAkashSandboxChainOverride(input: AkashSandboxChainOverrideInput): AkashSandboxChainOverride | undefined { + const configuredValues = Object.values(input).filter(value => Boolean(value?.trim())); + if (configuredValues.length === 0) return undefined; + if (configuredValues.length !== AKASH_SANDBOX_OVERRIDE_ENV_NAMES.length) { + throw new Error(`${AKASH_SANDBOX_OVERRIDE_ENV_NAMES.join(", ")} must be set together`); + } + + return { + chainId: requireValue(input.chainId, AKASH_SANDBOX_OVERRIDE_ENV_NAMES[0]), + rpcUrl: requireHttpUrl(input.rpcUrl, AKASH_SANDBOX_OVERRIDE_ENV_NAMES[1]), + restApiUrl: requireHttpUrl(input.restApiUrl, AKASH_SANDBOX_OVERRIDE_ENV_NAMES[2]), + genesisUrl: requireHttpUrl(input.genesisUrl, AKASH_SANDBOX_OVERRIDE_ENV_NAMES[3]) + }; +} + +const akashSandboxOverride = resolveAkashSandboxChainOverride({ + chainId: process.env.NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID, + rpcUrl: process.env.NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL, + restApiUrl: process.env.NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL, + genesisUrl: process.env.NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL +}); + export const chainDefinitions: { [key: string]: ChainDef } = { akash: { + chainId: "akashnet-2", code: "akash", rpcNodes: netConfig.getAllBaseRpcUrls("mainnet"), + apiUrl: netConfig.getBaseAPIUrl("mainnet"), cosmosDirectoryId: "akash", connectionString: process.env.AKASH_DATABASE_CS, genesisFileUrl: `https://raw.githubusercontent.com/akash-network/net/main/${netConfig.mapped("mainnet")}/genesis.json`, coinGeckoId: "akash-network", logoUrlSVG: "https://raw.githubusercontent.com/cosmos/chain-registry/master/akash/images/akt.svg", logoUrlPNG: "https://console.akash.network/images/chains/akash.png", - customIndexers: ["AkashStatsIndexer", "BmeIndexer"], + customIndexers: ["AkashStatsIndexer", "BmeIndexer", "ProviderVerificationIndexer"], bech32Prefix: "akash", denom: "akt", udenom: "uakt", @@ -81,6 +146,7 @@ export const chainDefinitions: { [key: string]: ChainDef } = { Provider, ProviderAttribute, ProviderAttributeSignature, + ProviderMaintenance, ProviderSnapshot, ProviderSnapshotNode, ProviderSnapshotNodeCPU, @@ -88,20 +154,39 @@ export const chainDefinitions: { [key: string]: ChainDef } = { ProviderSnapshotStorage, BmeLedgerRecord, BmeRawEvent, - BmeStatusChange + BmeStatusChange, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget ] }, get akashTestnet() { return { + chainId: "testnet-8", code: "akash-testnet", rpcNodes: netConfig.getAllBaseRpcUrls("testnet"), + apiUrl: netConfig.getBaseAPIUrl("testnet"), cosmosDirectoryId: "akash", connectionString: process.env.AKASH_TESTNET_DATABASE_CS, genesisFileUrl: `https://raw.githubusercontent.com/akash-network/net/main/${netConfig.mapped("testnet")}/genesis.json`, coinGeckoId: "akash-network", logoUrlSVG: "https://raw.githubusercontent.com/cosmos/chain-registry/master/akash/images/akt.svg", logoUrlPNG: "https://console.akash.network/images/chains/akash.png", - customIndexers: ["AkashStatsIndexer", "BmeIndexer"], + customIndexers: ["AkashStatsIndexer", "BmeIndexer", "ProviderVerificationIndexer"], bech32Prefix: "akash", denom: "act", udenom: "uact", @@ -118,6 +203,7 @@ export const chainDefinitions: { [key: string]: ChainDef } = { Provider, ProviderAttribute, ProviderAttributeSignature, + ProviderMaintenance, ProviderSnapshot, ProviderSnapshotNode, ProviderSnapshotNodeCPU, @@ -125,20 +211,39 @@ export const chainDefinitions: { [key: string]: ChainDef } = { ProviderSnapshotStorage, BmeLedgerRecord, BmeRawEvent, - BmeStatusChange + BmeStatusChange, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget ] }; }, akashSandbox: { + chainId: akashSandboxOverride?.chainId ?? "sandbox-2", code: "akash-sandbox", - rpcNodes: netConfig.getAllBaseRpcUrls("sandbox"), + rpcNodes: akashSandboxOverride ? [akashSandboxOverride.rpcUrl] : netConfig.getAllBaseRpcUrls("sandbox"), + apiUrl: akashSandboxOverride?.restApiUrl ?? netConfig.getBaseAPIUrl("sandbox"), cosmosDirectoryId: "akash", connectionString: process.env.AKASH_SANDBOX_DATABASE_CS, - genesisFileUrl: `https://raw.githubusercontent.com/akash-network/net/main/${netConfig.mapped("sandbox")}/genesis.json`, + genesisFileUrl: akashSandboxOverride?.genesisUrl ?? `https://raw.githubusercontent.com/akash-network/net/main/${netConfig.mapped("sandbox")}/genesis.json`, coinGeckoId: "akash-network", logoUrlSVG: "https://raw.githubusercontent.com/cosmos/chain-registry/master/akash/images/akt.svg", logoUrlPNG: "https://console.akash.network/images/chains/akash.png", - customIndexers: ["AkashStatsIndexer", "BmeIndexer"], + customIndexers: ["AkashStatsIndexer", "BmeIndexer", "ProviderVerificationIndexer"], bech32Prefix: "akash", denom: "akt", udenom: "uakt", @@ -155,6 +260,7 @@ export const chainDefinitions: { [key: string]: ChainDef } = { Provider, ProviderAttribute, ProviderAttributeSignature, + ProviderMaintenance, ProviderSnapshot, ProviderSnapshotNode, ProviderSnapshotNodeCPU, @@ -162,9 +268,39 @@ export const chainDefinitions: { [key: string]: ChainDef } = { ProviderSnapshotStorage, BmeLedgerRecord, BmeRawEvent, - BmeStatusChange + BmeStatusChange, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget ] } }; export const activeChain = chainDefinitions[process.env.ACTIVE_CHAIN || "akash"]; + +function requireValue(value: string | undefined, name: string): string { + const trimmedValue = value?.trim(); + if (!trimmedValue) throw new Error(`${name} must not be empty`); + return trimmedValue; +} + +function requireHttpUrl(value: string | undefined, name: string): string { + const url = requireValue(value, name); + const protocol = new URL(url).protocol; + if (protocol !== "http:" && protocol !== "https:") throw new Error(`${name} must use http or https`); + return url; +} diff --git a/packages/database/dbSchemas/akash/index.ts b/packages/database/dbSchemas/akash/index.ts index 96a9f0e445..acebab629a 100644 --- a/packages/database/dbSchemas/akash/index.ts +++ b/packages/database/dbSchemas/akash/index.ts @@ -14,6 +14,24 @@ export { Bid } from "./bid"; export { BmeLedgerRecord } from "./bmeLedgerRecord"; export { BmeRawEvent } from "./bmeRawEvent"; export { BmeStatusChange } from "./bmeStatusChange"; +export { ProviderMaintenance } from "./providerMaintenance"; +export { VerificationAttestation } from "./verificationAttestation"; +export { VerificationAttestationCapability } from "./verificationAttestationCapability"; +export { VerificationAuditEscrow } from "./verificationAuditEscrow"; +export { VerificationAuditEscrowCapability } from "./verificationAuditEscrowCapability"; +export { VerificationAuditor } from "./verificationAuditor"; +export { VerificationBlockEvent } from "./verificationBlockEvent"; +export { VerificationDiscrepancy } from "./verificationDiscrepancy"; +export { VerificationGrace } from "./verificationGrace"; +export { VerificationGraceDiscrepancy } from "./verificationGraceDiscrepancy"; +export { VerificationParams } from "./verificationParams"; +export { VerificationProviderBond } from "./verificationProviderBond"; +export { VerificationProviderBondUnbonding } from "./verificationProviderBondUnbonding"; +export { VerificationProviderObservation } from "./verificationProviderObservation"; +export { VerificationProviderSnapshot } from "./verificationProviderSnapshot"; +export { VerificationProviderTierDemotion } from "./verificationProviderTierDemotion"; +export { VerificationProviderTierStream } from "./verificationProviderTierStream"; +export { VerificationReconcileTarget } from "./verificationReconcileTarget"; // Overrides export { AkashBlock } from "./akashBlock"; diff --git a/packages/database/dbSchemas/akash/provider.ts b/packages/database/dbSchemas/akash/provider.ts index 6d98fb8585..fead23352a 100644 --- a/packages/database/dbSchemas/akash/provider.ts +++ b/packages/database/dbSchemas/akash/provider.ts @@ -1,11 +1,19 @@ import { DataTypes } from "sequelize"; -import { BelongsTo, Column, Default, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BelongsTo, Column, Default, HasMany, HasOne, Model, PrimaryKey, Table } from "sequelize-typescript"; import { Required } from "../decorators/requiredDecorator"; import { AkashBlock } from "./akashBlock"; import { ProviderAttribute } from "./providerAttribute"; import { ProviderAttributeSignature } from "./providerAttributeSignature"; +import { ProviderMaintenance } from "./providerMaintenance"; // eslint-disable-line import-x/no-cycle import { ProviderSnapshot } from "./providerSnapshot"; +import { VerificationAttestation } from "./verificationAttestation"; // eslint-disable-line import-x/no-cycle +import { VerificationAuditEscrow } from "./verificationAuditEscrow"; // eslint-disable-line import-x/no-cycle +import { VerificationDiscrepancy } from "./verificationDiscrepancy"; // eslint-disable-line import-x/no-cycle +import { VerificationGrace } from "./verificationGrace"; // eslint-disable-line import-x/no-cycle +import { VerificationProviderBond } from "./verificationProviderBond"; // eslint-disable-line import-x/no-cycle +import { VerificationProviderObservation } from "./verificationProviderObservation"; // eslint-disable-line import-x/no-cycle +import { VerificationProviderSnapshot } from "./verificationProviderSnapshot"; // eslint-disable-line import-x/no-cycle /** * Provider model for Akash @@ -147,6 +155,14 @@ export class Provider extends Model { * The provider snapshots associated with the provider */ @HasMany(() => ProviderSnapshot, "owner") providerSnapshots!: ProviderSnapshot[]; + @HasMany(() => VerificationAttestation, { foreignKey: "provider", constraints: false }) verificationAttestations!: VerificationAttestation[]; + @HasMany(() => VerificationAuditEscrow, { foreignKey: "provider", constraints: false }) verificationAuditEscrows!: VerificationAuditEscrow[]; + @HasMany(() => VerificationDiscrepancy, { foreignKey: "provider", constraints: false }) verificationDiscrepancies!: VerificationDiscrepancy[]; + @HasMany(() => VerificationGrace, { foreignKey: "provider", constraints: false }) verificationGraceRecords!: VerificationGrace[]; + @HasMany(() => ProviderMaintenance, { foreignKey: "provider", constraints: false }) maintenanceRecords!: ProviderMaintenance[]; + @HasOne(() => VerificationProviderBond, { foreignKey: "provider", constraints: false }) verificationBond?: VerificationProviderBond; + @HasOne(() => VerificationProviderObservation, { foreignKey: "provider", constraints: false }) verificationObservation?: VerificationProviderObservation; + @HasOne(() => VerificationProviderSnapshot, { foreignKey: "provider", constraints: false }) verificationSnapshot?: VerificationProviderSnapshot; /** * The block at which the provider was created */ diff --git a/packages/database/dbSchemas/akash/providerMaintenance.ts b/packages/database/dbSchemas/akash/providerMaintenance.ts new file mode 100644 index 0000000000..34e5d77d53 --- /dev/null +++ b/packages/database/dbSchemas/akash/providerMaintenance.ts @@ -0,0 +1,26 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "provider_maintenance", + underscored: true, + indexes: [{ fields: ["provider", "status"] }, { fields: ["starts_at", "expected_ends_at"] }] +}) +export class ProviderMaintenance extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) id!: string; + @Required @Column maintenanceType!: number; + @Required @Column(DataTypes.DATE) startsAt!: Date; + @Required @Column(DataTypes.DATE) expectedEndsAt!: Date; + @Required @Column(DataTypes.DATE) openedAt!: Date; + @Column(DataTypes.DATE) closedAt?: Date; + @Column(DataTypes.BLOB) metadataHash?: Buffer; + @Required @Column status!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; +} diff --git a/packages/database/dbSchemas/akash/verificationAttestation.ts b/packages/database/dbSchemas/akash/verificationAttestation.ts new file mode 100644 index 0000000000..fb316c055a --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationAttestation.ts @@ -0,0 +1,35 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle +import { VerificationAuditor } from "./verificationAuditor"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_attestation", + underscored: true, + indexes: [{ fields: ["provider", "status", "tier"] }, { fields: ["expires_at", "status"] }, { fields: ["audit_escrow_id"] }] +}) +export class VerificationAttestation extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @PrimaryKey @Column auditor!: string; + @Required @Column tier!: number; + @Required @Column(DataTypes.BLOB) evidenceHash!: Buffer; + @Required @Column feeDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) feeAmount!: string; + @Required @Column feeStatus!: number; + @Required @Column(DataTypes.DATE) createdAt!: Date; + @Required @Column(DataTypes.DATE) expiresAt!: Date; + @Required @Column status!: number; + @Required @Column voidedReason!: number; + @Required @Column depositDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) depositAmount!: string; + @Required @Column depositStatus!: number; + @Required @Column(DataTypes.DECIMAL(20, 0)) auditEscrowId!: string; + @Required @Column faultAttribution!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; + @BelongsTo(() => VerificationAuditor, { foreignKey: "auditor", targetKey: "address", constraints: false }) auditorRecord!: VerificationAuditor; +} diff --git a/packages/database/dbSchemas/akash/verificationAttestationCapability.ts b/packages/database/dbSchemas/akash/verificationAttestationCapability.ts new file mode 100644 index 0000000000..99357fef4d --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationAttestationCapability.ts @@ -0,0 +1,17 @@ +import { DataTypes } from "sequelize"; +import { Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; + +@Table({ + tableName: "verification_attestation_capability", + underscored: true, + indexes: [{ fields: ["capability", "provider"] }] +}) +export class VerificationAttestationCapability extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @PrimaryKey @Column auditor!: string; + @Required @PrimaryKey @Column capability!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; +} diff --git a/packages/database/dbSchemas/akash/verificationAuditEscrow.ts b/packages/database/dbSchemas/akash/verificationAuditEscrow.ts new file mode 100644 index 0000000000..96c22d6413 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationAuditEscrow.ts @@ -0,0 +1,36 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle +import { VerificationAuditEscrowCapability } from "./verificationAuditEscrowCapability"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_audit_escrow", + underscored: true, + indexes: [{ fields: ["provider", "status"] }, { fields: ["expires_at", "status"] }] +}) +export class VerificationAuditEscrow extends Model { + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) id!: string; + @Required @Column provider!: string; + @Required @Column consumedByAuditor!: string; + @Required @Column requestedTier!: number; + @Required @Column feeDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) feeAmount!: string; + @Required @Column feeStatus!: number; + @Required @Column providerDepositDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) providerDepositAmount!: string; + @Required @Column providerDepositStatus!: number; + @Required @Column status!: number; + @Required @Column(DataTypes.DATE) openedAt!: Date; + @Column(DataTypes.DATE) consumedAt?: Date; + @Required @Column(DataTypes.DATE) expiresAt!: Date; + @Column(DataTypes.BLOB) metadataHash?: Buffer; + @Required @Column settlementReason!: number; + @Required @Column faultAttribution!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; + @HasMany(() => VerificationAuditEscrowCapability, "auditEscrowId") capabilities!: VerificationAuditEscrowCapability[]; +} diff --git a/packages/database/dbSchemas/akash/verificationAuditEscrowCapability.ts b/packages/database/dbSchemas/akash/verificationAuditEscrowCapability.ts new file mode 100644 index 0000000000..c2ad5e84cd --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationAuditEscrowCapability.ts @@ -0,0 +1,19 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { VerificationAuditEscrow } from "./verificationAuditEscrow"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_audit_escrow_capability", + underscored: true, + indexes: [{ fields: ["capability"] }] +}) +export class VerificationAuditEscrowCapability extends Model { + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) auditEscrowId!: string; + @Required @PrimaryKey @Column capability!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => VerificationAuditEscrow, "auditEscrowId") auditEscrow!: VerificationAuditEscrow; +} diff --git a/packages/database/dbSchemas/akash/verificationAuditor.ts b/packages/database/dbSchemas/akash/verificationAuditor.ts new file mode 100644 index 0000000000..5ebda7d80e --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationAuditor.ts @@ -0,0 +1,28 @@ +import { DataTypes } from "sequelize"; +import { Column, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { VerificationAttestation } from "./verificationAttestation"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_auditor", + underscored: true, + indexes: [{ fields: ["status"] }, { fields: ["renewal_deadline"] }] +}) +export class VerificationAuditor extends Model { + @Required @PrimaryKey @Column address!: string; + @Required @Column status!: number; + @Required @Column maxAttestationTier!: number; + @Required @Column bondDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) bondAmount!: string; + @Required @Column bondStatus!: number; + @Column(DataTypes.BLOB) metadataHash?: Buffer; + @Required @Column(DataTypes.DATE) registeredAt!: Date; + @Required @Column(DataTypes.DATE) renewalDeadline!: Date; + @Required @Column(DataTypes.DECIMAL(20, 0)) discrepancyCount!: string; + @Column(DataTypes.DATE) bondUnbondingCompletionTime?: Date; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @HasMany(() => VerificationAttestation, { foreignKey: "auditor", constraints: false }) attestations!: VerificationAttestation[]; +} diff --git a/packages/database/dbSchemas/akash/verificationBlockEvent.ts b/packages/database/dbSchemas/akash/verificationBlockEvent.ts new file mode 100644 index 0000000000..1d054207bd --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationBlockEvent.ts @@ -0,0 +1,21 @@ +import { DataTypes, UUIDV4 } from "sequelize"; +import { BelongsTo, Column, Default, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Block } from "../base/block"; +import { Required } from "../decorators/requiredDecorator"; + +/** Durable staging for verification invalidation signals from finalize_block_events. */ +@Table({ + tableName: "verification_block_event", + underscored: true, + indexes: [{ unique: true, fields: ["height", "index"] }, { fields: ["height", "is_processed"] }] +}) +export class VerificationBlockEvent extends Model { + @Required @PrimaryKey @Default(UUIDV4) @Column(DataTypes.UUID) id!: string; + @Required @Column height!: number; + @BelongsTo(() => Block, "height") block!: Block; + @Required @Column index!: number; + @Required @Column type!: string; + @Required @Column(DataTypes.JSONB) data!: Record; + @Required @Default(false) @Column isProcessed!: boolean; +} diff --git a/packages/database/dbSchemas/akash/verificationDiscrepancy.ts b/packages/database/dbSchemas/akash/verificationDiscrepancy.ts new file mode 100644 index 0000000000..f68fa0e448 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationDiscrepancy.ts @@ -0,0 +1,30 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_discrepancy", + underscored: true, + indexes: [{ fields: ["provider", "resolution_status"] }, { fields: ["auditor_a"] }, { fields: ["auditor_b"] }] +}) +export class VerificationDiscrepancy extends Model { + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) id!: string; + @Required @Column provider!: string; + @Required @Column auditorA!: string; + @Required @Column auditorATier!: number; + @Required @Column auditorB!: string; + @Required @Column auditorBTier!: number; + @Required @Column(DataTypes.DATE) detectedAt!: Date; + @Required @Column resolutionStatus!: number; + @Required @Column(DataTypes.DECIMAL(20, 0)) resolutionProposalId!: string; + @Required @Column(DataTypes.DECIMAL(20, 0)) graceRecordId!: string; + @Required @Column resolutionReason!: number; + @Required @Column faultAttribution!: number; + @Column(DataTypes.BLOB) resolutionEvidenceHash?: Buffer; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; +} diff --git a/packages/database/dbSchemas/akash/verificationGrace.ts b/packages/database/dbSchemas/akash/verificationGrace.ts new file mode 100644 index 0000000000..24e9283db9 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationGrace.ts @@ -0,0 +1,25 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle +import { VerificationGraceDiscrepancy } from "./verificationGraceDiscrepancy"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_grace", + underscored: true, + indexes: [{ fields: ["provider", "status"] }, { fields: ["expires_at", "status"] }] +}) +export class VerificationGrace extends Model { + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) id!: string; + @Required @Column provider!: string; + @Required @Column preservedTier!: number; + @Required @Column(DataTypes.DATE) startedAt!: Date; + @Required @Column(DataTypes.DATE) expiresAt!: Date; + @Required @Column status!: number; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; + @HasMany(() => VerificationGraceDiscrepancy, "graceId") sourceDiscrepancies!: VerificationGraceDiscrepancy[]; +} diff --git a/packages/database/dbSchemas/akash/verificationGraceDiscrepancy.ts b/packages/database/dbSchemas/akash/verificationGraceDiscrepancy.ts new file mode 100644 index 0000000000..f1182e83e0 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationGraceDiscrepancy.ts @@ -0,0 +1,21 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { VerificationDiscrepancy } from "./verificationDiscrepancy"; // eslint-disable-line import-x/no-cycle +import { VerificationGrace } from "./verificationGrace"; + +@Table({ + tableName: "verification_grace_discrepancy", + underscored: true, + indexes: [{ fields: ["discrepancy_id"] }] +}) +export class VerificationGraceDiscrepancy extends Model { + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) graceId!: string; + @Required @PrimaryKey @Column(DataTypes.DECIMAL(20, 0)) discrepancyId!: string; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => VerificationGrace, "graceId") grace!: VerificationGrace; + @BelongsTo(() => VerificationDiscrepancy, "discrepancyId") discrepancy!: VerificationDiscrepancy; +} diff --git a/packages/database/dbSchemas/akash/verificationParams.ts b/packages/database/dbSchemas/akash/verificationParams.ts new file mode 100644 index 0000000000..645b30abb2 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationParams.ts @@ -0,0 +1,15 @@ +import { DataTypes } from "sequelize"; +import { Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; + +@Table({ + tableName: "verification_params", + underscored: true +}) +export class VerificationParams extends Model { + @Required @PrimaryKey @Column(DataTypes.SMALLINT) id!: number; + @Required @Column(DataTypes.JSONB) params!: Record; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderBond.ts b/packages/database/dbSchemas/akash/verificationProviderBond.ts new file mode 100644 index 0000000000..50dad4787d --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderBond.ts @@ -0,0 +1,25 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, HasMany, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle +import { VerificationProviderBondUnbonding } from "./verificationProviderBondUnbonding"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_provider_bond", + underscored: true +}) +export class VerificationProviderBond extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @Column bondedDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) bondedAmount!: string; + @Required @Column requiredForCurrentTierDenom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) requiredForCurrentTierAmount!: string; + @Required @Column slashed!: boolean; + @Column(DataTypes.DATE) lastSlashTime?: Date; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; + @HasMany(() => VerificationProviderBondUnbonding, "provider") unbondingEntries!: VerificationProviderBondUnbonding[]; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderBondUnbonding.ts b/packages/database/dbSchemas/akash/verificationProviderBondUnbonding.ts new file mode 100644 index 0000000000..4fe5846345 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderBondUnbonding.ts @@ -0,0 +1,22 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { VerificationProviderBond } from "./verificationProviderBond"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_provider_bond_unbonding", + underscored: true, + indexes: [{ fields: ["completion_time"] }] +}) +export class VerificationProviderBondUnbonding extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @PrimaryKey @Column entryIndex!: number; + @Required @Column denom!: string; + @Required @Column(DataTypes.DECIMAL(30, 0)) amount!: string; + @Required @Column(DataTypes.DATE) completionTime!: Date; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => VerificationProviderBond, "provider") providerBond!: VerificationProviderBond; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderObservation.ts b/packages/database/dbSchemas/akash/verificationProviderObservation.ts new file mode 100644 index 0000000000..2676e2f6c7 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderObservation.ts @@ -0,0 +1,20 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_provider_observation", + underscored: true +}) +export class VerificationProviderObservation extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + @Required @Column effectiveTier!: number; + @Required @Column maxPlacementTier!: number; + @Required @Column snapshotState!: string; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderSnapshot.ts b/packages/database/dbSchemas/akash/verificationProviderSnapshot.ts new file mode 100644 index 0000000000..a6dd973fcd --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderSnapshot.ts @@ -0,0 +1,38 @@ +import { DataTypes } from "sequelize"; +import { BelongsTo, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; // eslint-disable-line import-x/no-cycle + +@Table({ + tableName: "verification_provider_snapshot", + underscored: true, + indexes: [{ fields: ["compliance_deadline", "suspended"] }, { fields: ["snapshot_timestamp"] }] +}) +export class VerificationProviderSnapshot extends Model { + @Required @PrimaryKey @Column provider!: string; + @Required @Column(DataTypes.BLOB) snapshotHash!: Buffer; + @Required @Column totalGpus!: number; + @Required @Column totalVcpus!: number; + @Required @Column(DataTypes.DECIMAL(20, 0)) totalMemoryMb!: string; + @Required @Column(DataTypes.DECIMAL(20, 0)) totalStorageMb!: string; + @Required @Column activeLeases!: number; + @Required @Column softwareVersion!: string; + @Column(DataTypes.BLOB) softwareSignature?: Buffer; + @Column softwareIdentityVersion?: string; + @Column softwareArtifactRef?: string; + @Column softwareDigestAlgorithm?: string; + @Column(DataTypes.BLOB) softwareDigest?: Buffer; + @Column softwareSignatureType?: string; + @Column(DataTypes.BLOB) softwareIdentitySignature?: Buffer; + @Column softwareSignatureRef?: string; + @Column softwarePublicKeyRef?: string; + @Required @Column(DataTypes.DATE) postedAt!: Date; + @Required @Column(DataTypes.DATE) snapshotTimestamp!: Date; + @Required @Column(DataTypes.DATE) complianceDeadline!: Date; + @Required @Column suspended!: boolean; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderTierDemotion.ts b/packages/database/dbSchemas/akash/verificationProviderTierDemotion.ts new file mode 100644 index 0000000000..86c58e2be1 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderTierDemotion.ts @@ -0,0 +1,27 @@ +import { DataTypes } from "sequelize"; +import { AutoIncrement, BelongsTo, Column, Default, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; +import { Provider } from "./provider"; + +@Table({ + tableName: "verification_provider_tier_demotion", + underscored: true, + indexes: [{ fields: ["provider", "id"] }, { fields: ["observed_height"] }] +}) +export class VerificationProviderTierDemotion extends Model { + @Required @AutoIncrement @PrimaryKey @Column(DataTypes.BIGINT) id!: string; + @Required @Column provider!: string; + @Required @Column previousEffectiveTier!: number; + @Required @Column previousMaxPlacementTier!: number; + @Required @Column previousSnapshotState!: string; + @Required @Column currentEffectiveTier!: number; + @Required @Column currentMaxPlacementTier!: number; + @Required @Column currentSnapshotState!: string; + @Required @Column(DataTypes.ARRAY(DataTypes.STRING)) changes!: string[]; + @Required @Column observedHeight!: number; + @Required @Column(DataTypes.DATE) observedBlockTime!: Date; + @Required @Default(DataTypes.NOW) @Column(DataTypes.DATE) createdAt!: Date; + + @BelongsTo(() => Provider, { foreignKey: "provider", targetKey: "owner", constraints: false }) providerRecord!: Provider; +} diff --git a/packages/database/dbSchemas/akash/verificationProviderTierStream.ts b/packages/database/dbSchemas/akash/verificationProviderTierStream.ts new file mode 100644 index 0000000000..d795504125 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationProviderTierStream.ts @@ -0,0 +1,13 @@ +import { DataTypes } from "sequelize"; +import { Column, Default, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; + +@Table({ + tableName: "verification_provider_tier_stream", + underscored: true +}) +export class VerificationProviderTierStream extends Model { + @Required @PrimaryKey @Column(DataTypes.SMALLINT) id!: number; + @Required @Default(DataTypes.UUIDV4) @Column(DataTypes.UUID) streamId!: string; +} diff --git a/packages/database/dbSchemas/akash/verificationReconcileTarget.ts b/packages/database/dbSchemas/akash/verificationReconcileTarget.ts new file mode 100644 index 0000000000..db3d3bd8b4 --- /dev/null +++ b/packages/database/dbSchemas/akash/verificationReconcileTarget.ts @@ -0,0 +1,20 @@ +import { DataTypes } from "sequelize"; +import { Column, Default, Model, PrimaryKey, Table } from "sequelize-typescript"; + +import { Required } from "../decorators/requiredDecorator"; + +@Table({ + tableName: "verification_reconcile_target", + underscored: true, + indexes: [{ fields: ["claimed_at", "next_attempt_at"] }, { fields: ["requested_height"] }] +}) +export class VerificationReconcileTarget extends Model { + @Required @PrimaryKey @Column targetType!: string; + @Required @PrimaryKey @Column targetKey!: string; + @Required @Column requestedHeight!: number; + @Required @Default(true) @Column invalidated!: boolean; + @Column(DataTypes.DATE) claimedAt?: Date; + @Required @Default(0) @Column attemptCount!: number; + @Column(DataTypes.DATE) nextAttemptAt?: Date; + @Column(DataTypes.TEXT) lastError?: string; +} diff --git a/packages/database/package.json b/packages/database/package.json index 6bfb36073c..c60221f467 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -14,6 +14,8 @@ "scripts": { "format": "prettier --write ./*.{ts,json} **/*.{ts,json}", "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", "validate:types": "tsc -p tsconfig.build.json --noEmit && echo" }, "dependencies": { @@ -23,6 +25,7 @@ "sequelize-typescript": "^2.1.5" }, "devDependencies": { - "@akashnetwork/dev-config": "*" + "@akashnetwork/dev-config": "*", + "vitest": "^4.1.5" } } 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" }, diff --git a/packages/provider-verification/package.json b/packages/provider-verification/package.json new file mode 100644 index 0000000000..904a0c8c51 --- /dev/null +++ b/packages/provider-verification/package.json @@ -0,0 +1,30 @@ +{ + "name": "@akashnetwork/provider-verification", + "version": "0.0.0", + "private": true, + "description": "Shared AEP-86 provider verification policy evaluation", + "license": "Apache-2.0", + "author": "Akash Network", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "default": "./src/index.ts" + } + }, + "main": "src/index.ts", + "scripts": { + "format": "prettier --write ./*.{ts,json} **/*.{ts,json}", + "lint": "eslint .", + "test": "vitest run", + "validate:types": "tsc -p tsconfig.build.json --noEmit && echo" + }, + "dependencies": { + "@akashnetwork/chain-sdk": "1.0.0-alpha.43" + }, + "devDependencies": { + "@akashnetwork/dev-config": "*", + "typescript": "~5.8.2", + "vitest": "^4.1.5" + } +} diff --git a/packages/provider-verification/src/index.ts b/packages/provider-verification/src/index.ts new file mode 100644 index 0000000000..c6870d7eb0 --- /dev/null +++ b/packages/provider-verification/src/index.ts @@ -0,0 +1,12 @@ +export { + deriveProviderVerificationSummary, + evaluateProviderVerification, + type ProviderVerificationCompleteness, + type ProviderVerificationEvaluation, + type ProviderVerificationFacts, + type ProviderVerificationFailure, + type ProviderVerificationSummary, + type SnapshotComplianceState +} from "./providerVerification.js"; +export * from "./providerVerificationQueryClient.js"; +export * from "./providerTierState.js"; diff --git a/packages/provider-verification/src/providerTierState.spec.ts b/packages/provider-verification/src/providerTierState.spec.ts new file mode 100644 index 0000000000..2aee61be90 --- /dev/null +++ b/packages/provider-verification/src/providerTierState.spec.ts @@ -0,0 +1,149 @@ +import type { AttestationRecord, ProviderSnapshotRecord, ProviderVerificationGraceRecord } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { AttestationStatus, VerificationGraceStatus, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { describe, expect, it } from "vitest"; + +import { deriveProviderTierState, detectProviderTierDemotion } from "./providerTierState.js"; +import type { ProviderVerificationFacts } from "./providerVerification.js"; + +const observedAt = new Date("2026-08-24T12:00:00.000Z"); + +describe(deriveProviderTierState.name, () => { + it("uses active grace for the effective tier", () => { + expect( + deriveProviderTierState( + facts({ + attestations: [attestation(VerificationTier.verification_tier_identified)], + graces: [grace(VerificationTier.verification_tier_established)], + snapshot: snapshot({ complianceDeadline: new Date("2026-08-25T12:00:00.000Z") }) + }) + ) + ).toEqual({ + effectiveTier: VerificationTier.verification_tier_established, + maxPlacementTier: VerificationTier.verification_tier_established, + snapshotState: "current" + }); + }); + + it.each(["not_posted", "stale", "suspended"] as const)("clamps L2+ placement eligibility to L1 when the snapshot is %s", snapshotState => { + const snapshotByState = { + not_posted: null, + stale: snapshot({ complianceDeadline: observedAt }), + suspended: snapshot({ complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: true }) + }; + + expect( + deriveProviderTierState(facts({ attestations: [attestation(VerificationTier.verification_tier_trusted)], snapshot: snapshotByState[snapshotState] })) + ).toMatchObject({ + effectiveTier: VerificationTier.verification_tier_trusted, + maxPlacementTier: VerificationTier.verification_tier_identified, + snapshotState + }); + }); + + it("does not require a snapshot for L1", () => { + expect(deriveProviderTierState(facts({ attestations: [attestation(VerificationTier.verification_tier_identified)] }))).toMatchObject({ + effectiveTier: VerificationTier.verification_tier_identified, + maxPlacementTier: VerificationTier.verification_tier_identified, + snapshotState: "not_posted" + }); + }); +}); + +describe(detectProviderTierDemotion.name, () => { + it("reports independent tier and snapshot eligibility decreases", () => { + const currentSnapshot = { + effectiveTier: VerificationTier.verification_tier_established, + maxPlacementTier: VerificationTier.verification_tier_established, + snapshotState: "current" as const + }; + + expect( + detectProviderTierDemotion(currentSnapshot, { + ...currentSnapshot, + maxPlacementTier: VerificationTier.verification_tier_identified, + snapshotState: "stale" + }) + ).toEqual(["snapshot_eligibility"]); + expect( + detectProviderTierDemotion(currentSnapshot, { + effectiveTier: VerificationTier.verification_tier_verified, + maxPlacementTier: VerificationTier.verification_tier_verified, + snapshotState: "current" + }) + ).toEqual(["tier_gate", "snapshot_eligibility"]); + }); + + it("does not emit on an upgrade or unchanged state", () => { + const previous = { + effectiveTier: VerificationTier.verification_tier_identified, + maxPlacementTier: VerificationTier.verification_tier_identified, + snapshotState: "not_posted" as const + }; + + expect(detectProviderTierDemotion(previous, previous)).toEqual([]); + expect( + detectProviderTierDemotion(previous, { + effectiveTier: VerificationTier.verification_tier_verified, + maxPlacementTier: VerificationTier.verification_tier_verified, + snapshotState: "current" + }) + ).toEqual([]); + }); +}); + +function facts(overrides: Partial = {}): ProviderVerificationFacts { + return { + attestations: [], + graces: [], + snapshot: null, + completeness: { attestations: true, graces: true, snapshot: true }, + observedAt, + observedHeight: "100", + ...overrides + }; +} + +function attestation(tier: VerificationTier): AttestationRecord { + return { + provider: "akash1provider", + auditor: "akash1auditor", + tier, + capabilities: [], + evidenceHash: new Uint8Array(), + fee: undefined, + feeStatus: 0, + createdAt: undefined, + expiresAt: undefined, + status: AttestationStatus.attestation_status_valid, + voidedReason: 0, + deposit: undefined, + depositStatus: 0, + auditEscrowId: 0n, + faultAttribution: 0 + }; +} + +function grace(preservedTier: VerificationTier): ProviderVerificationGraceRecord { + return { + id: 1n, + provider: "akash1provider", + preservedTier, + sourceDiscrepancyIds: [], + startedAt: undefined, + expiresAt: undefined, + status: VerificationGraceStatus.verification_grace_status_active + }; +} + +function snapshot(overrides: Partial = {}): ProviderSnapshotRecord { + return { + provider: "akash1provider", + snapshotHash: new Uint8Array(), + resourceSummary: undefined, + postedAt: undefined, + snapshotTimestamp: undefined, + complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), + suspended: false, + ...overrides + }; +} diff --git a/packages/provider-verification/src/providerTierState.ts b/packages/provider-verification/src/providerTierState.ts new file mode 100644 index 0000000000..a5ff93b7c6 --- /dev/null +++ b/packages/provider-verification/src/providerTierState.ts @@ -0,0 +1,32 @@ +import { VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; + +import { deriveProviderVerificationSummary, type ProviderVerificationFacts, type SnapshotComplianceState } from "./providerVerification.js"; + +export type ProviderTierDemotionChange = "tier_gate" | "snapshot_eligibility"; + +export interface ProviderTierState { + effectiveTier: VerificationTier; + maxPlacementTier: VerificationTier; + snapshotState: SnapshotComplianceState; +} + +export function deriveProviderTierState(facts: ProviderVerificationFacts): ProviderTierState { + const summary = deriveProviderVerificationSummary(facts); + const maxPlacementTier = + summary.tierGateTier < VerificationTier.verification_tier_verified || summary.snapshotState === "current" + ? summary.tierGateTier + : VerificationTier.verification_tier_identified; + + return { + effectiveTier: summary.tierGateTier, + maxPlacementTier, + snapshotState: summary.snapshotState + }; +} + +export function detectProviderTierDemotion(previous: ProviderTierState, current: ProviderTierState): ProviderTierDemotionChange[] { + const changes: ProviderTierDemotionChange[] = []; + if (current.effectiveTier < previous.effectiveTier) changes.push("tier_gate"); + if (current.maxPlacementTier < previous.maxPlacementTier) changes.push("snapshot_eligibility"); + return changes; +} diff --git a/packages/provider-verification/src/providerVerification.spec.ts b/packages/provider-verification/src/providerVerification.spec.ts new file mode 100644 index 0000000000..7fe6856d7a --- /dev/null +++ b/packages/provider-verification/src/providerVerification.spec.ts @@ -0,0 +1,267 @@ +import type { AttestationRecord, ProviderVerificationGraceRecord, VerificationRequirement } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { + AttestationStatus, + AuditorSelectionMode, + CapabilityFlag, + VerificationGraceStatus, + VerificationTier +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { describe, expect, it } from "vitest"; + +import { deriveProviderVerificationSummary, evaluateProviderVerification, type ProviderVerificationFacts } from "./providerVerification.js"; + +const observedAt = new Date("2026-08-24T12:00:00.000Z"); + +describe("deriveProviderVerificationSummary", () => { + it("matches the market keeper by using stored-valid attestations regardless of auditor lifecycle or expires_at", () => { + const facts = createFacts({ + attestations: [ + createAttestation({ auditor: "auditor-b", tier: VerificationTier.verification_tier_verified, capabilities: [CapabilityFlag.capability_bare_metal] }), + createAttestation({ + auditor: "auditor-a", + tier: VerificationTier.verification_tier_established, + capabilities: [CapabilityFlag.capability_persistent_storage] + }), + createAttestation({ + auditor: "auditor-c", + status: AttestationStatus.attestation_status_expired, + tier: VerificationTier.verification_tier_trusted, + capabilities: [CapabilityFlag.capability_confidential_computing] + }) + ] + }); + + expect(deriveProviderVerificationSummary(facts)).toMatchObject({ + bestStatusValidTier: VerificationTier.verification_tier_established, + tierGateTier: VerificationTier.verification_tier_established, + capabilities: [CapabilityFlag.capability_persistent_storage, CapabilityFlag.capability_bare_metal], + validAttestationCount: 2, + validAuditors: ["auditor-a", "auditor-b"] + }); + }); + + it("uses active grace only for the tier gate", () => { + const facts = createFacts({ + attestations: [createAttestation({ tier: VerificationTier.verification_tier_identified, capabilities: [CapabilityFlag.capability_persistent_storage] })], + graces: [createGrace(VerificationTier.verification_tier_established)] + }); + + expect(deriveProviderVerificationSummary(facts)).toMatchObject({ + bestStatusValidTier: VerificationTier.verification_tier_identified, + tierGateTier: VerificationTier.verification_tier_established, + capabilities: [CapabilityFlag.capability_persistent_storage] + }); + }); +}); + +describe("evaluateProviderVerification", () => { + it("does not enforce a vacuous requirement", () => { + const result = evaluateProviderVerification({ + moduleActive: null, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_unspecified }), + facts: createFacts() + }); + + expect(result.outcome).toBe("pass"); + }); + + it("does not enforce verification while the chain module is inactive", () => { + const result = evaluateProviderVerification({ + moduleActive: false, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_trusted }), + facts: createFacts({ completeness: { attestations: false, graces: false, snapshot: false } }) + }); + + expect(result.outcome).toBe("pass"); + }); + + it("returns unknown instead of excluding a provider when required facts are incomplete", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_verified }), + facts: createFacts({ completeness: { attestations: true, graces: false, snapshot: false } }) + }); + + expect(result).toMatchObject({ outcome: "unknown", incompleteFacts: ["graces", "snapshot"] }); + }); + + it("checks snapshot before tier and retains the full failure set", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + minAuditorCount: 2 + }), + facts: createFacts() + }); + + expect(result.outcome).toBe("fail"); + if (result.outcome !== "fail") throw new Error("expected failure"); + expect(result.firstFailure.code).toBe("snapshot_not_posted"); + expect(result.failures.map(failure => failure.code)).toEqual([ + "snapshot_not_posted", + "insufficient_tier", + "missing_capability", + "insufficient_auditor_count" + ]); + }); + + it("matches capability union and tier-qualified auditor semantics", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage, CapabilityFlag.capability_bare_metal], + requiredAuditors: ["auditor-l2", "auditor-l1"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_any, + minAuditorCount: 1 + }), + facts: createFacts({ + attestations: [ + createAttestation({ auditor: "auditor-l2", tier: VerificationTier.verification_tier_verified, capabilities: [CapabilityFlag.capability_bare_metal] }), + createAttestation({ + auditor: "auditor-l1", + tier: VerificationTier.verification_tier_identified, + capabilities: [CapabilityFlag.capability_persistent_storage] + }) + ], + snapshot: { complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: false } + }) + }); + + expect(result).toMatchObject({ outcome: "pass", qualifiedAuditors: ["auditor-l2"] }); + }); + + it("treats unspecified auditor mode as any and reports all missing auditors for all mode", () => { + const facts = createFacts({ attestations: [createAttestation({ auditor: "auditor-a" })] }); + const anyResult = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ requiredAuditors: ["auditor-a", "auditor-b"] }), + facts + }); + const allResult = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ requiredAuditors: ["auditor-a", "auditor-b"], auditorMode: AuditorSelectionMode.auditor_selection_mode_all }), + facts + }); + + expect(anyResult.outcome).toBe("pass"); + expect(allResult).toMatchObject({ + outcome: "fail", + firstFailure: { code: "required_auditor_not_found", missing: ["auditor-b"] } + }); + }); + + it("does not add a provider-bond check that the market BidFilter does not perform", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_verified }), + facts: createFacts({ + attestations: [createAttestation({ tier: VerificationTier.verification_tier_verified })], + snapshot: { complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: false } + }) + }); + + expect(result.outcome).toBe("pass"); + }); + + it("uses the snapshot compliance deadline even before suspension is persisted", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_verified }), + facts: createFacts({ + attestations: [createAttestation({ tier: VerificationTier.verification_tier_verified })], + snapshot: { complianceDeadline: observedAt, suspended: false } + }) + }); + + expect(result).toMatchObject({ outcome: "fail", firstFailure: { code: "snapshot_stale" } }); + }); + + it("does not let an active grace bypass L2 snapshot suspension", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_verified }), + facts: createFacts({ + graces: [createGrace(VerificationTier.verification_tier_verified)], + snapshot: { complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: true } + }) + }); + + expect(result).toMatchObject({ + outcome: "fail", + firstFailure: { code: "snapshot_suspended" }, + failures: [{ code: "snapshot_suspended" }] + }); + }); + + it("keeps eligibility when a partial bond slash leaves the attestation valid", () => { + const result = evaluateProviderVerification({ + moduleActive: true, + requirement: createRequirement({ minTier: VerificationTier.verification_tier_verified }), + facts: createFacts({ + attestations: [createAttestation({ tier: VerificationTier.verification_tier_verified })], + snapshot: { complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: false } + }) + }); + + expect(result.outcome).toBe("pass"); + }); +}); + +function createFacts(overrides: Partial = {}): ProviderVerificationFacts { + return { + attestations: [], + graces: [], + snapshot: null, + completeness: { attestations: true, graces: true, snapshot: true }, + observedAt, + observedHeight: "100", + ...overrides + }; +} + +function createAttestation(overrides: Partial = {}): AttestationRecord { + return { + provider: "provider", + auditor: "auditor", + tier: VerificationTier.verification_tier_identified, + capabilities: [], + evidenceHash: new Uint8Array(), + fee: undefined, + feeStatus: 0, + createdAt: new Date("2025-01-01T00:00:00.000Z"), + expiresAt: new Date("2025-01-02T00:00:00.000Z"), + status: AttestationStatus.attestation_status_valid, + voidedReason: 0, + deposit: undefined, + depositStatus: 0, + auditEscrowId: 1n, + faultAttribution: 0, + ...overrides + }; +} + +function createGrace(preservedTier: VerificationTier): ProviderVerificationGraceRecord { + return { + id: 1n, + provider: "provider", + preservedTier, + sourceDiscrepancyIds: [1n], + startedAt: observedAt, + expiresAt: new Date("2026-08-25T12:00:00.000Z"), + status: VerificationGraceStatus.verification_grace_status_active + }; +} + +function createRequirement(overrides: Partial = {}): VerificationRequirement { + return { + minTier: VerificationTier.verification_tier_identified, + requiredCapabilities: [], + requiredAuditors: [], + auditorMode: AuditorSelectionMode.auditor_selection_mode_unspecified, + minAuditorCount: 0, + ...overrides + }; +} diff --git a/packages/provider-verification/src/providerVerification.ts b/packages/provider-verification/src/providerVerification.ts new file mode 100644 index 0000000000..5384b844b4 --- /dev/null +++ b/packages/provider-verification/src/providerVerification.ts @@ -0,0 +1,191 @@ +import type { + AttestationRecord, + ProviderSnapshotRecord, + ProviderVerificationGraceRecord, + VerificationRequirement +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { + AttestationStatus, + AuditorSelectionMode, + CapabilityFlag, + VerificationGraceStatus, + VerificationTier +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; + +type AttestationFact = Pick; +type GraceFact = Pick; +type SnapshotFact = Pick; + +export interface ProviderVerificationCompleteness { + attestations: boolean; + graces: boolean; + snapshot: boolean; +} + +export interface ProviderVerificationFacts { + attestations: readonly AttestationFact[]; + graces: readonly GraceFact[]; + snapshot: SnapshotFact | null; + completeness: ProviderVerificationCompleteness; + observedAt: Date; + observedHeight: string; +} + +export type SnapshotComplianceState = "unknown" | "not_posted" | "current" | "stale" | "suspended"; + +export interface ProviderVerificationSummary { + bestStatusValidTier: VerificationTier; + tierGateTier: VerificationTier; + capabilities: CapabilityFlag[]; + validAttestationCount: number; + validAuditors: string[]; + snapshotState: SnapshotComplianceState; + observedHeight: string; +} + +export type ProviderVerificationFailure = + | { code: "snapshot_not_posted" } + | { code: "snapshot_suspended" } + | { code: "snapshot_stale" } + | { code: "insufficient_tier"; actual: VerificationTier; required: VerificationTier } + | { code: "missing_capability"; capability: CapabilityFlag } + | { code: "insufficient_auditor_count"; actual: number; required: number } + | { code: "required_auditor_not_found"; mode: AuditorSelectionMode; missing: string[] }; + +interface EvaluationBase { + summary: ProviderVerificationSummary; + qualifiedAuditors: string[]; +} + +export type ProviderVerificationEvaluation = + | (EvaluationBase & { outcome: "pass"; firstFailure: null; failures: [] }) + | (EvaluationBase & { + outcome: "fail"; + firstFailure: ProviderVerificationFailure; + failures: ProviderVerificationFailure[]; + }) + | (EvaluationBase & { outcome: "unknown"; firstFailure: null; failures: []; incompleteFacts: Array }); + +interface EvaluateProviderVerificationInput { + moduleActive: boolean | null; + requirement: VerificationRequirement | null; + facts: ProviderVerificationFacts; +} + +export function deriveProviderVerificationSummary(facts: ProviderVerificationFacts): ProviderVerificationSummary { + const validAttestations = facts.attestations.filter(attestation => attestation.status === AttestationStatus.attestation_status_valid); + const bestStatusValidTier = validAttestations.reduce( + (best, attestation) => betterTier(best, attestation.tier), + VerificationTier.verification_tier_unspecified + ); + const tierGateTier = facts.graces + .filter(grace => grace.status === VerificationGraceStatus.verification_grace_status_active) + .reduce((best, grace) => betterTier(best, grace.preservedTier), bestStatusValidTier); + const capabilities = uniqueSorted( + validAttestations.flatMap(attestation => attestation.capabilities).filter(capability => capability !== CapabilityFlag.capability_unspecified) + ); + const validAuditors = uniqueSorted(validAttestations.map(attestation => attestation.auditor).filter(Boolean)); + + return { + bestStatusValidTier, + tierGateTier, + capabilities, + validAttestationCount: validAttestations.length, + validAuditors, + snapshotState: deriveSnapshotState(facts), + observedHeight: facts.observedHeight + }; +} + +export function evaluateProviderVerification({ moduleActive, requirement, facts }: EvaluateProviderVerificationInput): ProviderVerificationEvaluation { + const summary = deriveProviderVerificationSummary(facts); + const noRequirement = !requirement || requirement.minTier === VerificationTier.verification_tier_unspecified; + + if (noRequirement || moduleActive === false) { + return pass(summary, []); + } + + const incompleteFacts: Array = []; + if (moduleActive === null) incompleteFacts.push("params"); + if (!facts.completeness.attestations) incompleteFacts.push("attestations"); + if (!facts.completeness.graces) incompleteFacts.push("graces"); + if (requiresSnapshot(requirement.minTier) && !facts.completeness.snapshot) incompleteFacts.push("snapshot"); + + const qualifiedAuditors = qualifiedAuditorsForTier(facts.attestations, requirement.minTier); + if (incompleteFacts.length > 0) { + return { outcome: "unknown", firstFailure: null, failures: [], incompleteFacts, qualifiedAuditors, summary }; + } + + const failures: ProviderVerificationFailure[] = []; + if (requiresSnapshot(requirement.minTier)) { + if (summary.snapshotState === "not_posted") failures.push({ code: "snapshot_not_posted" }); + if (summary.snapshotState === "suspended") failures.push({ code: "snapshot_suspended" }); + if (summary.snapshotState === "stale") failures.push({ code: "snapshot_stale" }); + } + + if (summary.tierGateTier < requirement.minTier) { + failures.push({ code: "insufficient_tier", actual: summary.tierGateTier, required: requirement.minTier }); + } + + for (const capability of requirement.requiredCapabilities) { + if (capability !== CapabilityFlag.capability_unspecified && !summary.capabilities.includes(capability)) { + failures.push({ code: "missing_capability", capability }); + } + } + + if (qualifiedAuditors.length < requirement.minAuditorCount) { + failures.push({ code: "insufficient_auditor_count", actual: qualifiedAuditors.length, required: requirement.minAuditorCount }); + } + + const requiredAuditorFailure = evaluateRequiredAuditors(requirement, qualifiedAuditors); + if (requiredAuditorFailure) failures.push(requiredAuditorFailure); + + return failures.length === 0 ? pass(summary, qualifiedAuditors) : { outcome: "fail", firstFailure: failures[0], failures, qualifiedAuditors, summary }; +} + +function deriveSnapshotState(facts: ProviderVerificationFacts): SnapshotComplianceState { + if (!facts.completeness.snapshot) return "unknown"; + if (!facts.snapshot) return "not_posted"; + if (facts.snapshot.suspended) return "suspended"; + if (!facts.snapshot.complianceDeadline || facts.snapshot.complianceDeadline <= facts.observedAt) return "stale"; + return "current"; +} + +function requiresSnapshot(tier: VerificationTier): boolean { + return tier >= VerificationTier.verification_tier_verified; +} + +function betterTier(left: VerificationTier, right: VerificationTier): VerificationTier { + return right > left ? right : left; +} + +function qualifiedAuditorsForTier(attestations: readonly AttestationFact[], minTier: VerificationTier): string[] { + return uniqueSorted( + attestations + .filter(attestation => attestation.status === AttestationStatus.attestation_status_valid && attestation.tier >= minTier) + .map(attestation => attestation.auditor) + .filter(Boolean) + ); +} + +function evaluateRequiredAuditors( + requirement: VerificationRequirement, + qualifiedAuditors: readonly string[] +): Extract | null { + if (requirement.requiredAuditors.length === 0) return null; + + const available = new Set(qualifiedAuditors); + const missing = requirement.requiredAuditors.filter(auditor => !available.has(auditor)); + const allRequired = requirement.auditorMode === AuditorSelectionMode.auditor_selection_mode_all; + const satisfied = allRequired ? missing.length === 0 : requirement.requiredAuditors.some(auditor => available.has(auditor)); + + return satisfied ? null : { code: "required_auditor_not_found", mode: requirement.auditorMode, missing }; +} + +function uniqueSorted(values: readonly T[]): T[] { + return [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +function pass(summary: ProviderVerificationSummary, qualifiedAuditors: string[]): ProviderVerificationEvaluation { + return { outcome: "pass", firstFailure: null, failures: [], qualifiedAuditors, summary }; +} diff --git a/packages/provider-verification/src/providerVerificationQueryClient.spec.ts b/packages/provider-verification/src/providerVerificationQueryClient.spec.ts new file mode 100644 index 0000000000..b56b4ea627 --- /dev/null +++ b/packages/provider-verification/src/providerVerificationQueryClient.spec.ts @@ -0,0 +1,176 @@ +import { SDKError, SDKErrorCode } from "@akashnetwork/chain-sdk/web"; +import { describe, expect, it, vi } from "vitest"; + +import { type ProviderQueries, ProviderVerificationQueryClient, type VerificationQueries } from "./providerVerificationQueryClient.js"; + +const height = "1234"; +const options = { headers: { "x-cosmos-block-height": height } }; + +describe(ProviderVerificationQueryClient.name, () => { + it("pins and paginates global queries at one height", async () => { + const getAuditors = vi + .fn() + .mockResolvedValueOnce({ auditors: [{ address: "akash1auditor1" }], pagination: { nextKey: Uint8Array.from([1]), total: 0n } }) + .mockResolvedValueOnce({ auditors: [{ address: "akash1auditor2" }], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const getDiscrepancies = vi.fn().mockResolvedValue({ discrepancies: [], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const getParams = vi.fn().mockResolvedValue({ params: { verificationModuleActive: true } }); + const client = createClient({ getAuditors, getDiscrepancies, getParams }); + + const result = await client.getGlobalState(height); + + expect(result).toMatchObject({ + auditors: [{ address: "akash1auditor1" }, { address: "akash1auditor2" }], + discrepancies: [], + observedHeight: height, + params: { verificationModuleActive: true } + }); + expect(getParams).toHaveBeenCalledWith({}, options); + expect(getAuditors).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ pagination: expect.objectContaining({ key: new Uint8Array(), limit: 100n }) }), + options + ); + expect(getAuditors).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ pagination: expect.objectContaining({ key: Uint8Array.from([1]), limit: 100n }) }), + options + ); + expect(getDiscrepancies).toHaveBeenCalledWith(expect.any(Object), options); + }); + + it("returns one height-consistent provider state and maps missing optional records to null", async () => { + const getProviderAttestations = vi + .fn() + .mockResolvedValue({ attestations: [{ auditor: "akash1auditor" }], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const getProviderAuditEscrows = vi.fn().mockResolvedValue({ escrows: [], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const getProviderBond = vi.fn().mockResolvedValue({ + bond: { provider: "akash1provider" }, + requiredForCurrentTier: { amount: "500", denom: "uakt" } + }); + const getProviderVerificationGrace = vi.fn().mockResolvedValue({ grace: undefined }); + const getProviderSnapshot = vi.fn().mockResolvedValue({ snapshot: { provider: "akash1provider" } }); + const getProviderMaintenances = vi.fn().mockResolvedValue({ maintenance: [], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const client = createClient( + { + getProviderAttestations, + getProviderAuditEscrows, + getProviderBond, + getProviderVerificationGrace, + getProviderSnapshot + }, + { getProviderMaintenances } + ); + + const result = await client.getProviderState("akash1provider", height); + + expect(result).toMatchObject({ + provider: "akash1provider", + attestations: [{ auditor: "akash1auditor" }], + auditEscrows: [], + bond: { provider: "akash1provider" }, + requiredBondForCurrentTier: { amount: "500", denom: "uakt" }, + grace: null, + snapshot: { provider: "akash1provider" }, + maintenances: [], + observedHeight: height + }); + for (const query of [ + getProviderAttestations, + getProviderAuditEscrows, + getProviderBond, + getProviderVerificationGrace, + getProviderSnapshot, + getProviderMaintenances + ]) { + expect(query.mock.calls[0].at(-1)).toEqual(options); + } + }); + + it("does not hide transport failures", async () => { + const client = createClient({ getProviderBond: vi.fn().mockRejectedValue(new SDKError("unavailable", SDKErrorCode.Unavailable)) }); + + await expect(client.getProviderState("akash1provider", height)).rejects.toMatchObject({ code: SDKErrorCode.Unavailable }); + }); + + it("queries only placement facts for provider screening", async () => { + const getProviderAttestations = vi + .fn() + .mockResolvedValue({ attestations: [{ auditor: "akash1auditor" }], pagination: { nextKey: new Uint8Array(), total: 0n } }); + const getProviderVerificationGrace = vi.fn().mockResolvedValue({ grace: undefined }); + const getProviderSnapshot = vi.fn().mockResolvedValue({ snapshot: { provider: "akash1provider" } }); + const getProviderAuditEscrows = vi.fn().mockRejectedValue(new SDKError("unavailable", SDKErrorCode.Unavailable)); + const getProviderBond = vi.fn(); + const getProviderMaintenances = vi.fn(); + const client = createClient( + { getProviderAttestations, getProviderVerificationGrace, getProviderSnapshot, getProviderAuditEscrows, getProviderBond }, + { getProviderMaintenances } + ); + + await expect(client.getProviderScreeningState("akash1provider", height)).resolves.toMatchObject({ + provider: "akash1provider", + attestations: [{ auditor: "akash1auditor" }], + grace: null, + snapshot: { provider: "akash1provider" }, + observedHeight: height + }); + expect(getProviderAuditEscrows).not.toHaveBeenCalled(); + expect(getProviderBond).not.toHaveBeenCalled(); + expect(getProviderMaintenances).not.toHaveBeenCalled(); + }); + + it("maps a missing provider bond response to absent bond facts", async () => { + const client = createClient({ getProviderBond: vi.fn().mockRejectedValue(new SDKError("missing", SDKErrorCode.NotFound)) }); + + const result = await client.getProviderState("akash1provider", height); + + expect(result.bond).toBeNull(); + expect(result.requiredBondForCurrentTier).toBeNull(); + }); + + it("pins singular reconciliation queries and preserves uint64 identities", async () => { + const getAuditEscrow = vi.fn().mockResolvedValue({ escrow: { id: 9007199254740993n } }); + const getDiscrepancy = vi.fn().mockResolvedValue({ discrepancy: { id: 9007199254740995n } }); + const getAuditor = vi.fn().mockResolvedValue({ auditor: { address: "akash1auditor" } }); + const client = createClient({ getAuditEscrow, getAuditor, getDiscrepancy }); + + await expect(client.getAuditEscrow("9007199254740993", height)).resolves.toMatchObject({ id: 9007199254740993n }); + await expect(client.getDiscrepancy("9007199254740995", height)).resolves.toMatchObject({ id: 9007199254740995n }); + await expect(client.getAuditor("akash1auditor", height)).resolves.toMatchObject({ address: "akash1auditor" }); + + expect(getAuditEscrow).toHaveBeenCalledWith({ id: 9007199254740993n }, options); + expect(getDiscrepancy).toHaveBeenCalledWith({ id: 9007199254740995n }, options); + expect(getAuditor).toHaveBeenCalledWith({ auditor: "akash1auditor" }, options); + }); + + it("rejects malformed uint64 identifiers before calling the SDK", async () => { + const getAuditEscrow = vi.fn(); + const client = createClient({ getAuditEscrow }); + + await expect(client.getAuditEscrow("-1", height)).rejects.toThrow("Invalid uint64 identifier"); + expect(getAuditEscrow).not.toHaveBeenCalled(); + }); +}); + +function createClient(verification: Partial = {}, provider: Partial = {}) { + const emptyPage = { pagination: { nextKey: new Uint8Array(), total: 0n } }; + const verificationQueries = { + getAuditors: vi.fn().mockResolvedValue({ auditors: [], ...emptyPage }), + getAuditor: vi.fn().mockResolvedValue({ auditor: undefined }), + getAuditEscrow: vi.fn().mockResolvedValue({ escrow: undefined }), + getDiscrepancy: vi.fn().mockResolvedValue({ discrepancy: undefined }), + getDiscrepancies: vi.fn().mockResolvedValue({ discrepancies: [], ...emptyPage }), + getParams: vi.fn().mockResolvedValue({ params: undefined }), + getProviderAttestations: vi.fn().mockResolvedValue({ attestations: [], ...emptyPage }), + getProviderAuditEscrows: vi.fn().mockResolvedValue({ escrows: [], ...emptyPage }), + getProviderBond: vi.fn().mockResolvedValue({ bond: undefined, requiredForCurrentTier: undefined }), + getProviderSnapshot: vi.fn().mockResolvedValue({ snapshot: undefined }), + getProviderVerificationGrace: vi.fn().mockResolvedValue({ grace: undefined }), + ...verification + } as VerificationQueries; + const providerQueries = { + getProviderMaintenances: vi.fn().mockResolvedValue({ maintenance: [], ...emptyPage }), + ...provider + } as ProviderQueries; + + return new ProviderVerificationQueryClient(verificationQueries, providerQueries); +} diff --git a/packages/provider-verification/src/providerVerificationQueryClient.ts b/packages/provider-verification/src/providerVerificationQueryClient.ts new file mode 100644 index 0000000000..aed94379fd --- /dev/null +++ b/packages/provider-verification/src/providerVerificationQueryClient.ts @@ -0,0 +1,230 @@ +import type { + AttestationRecord, + AuditEscrowRecord, + AuditorRecord, + DiscrepancyEvent, + ProviderBondRecord, + ProviderSnapshotRecord, + ProviderVerificationGraceRecord, + Verification_Params +} from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { AttestationStatus, AuditEscrowStatus, AuditorStatus, DiscrepancyStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderMaintenanceWithStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import { ProviderMaintenanceStatus } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import type { Coin } from "@akashnetwork/chain-sdk/private-types/cosmos.v1beta1"; +import { createChainNodeWebSDK, SDKError, SDKErrorCode } from "@akashnetwork/chain-sdk/web"; + +type ChainNodeWebSDK = ReturnType; +export type VerificationQueries = Pick< + ChainNodeWebSDK["akash"]["verification"]["v1"], + | "getAuditors" + | "getAuditor" + | "getAuditEscrow" + | "getDiscrepancy" + | "getDiscrepancies" + | "getParams" + | "getProviderAttestations" + | "getProviderAuditEscrows" + | "getProviderBond" + | "getProviderSnapshot" + | "getProviderVerificationGrace" +>; +export type ProviderQueries = Pick; + +interface Pagination { + nextKey: Uint8Array; +} + +export interface ProviderVerificationGlobalState { + params: Verification_Params | null; + auditors: AuditorRecord[]; + discrepancies: DiscrepancyEvent[]; + observedHeight: string; +} + +export interface ProviderVerificationScreeningState { + provider: string; + attestations: AttestationRecord[]; + grace: ProviderVerificationGraceRecord | null; + snapshot: ProviderSnapshotRecord | null; + observedHeight: string; +} + +export interface ProviderVerificationProviderState extends ProviderVerificationScreeningState { + auditEscrows: AuditEscrowRecord[]; + bond: ProviderBondRecord | null; + requiredBondForCurrentTier: Coin | null; + maintenances: ProviderMaintenanceWithStatus[]; +} + +export class ProviderVerificationQueryClient { + constructor( + private readonly verification: VerificationQueries, + private readonly provider: ProviderQueries + ) {} + + async getAuditor(auditor: string, height: string): Promise { + return optionalRecord( + () => this.verification.getAuditor({ auditor }, queryOptions(height)), + response => response.auditor + ); + } + + async getAuditEscrow(id: string, height: string): Promise { + return optionalRecord( + () => this.verification.getAuditEscrow({ id: parseUint64(id) }, queryOptions(height)), + response => response.escrow + ); + } + + async getDiscrepancy(id: string, height: string): Promise { + return optionalRecord( + () => this.verification.getDiscrepancy({ id: parseUint64(id) }, queryOptions(height)), + response => response.discrepancy + ); + } + + async getGlobalState(height: string): Promise { + const options = queryOptions(height); + const [paramsResponse, auditors, discrepancies] = await Promise.all([ + this.verification.getParams({}, options), + collectPages(async pagination => { + const response = await this.verification.getAuditors({ pagination, statusFilter: AuditorStatus.auditor_status_unspecified }, options); + + return { items: response.auditors, pagination: response.pagination }; + }), + collectPages(async pagination => { + const response = await this.verification.getDiscrepancies({ pagination, statusFilter: DiscrepancyStatus.discrepancy_status_unspecified }, options); + + return { items: response.discrepancies, pagination: response.pagination }; + }) + ]); + + return { + params: paramsResponse.params ?? null, + auditors, + discrepancies, + observedHeight: height + }; + } + + async getProviderState(provider: string, height: string): Promise { + const options = queryOptions(height); + const [screening, auditEscrows, bondResponse, maintenances] = await Promise.all([ + this.getProviderScreeningState(provider, height), + collectPages(async pagination => { + const response = await this.verification.getProviderAuditEscrows( + { pagination, provider, statusFilter: AuditEscrowStatus.audit_escrow_status_unspecified }, + options + ); + + return { items: response.escrows, pagination: response.pagination }; + }), + optionalResponse(() => this.verification.getProviderBond({ provider }, options)), + collectPages(async pagination => { + const response = await this.provider.getProviderMaintenances( + { pagination, provider, statusFilter: ProviderMaintenanceStatus.provider_maintenance_status_unspecified }, + options + ); + + return { items: response.maintenance, pagination: response.pagination }; + }) + ]); + + return { + ...screening, + auditEscrows, + bond: bondResponse?.bond ?? null, + requiredBondForCurrentTier: bondResponse?.requiredForCurrentTier ?? null, + maintenances + }; + } + + async getProviderScreeningState(provider: string, height: string): Promise { + const options = queryOptions(height); + const [attestations, grace, snapshot] = await Promise.all([ + collectPages(async pagination => { + const response = await this.verification.getProviderAttestations( + { pagination, provider, statusFilter: AttestationStatus.attestation_status_unspecified }, + options + ); + + return { items: response.attestations, pagination: response.pagination }; + }), + optionalRecord( + () => this.verification.getProviderVerificationGrace({ provider }, options), + response => response.grace + ), + optionalRecord( + () => this.verification.getProviderSnapshot({ provider }, options), + response => response.snapshot + ) + ]); + + return { + provider, + attestations, + grace, + snapshot, + observedHeight: height + }; + } +} + +export function createProviderVerificationQueryClient(baseUrl: string): ProviderVerificationQueryClient { + const sdk = createChainNodeWebSDK({ query: { baseUrl } }); + return new ProviderVerificationQueryClient(sdk.akash.verification.v1, sdk.akash.provider.v1beta4); +} + +function queryOptions(height: string) { + return { headers: { "x-cosmos-block-height": height } }; +} + +async function collectPages( + fetchPage: (pagination: ReturnType) => Promise<{ items: T[]; pagination: Pagination | undefined }> +): Promise { + const items: T[] = []; + let nextKey: Uint8Array = new Uint8Array(); + + do { + const response = await fetchPage(pageRequest(nextKey)); + items.push(...response.items); + nextKey = response.pagination?.nextKey ?? new Uint8Array(); + } while (nextKey.length > 0); + + return items; +} + +function pageRequest(key: Uint8Array) { + return { + key, + offset: 0n, + limit: 100n, + countTotal: false, + reverse: false + }; +} + +function parseUint64(value: string): bigint { + if (!/^\d+$/.test(value)) throw new Error(`Invalid uint64 identifier: ${value}`); + const parsed = BigInt(value); + if (parsed > 18_446_744_073_709_551_615n) throw new Error(`Invalid uint64 identifier: ${value}`); + return parsed; +} + +async function optionalRecord( + query: () => Promise, + select: (response: TResponse) => TRecord | undefined +): Promise { + const response = await optionalResponse(query); + return response ? (select(response) ?? null) : null; +} + +async function optionalResponse(query: () => Promise): Promise { + try { + return await query(); + } catch (error) { + if (error instanceof SDKError && error.code === SDKErrorCode.NotFound) return null; + throw error; + } +} diff --git a/packages/provider-verification/tsconfig.build.json b/packages/provider-verification/tsconfig.build.json new file mode 100644 index 0000000000..8777ec2f91 --- /dev/null +++ b/packages/provider-verification/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "@akashnetwork/dev-config/tsconfig.base-node.json", + "compilerOptions": { + "baseUrl": ".", + "noImplicitAny": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*"] +} diff --git a/packages/provider-verification/tsconfig.json b/packages/provider-verification/tsconfig.json new file mode 100644 index 0000000000..b2145b6eb0 --- /dev/null +++ b/packages/provider-verification/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.build.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/provider-verification/vitest.config.ts b/packages/provider-verification/vitest.config.ts new file mode 100644 index 0000000000..2b80a1ab93 --- /dev/null +++ b/packages/provider-verification/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "provider-verification", + include: ["**/*.spec.ts"], + environment: "node" + } +});